Compare commits

..

26 Commits

Author SHA1 Message Date
m
7e57507a92 feat(t-paliad-175): SmartTimeline Slice 4 — frontend lane render + Client Timeline-Ansicht toggle
shape-timeline.ts gains a third render mode triggered by lanes.length>1:
.smart-timeline-lanes-wrap holds a multiselect lane filter chip-row +
the .smart-timeline-lanes grid (one column per lane, time axis vertical
within each lane). Lanes the user has unchecked render dimmed to
preserve time-axis alignment across the strip; "Alle" pseudo-chip
resets to all selected. Lane mode takes precedence over Track-mode
(different axes — lanes group by direct-child project, tracks group
by CCR-vs-parent on a single Case).

loadTimeline parses the new envelope shape {events, lanes} from
GET /api/projects/{id}/timeline; defensive fallback to the old []
shape during the rolling deploy window. selectedLanes state is
client-side (chip toggles re-render in place without a re-fetch);
disappearing lanes (e.g. CCR child deleted between renders) drop
out of the selection automatically.

Client-level Verlauf toggle (Q12 lock-in): on project.type='client',
the Verlauf tab defaults to the matter-list rendering (simple list
of direct child litigations linking through). Flipping the
"Timeline-Ansicht" toggle (visible only at Client level) swaps to
the lane SmartTimeline. State persists in localStorage per project
so navigating away + back keeps the user's choice. Patent +
Litigation default to the lane view, matching Q12.

Custom-milestone form gains the bubble_up checkbox (§7.2 Q5). When
checked, the milestone surfaces on Patent / Litigation / Client
SmartTimelines via the backend's metadata.bubble_up=true override.
Default OFF for custom_milestone — structural milestones
(counterclaim_created etc.) default ON server-side.

CSS: ~130 lines under .smart-timeline-lanes / -lane / -lane-filter /
-matter-list. Mobile collapses lanes to single-column at ≤640px.

i18n: 12 new keys (DE+EN) under projects.detail.smarttimeline.lane.* /
.client.* / .milestone.bubble_up.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
2026-05-09 16:27:39 +02:00
m
7da8802f9b feat(t-paliad-175): SmartTimeline Slice 4 — backend levelPolicy + lane aggregation + bubble-up
ProjectionService now dispatches on project type per design §5.1:
- Case (and unknown) — full detail flow: parent track + CCR sub-projects
  + parent_context for CCR children. Lanes mirror tracks ("self" +
  "counterclaim:<id>" + "parent_context:<id>").
- Patent / Litigation / Client — lane-aggregated: load direct children
  matching the axis (cases / patents / litigations), gather subtree
  events per lane, apply (kinds, statuses) filter, tag rows with
  LaneID = direct-child id. Calculator skipped at higher levels —
  predicted future is a Case-level concern.

levelPolicy(projectType) returns the (kinds, statuses, lane_axis)
triple. Patent = deadlines+milestones with done/open/overdue;
Litigation + Client = milestones with done.

metadata.bubble_up on paliad.project_events (no schema change — uses
existing jsonb column) overrides the kind/status filter at higher
levels. Defaults per Q5: counterclaim_created / third_party_intervention
/ scope_change → true; custom_milestone → false (user opts in via
form checkbox). insertCounterclaimEvent now sets bubble_up=true on
both parent + child audit rows so the counterclaim_created milestone
surfaces at Patent / Litigation / Client.

Wire shape changed from []TimelineEvent to envelope {events, lanes} —
lane metadata can ride alongside the rows without exceeding header-
size limits when a Client-level projection has many lanes. Frontend
reads .events for the per-row contract and .lanes for parallel-column
rendering. X-Projection-* headers preserved for Slice 1-3 affordances
(lookahead toggle, track chip).

RecordCustomMilestone gains a bubbleUp bool param; persisted to
metadata.bubble_up only when true (so existing rows-without-it keep
the default-off behaviour).

Tests: TestLevelPolicy locks the triple table; TestRowSurvivesPolicy_
BubbleUpOverridesFilter pins the override contract; TestExtractBubbleUp
covers all per-event-type defaults + explicit override paths;
TestChildTypeForAxis pins the axis → type map. Live integration test
TestProjectionService_LevelAggregation_Live walks the patent-level
fixture: bubbled-up milestone surfaces, regular custom_milestone is
filtered, deadlines surface at Patent level.

Refs: docs/design-smart-timeline-2026-05-08.md §5 + §10 Slice 4
Refs: m/paliad#31, t-paliad-175
2026-05-09 16:22:07 +02:00
m
91d3811276 Merge: t-paliad-174 — SmartTimeline Slice 3 (counterclaim sub-project + parallel-track render)
bohr's Slice 3 of the SmartTimeline per docs/design-smart-timeline-2026-05-08.md
§4 + §10. Counterclaims now first-class as sub-project rows with their own
proceeding type, our_side perspective, and timeline; parent's SmartTimeline
renders them as a parallel right-track on desktop + vertical-stacked sub-headers
on mobile.

Backend (commits 306bb11 + 82888de):
- Migration 077: paliad.projects.counterclaim_of nullable FK ON DELETE SET NULL,
  partial index, and a deferred trigger paliad.projects_no_two_level_ccr that
  rejects malformed two-level CCR-of-CCR chains at the schema level. Defense in
  depth — service-side ErrInvalidInput AND schema-side trigger.
- ProjectService.CreateCounterclaim: atomic create with parent-id placement
  (sibling under patent — child.parent_id = parent.parent_id, fallback to
  parent.id when parent has no parent), our_side flipped by default
  (claimant↔defendant; both stays both), proceeding_type defaults to UPC_REV,
  bilateral counterclaim_created audit rows on both parent + child.
- ProjectService.LoadCounterclaimChildrenVisible.
- ProjectionService.For loads CCR children for parent view; emits
  Track='counterclaim:<id>' rows. CCR child's view also loads parent context
  faded (Track='parent_context:<id>') per design §4.5. AvailableTracks
  surfaced via new X-Projection-Tracks response header.
- POST /api/projects/{id}/counterclaim handler.
- Tests: TestDerivedCounterclaimOurSide (9 cases) + TestCreateCounterclaim_Live
  (4 sub-tests).

Frontend (commit 483649d):
- shape-timeline.ts: CSS-grid wrapper renders one column per available track;
  ≤640px media query collapses to vertical stacking with sub-headers per
  track. [Track ▼] dropdown filters Beide / Nur Hauptverfahren /
  Nur Widerklage purely client-side (no re-fetch).
- '+ Eintrag → Widerklage (CCR)' inline form: proceeding-type select
  (UPC_REV default; UPC_CCI for R.49.2.b path), title + CCR case_number,
  'Stimmt nicht?' toggle for our_side override. POSTs and navigates to
  the new child's /projects/<id>.

Locked picks per design §11 (no deviations):
- Q1: counterclaim = sub-project
- Q2: default-flip our_side with toggle
- Q4: sibling-under-patent placement
- Q8: parallel right-track + Track chip + mobile-stack collapse

Verified: go build ./... clean, go vet clean, go test
./internal/services ./internal/handlers passing, bun build clean (2161
keys). Migration 077 dry-run on live DB succeeded + rolled back; tracker
advances 76 → 77 on next deploy boot.

Out of scope (Slice 4): lane-grouped rendering at Patent / Litigation /
Client levels; 'Timeline-Ansicht' Client toggle; off-script bubble-up.

Sequence enforcement (#31, Slice 2) keeps working independently per track
— anchoring SoD on parent rejects without parent's SoC, same for the CCR
chain on its own. Cross-track is correctly NOT enforced.
2026-05-09 16:09:24 +02:00
m
483649d9d2 feat(t-paliad-174): SmartTimeline Slice 3 — frontend parallel-track render + CCR creation modal
shape-timeline.ts renders multiple tracks side-by-side via a CSS-grid
wrapper (one column per available track). The pre-Slice-3 single-column
flow is reused per column — each track keeps its own past / today /
future / undated structure and its own lookahead toggle. On ≤640px the
grid collapses to a single column with track sub-headers preserved so
the user knows which track they're reading.

A [Track ▼] selector surfaces above the timeline whenever the response
advertises more than the default "parent" track (read from the new
X-Projection-Tracks header). Options: "Beide" (default — render every
track in parallel) / "Nur Hauptverfahren" / "Nur Widerklage". The
filter is purely client-side, so swapping tracks doesn't re-fetch.

Visual treatment: parent track gets the lime accent; counterclaim track
takes the muted surface-2 background so the lawyer reads "this is the
defended side" at a glance; parent_context track is dashed-bordered and
faded to signal the read-only context view.

The previously-disabled "Widerklage (CCR) — kommt mit Slice 3" button
in the "+ Eintrag" modal is enabled and now opens an inline form with
proceeding-type select (defaulted to UPC_REV; populated lazily on first
open from /api/proceeding-types-db), optional title + CCR case-number,
and a "Stimmt nicht?" toggle for the R.49.2.b CCI edge case. POSTs to
/api/projects/{id}/counterclaim and navigates to the new child page on
success.

i18n: 30 keys (15 DE + 15 EN) under projects.detail.smarttimeline.track.*
+ projects.detail.smarttimeline.counterclaim.*. CSS: ~100 lines for the
grid wrapper, per-track visual modifiers, mobile collapse media query,
and the track-chip styling.
2026-05-09 16:07:58 +02:00
m
82888dea78 feat(t-paliad-174): SmartTimeline Slice 3 — projection parallel tracks + counterclaim handler
ProjectionService.For now composes multiple tracks instead of a single
"parent" stream. The viewed project always emits Track="parent"; visible
CCR children emit Track="counterclaim:<child_id>"; a project that is
itself a CCR (counterclaim_of != nil) pulls its target's events as
Track="parent_context:<parent_id>" so the lawyer working the CCR sees
the main proceeding without leaving the page (§4.5).

Each track runs the actuals + projection pipeline independently with
its own lookahead cap and dependency annotations against its own
proceeding's rule tree. SubProjectID + SubProjectTitle are populated on
non-parent rows so the frontend can render the sub-project title in the
column sub-header.

ProjectionMeta gains AvailableTracks; the handler surfaces it as the
new X-Projection-Tracks response header (CSV) so the wire shape stays
[]TimelineEvent (frozen since Slice 1).

POST /api/projects/{id}/counterclaim wraps ProjectService.CreateCounterclaim
— accepts proceeding_type_id / flip_our_side / title / case_number,
returns the new project's id + canonical /projects/<id> URL.

Tests: pure-function coverage for derivedCounterclaimOurSide (default
flip + R.49.2.b override + court/both pass-through). Live-DB integration
test covers the four invariants — CreateCounterclaim atomicity (parent
audit + child audit + our_side flip + sibling-under-patent placement),
parent's projection surfaces the counterclaim track, child's projection
surfaces parent_context, two-level CCR chains are rejected by both the
service guard and the schema-level trigger.
2026-05-09 16:07:37 +02:00
m
306bb11618 feat(t-paliad-174): SmartTimeline Slice 3 — counterclaim sub-project schema + service
Migration 077 adds paliad.projects.counterclaim_of (nullable FK ON DELETE
SET NULL) plus a partial index. A trigger function rejects two-level CCR
chains: a project with counterclaim_of NOT NULL cannot be the target of
another CCR — UPC practice has no CCR-of-a-CCR shape, so reject it at
the schema level rather than defending in the application layer.

ProjectService gains LoadCounterclaimChildrenVisible (list visible CCR
sub-projects against a parent) and CreateCounterclaim (atomic: project
row + creator-as-lead team membership + audit rows on parent AND child).
The CCR child is placed as a sibling under the same patent (§4.4), our
side flips claimant↔defendant by default with a "Stimmt nicht?" override
for the R.49.2.b CCI edge case, and the proceeding type defaults to
UPC_REV. Title auto-suggests from the patent ancestor's patent_number
when available.

Tracker advances 76 → 77.
2026-05-09 16:07:17 +02:00
m
196f3f74a6 Merge: t-paliad-173 — SmartTimeline Slice 2 + m/paliad#31 layered features
gauss's bundle: Slice 2 base from lagrange's design (FristenrechnerService
projection + click-to-anchor + reflow-on-actuals + rule-skipped path) PLUS
m/paliad#31 layered requirements (7-event lookahead cap, dependency
provenance display, SoC→SoD sequence enforcement at the anchor write path).

Backend (commit 85d7dd4):
- Migration 076: appointments.deadline_rule_id FK (nullable, ON DELETE SET
  NULL) + partial index; deadlines.source CHECK extended with 'anchor';
  project_events validation extended with 'rule_skipped' event_type.
- ProjectionService: FristenrechnerService.Calculate integration with
  AnchorOverrides built from completed actuals (per design §6.1); projected
  rows include Status='predicted' / 'court_set' / 'predicted_overdue';
  7-event lookahead cap with ?lookahead=N override (1..50); skipped-rule
  cascade-suppression via project_events WHERE event_type='rule_skipped';
  dependency annotations (DependsOnRuleCode + DependsOnDate + name).
- POST /api/projects/{id}/timeline/anchor: 200 happy path (idempotent
  re-PATCH), 409 predecessor_missing payload (rule code + bilingual name +
  bilingual message) when sequence violation detected.
- POST /api/projects/{id}/timeline/skip: writes project_events
  rule_skipped+milestone for §6.4 'ist nicht eingetreten' decision.
- Tests: projection_anchor_test.go +294 LoC; projection_service_test
  extended.

Frontend (commit 331efc8):
- shape-timeline.ts +425 LoC: projected/court-set/overdue row variants,
  inline click-to-anchor editor (200 reflow / 409 inline-error +
  'Stattdessen <predecessor> erfassen' link), depends-on footer +
  'Pfad anzeigen' chain expansion, 'Mehr / Weniger anzeigen' lookahead
  toggle persisting in localStorage per project.
- FilterBar +99 LoC: timeline_status (predicted/actual/overdue/done/
  off_script) + timeline_track axes; 'Zukunft anzeigen' / 'Nur
  vergangenes' macro chip pair.
- projects-detail.ts: orphan renderEvents() removed (Slice 1 leftover
  band-aided in fermat's 0835be4 — proper cleanup landed here).
- 58 i18n keys (DE+EN), 191 LoC CSS, no hardcoded colours (CSS variables).

Locked design picks (lagrange §11): Q3 'anchor' source, Q9 court-set →
appointments. m/paliad#31 defaults locked as briefed: 7 fixed lookahead,
footer-on-every-row + expand-on-click for deps, 409 hard-reject for
sequence (no confirm-and-write override in this slice).

Verified: bun build.ts clean (2146 keys), go build ./... clean,
projection_service + projection_anchor unit tests passing; integration
tests gated on TEST_DATABASE_URL run on CI.

Migration tracker: 75 → 76 on next deploy boot.

Slices 3 (counterclaim sub-project + parallel-track) and 4 (lane
aggregation at Patent/Litigation/Client) remain queued for m's pace.
2026-05-09 15:45:43 +02:00
m
331efc8603 feat(t-paliad-173): SmartTimeline Slice 2 frontend + #31 layered features
shape-timeline.ts:
- Renders Kind="projected" rows with Status-driven styling: predicted
  (faded grey), court_set (dashed border), predicted_overdue (amber
  fade with overdue glyph).
- "[Datum setzen]" inline date editor on every projected row with a
  rule_code. Submit POSTs /api/projects/{id}/timeline/anchor; 200
  triggers onChange (re-fetch + re-render); 409 renders the
  predecessor_missing payload as inline error with a "Stattdessen
  <predecessor> erfassen" link that scrolls to + opens the parent's
  editor.
- "Folgt aus: <Name> (<Code>, <Date|Datum offen>)" footer on every row
  with depends_on_rule_code, plus "[Pfad anzeigen]" expander hint.
- "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle when backend's
  X-Projection-Total header indicates more projections exist beyond
  the current cap.
- Status pills on projected rows surface the status nuance next to
  the kind chip without overwhelming the title.

projects-detail.ts:
- loadTimeline reads X-Projection-{Total,Lookahead} headers and forwards
  them to renderSmartTimeline.
- Lookahead state persisted in localStorage per project (key
  `paliad.smarttimeline.lookahead.<id>`).
- Removes the renderEvents() orphan (band-aid from t-paliad-172) and
  every call site — renderTimeline is the only project-page render
  path now. Aligns with fermat's commit-message hint in 0835be4.

FilterBar (substrate):
- New axes timeline_status / timeline_track (chip clusters, multi-
  select). Macro chip pair "Zukunft anzeigen" / "Nur vergangenes" on
  the timeline_status axis maps to the predicted+court_set subset
  on/off.
- url-codec round-trips ?tl_status= / ?tl_track= so saved Sichten /
  bookmarks survive.

CSS:
- ~80 LoC for .smart-timeline-row--projected/--court_set/--predicted_overdue,
  status pills, depends-on footer, anchor editor, lookahead toggle.
  All tokens reuse existing CSS variables — no bare-hex fallbacks
  (cf. t-paliad-150 dark-mode lesson).

i18n:
- 31 new keys (DE+EN) for projected statuses, depends-on labels,
  anchor editor states, lookahead chips, FilterBar axis labels +
  values + macro chips. 2102 → 2146 total.

Tests:
- projection_anchor_test.go covers applyLookaheadCap (overdue +
  court_set exemption), applyLookaheadDefault clamping,
  ruleAnchorKind dispatch, extractMetadataString, lang normalisation,
  ruleNameInLang, PredecessorMissingError unwrap, annotateDependsOn
  (including parent-of-parent chain dating).

Migration 076 was applied live during dev (tracker 75 → 76); deploy
re-applies idempotently via the embedded migrate path.
2026-05-09 15:43:22 +02:00
m
85d7dd497c feat(t-paliad-173): SmartTimeline Slice 2 backend — projection + anchor + skip + sequence guard
Slice 2 of the SmartTimeline (docs/design-smart-timeline-2026-05-08.md
§6 + §9 + §10) bundled with m/paliad#31's layered requirements:

Migration 076:
- appointments.deadline_rule_id nullable FK to deadline_rules + partial idx
- deadlines.source CHECK widened to include 'anchor' (alongside existing
  'manual','fristenrechner','rule','import').

ProjectionService (extended):
- Wires FristenrechnerService + DeadlineRuleService.
- For() now emits Kind="projected" rows for any rule lacking a matching
  paliad.deadlines.rule_id / appointments.deadline_rule_id row, with
  Status in {predicted | predicted_overdue | court_set}.
- Lookahead cap (default 7, override via ?lookahead=N, max 50): future
  predicted rows beyond N are dropped; predicted_overdue + court_set
  rows are exempt from the cap (#31 layer 1).
- Dependency annotations DependsOnRuleCode/Date/Name on every row that
  carries a DeadlineRuleID, walked from the rule's parent_id chain
  (#31 layer 2). Date prefers actuals over projections.
- AnchorOverrides built from completed deadlines (completed_at /
  status='completed') + appointments tied via deadline_rule_id.
- triggerDate derives from the proceeding's root rule's anchor when
  present, else today() as placeholder.

Anchor write path (POST /api/projects/{id}/timeline/anchor):
- Sequence guard: if rule.parent_id has no anchored actual, return
  409 predecessor_missing with the missing rule's code/name DE+EN +
  pre-formatted bilingual messages so the frontend can render an
  inline error with a "Stattdessen <predecessor> erfassen" link
  (#31 layer 3, no confirm-and-write override in v1).
- kind dispatch: rules with event_type IN ('hearing','decision','order')
  write paliad.appointments with deadline_rule_id; everything else
  writes paliad.deadlines with source='anchor', status='completed',
  completed_at=actual_date.
- Idempotent: existing (project_id, rule_id) row PATCHes instead of
  inserting (race-safe per design §13).

Skip write path (POST /api/projects/{id}/timeline/skip):
- Writes paliad.project_events with event_type='rule_skipped' +
  metadata.rule_code; subsequent reads drop the matching projected
  row from the cascade (§6.4).

Handlers expose projection meta via X-Projection-{Has,Total,Shown,Overdue,Lookahead}
headers so the wire shape stays []TimelineEvent (frozen since Slice 1).
2026-05-09 15:33:20 +02:00
m
335be29b23 Merge: t-paliad-172 — fix Verlauf-tab-stuck regression from Slice 1
Slice 1 (3e1bbd3) of the SmartTimeline replaced the legacy
<ul#project-events-list> markup with <div#project-smart-timeline> but
left the orphan renderEvents() function and its call site in place.
renderEvents() did getElementById('project-events-list')! — non-null
asserted on a node that no longer existed. main() called renderEvents()
between body.style.display = '' and initTabs(). The null deref threw,
main() aborted, initTabs() never ran, and tab click handlers never
attached. Tab clicks went to <a href='#'> defaults; the URL got '#'
appended but no panel transition happened — m's 'stuck on Verlauf'
report (12:25).

fermat's minimal fix: drop the ! assertions, null-guard, return early
when the legacy DOM nodes are gone. 10 lines, one file
(frontend/src/client/projects-detail.ts:867-883). Comment points at
Slice 2 for the proper removal of the orphan call site.

Verified: bun build.ts clean, go build clean, Playwright reproduces the
TypeError on main and confirms tabs work post-fix. Empty-state Smart
Timeline still renders, '+ Eintrag' modal still opens/closes, Audit-Log
toggle still present.

Single commit 0835be4 from mai/fermat/bug-verlauf-tab-open-can.
2026-05-09 12:39:02 +02:00
m
0835be4a7f fix(t-paliad-172): null-guard renderEvents to unblock tab clicks
Slice 1 of SmartTimeline (t-paliad-171, commit 7057fe5) removed the
legacy <ul#project-events-list> + <p#project-events-empty> markup from
projects-detail.tsx but didn't prune the renderEvents() call sites. The
function still runs from main() and several other paths; with non-null
assertions on getElementById, the null deref threw a TypeError mid-init.

The throw aborted main() between body.style.display = "" and initTabs(),
so the .entity-tab click handlers were never attached. Default-action
clicks on <a href="#"> just appended "#" to the URL while the user was
already viewing whatever panel happened to be the default-display
section (tab-history) — making the Verlauf tab feel "stuck" because the
visible panel never changed.

Fix: drop the non-null assertions, null-guard the legacy DOM lookups,
and return early when the targets are gone. renderEvents() becomes a
silent no-op in the SmartTimeline layout, which matches euler's intent
documented in 7057fe5: "The legacy renderEvents() rendering path stays
as-is (dead, but the function is still called in places). It will be
removed once /timeline?include=audit_full has had a deploy of soak time
… Slice 2 revisits."

Verified locally with the projects-detail.js bundle + a fetch mock:
clicks on Team / Projektbaum / Parteien / etc. now switch the active
tab and panel display, the URL updates via replaceState, the
SmartTimeline still renders its empty state, and the "+ Eintrag" modal
still opens and closes correctly.
2026-05-09 12:38:24 +02:00
m
3e1bbd3c77 Merge: t-paliad-171 — SmartTimeline Slice 1 (skeleton; actuals only, no projection)
euler's first slice of the SmartTimeline per docs/design-smart-timeline-2026-05-08.md
§10. Past actuals + audit-log toggle + new render shape; NO projection logic
yet (Slice 2). NO counterclaim FK (Slice 3). NO lane aggregation (Slice 4).

What lands:
- Migration 075: nullable paliad.project_events.timeline_kind text + partial
  index (auto-applied at server boot via golang-migrate)
- Backend: ProjectionService + GET /api/projects/{id}/timeline + POST
  /api/projects/{id}/timeline/milestone, with unit + integration tests
- Frontend: shape-timeline.ts vertical-two-column render shape, '+ Eintrag'
  modal (Eigener Meilenstein wired; Frist/Termin link out; Widerklage/R.30
  disabled with 'Slice 3' tooltip), 'Audit-Log anzeigen' toggle persisting
  per-project in localStorage
- FilterBar (riemann's t-paliad-170 port) keeps mounting + working — facet
  set unchanged

Two flagged deviations from §3.2 mockup, parked for m's review:
- Render order: chronological top-down (past at top, future at bottom);
  mockup had future-above-past. Trivial CSS flip if m prefers.
- Legacy renderEvents() function not removed — Slice 2 cleanup.

Verified: bun build.ts clean (2117 keys), go build ./... clean, go test
./internal/services ./internal/handlers passing (live integration test
gated on TEST_DATABASE_URL; runs on CI).

Live tracker at v74 pre-deploy → v75 after Dokploy boot.

Commits: 49c260b 49c260b afd3aab 4a5d56d 7057fe5 from
mai/euler/smarttimeline-slice-1.

Slices 2-4 + 12 open inventor questions remain parked for m's morning
review of docs/design-smart-timeline-2026-05-08.md.
2026-05-08 23:42:39 +02:00
m
7057fe5d25 feat(t-paliad-171): mount SmartTimeline + "+ Eintrag" modal in /projects/<id> Verlauf
Replaces the legacy <ul.entity-events> Verlauf rendering with the new
SmartTimeline. Slice 1 wiring:

  - loadTimeline(id) calls /api/projects/{id}/timeline (the new
    endpoint backed by ProjectionService) and renderSmartTimeline
    paints into <div#project-smart-timeline>.
  - "Audit-Log anzeigen" header toggle re-fetches with
    ?include=audit_full, broadening the project_events filter to
    every audit row (legacy Verlauf chronological view). State
    persists per-project in localStorage so flipping it on for one
    case doesn't carry across to others.
  - "+ Eintrag" CTA opens a modal. "Eigener Meilenstein" submits
    via POST /api/projects/{id}/timeline/milestone and re-renders;
    Frist + Termin route to the existing /deadlines/new and
    /appointments/new flows; CCR + R.30 are disabled-with-tooltip
    "kommt mit Slice 3" per the design.
  - Subtree toggle now also drives the timeline (passes
    ?direct_only=true when the user flips off "Inkl. Unterprojekte").
  - Project-appointment add path also re-fetches the timeline so the
    new appointment surfaces immediately.

The legacy renderEvents() rendering path stays as-is (dead, but the
function is still called in places). It will be removed once
/timeline?include=audit_full has had a deploy of soak time and the
audit-toggle is the only path that feeds the legacy markup. Slice 2
revisits.

The FilterBar from t-paliad-170 (riemann's port) keeps mounting and
driving its customRunner — facets still narrow the legacy `events`
array. The bar gaining timeline_* axes lands later in the slice
sequence (design §8); Slice 1 ships the timeline beneath the existing
bar untouched.

Design ref: docs/design-smart-timeline-2026-05-08.md §10 Slice 1.
2026-05-08 23:41:11 +02:00
m
4a5d56d9e6 feat(t-paliad-171): SmartTimeline render shape — shape-timeline.ts + CSS + i18n keys
The vertical-timeline render component for the SmartTimeline (Verlauf
tab redesign). Two-column layout (date / event card), past
chronological → "Heute →" rule → future chronological, status icon +
kind chip per row.

Deep-link is wired via a row-level click handler that skips clicks on
inner <a>/<button>, NOT a ::before overlay — matches the project's
.entity-event whole-card click contract (project CLAUDE.md), keeps
text selection working, and avoids the t-102 overlay regression that
swallowed pointer events on the title text.

i18n: 28 new keys under projects.detail.smarttimeline.* (DE primary,
EN secondary). i18n-keys.ts is regenerated by build.ts on every build,
so the diff there is mechanical.

CSS: ~250 LoC under .smart-timeline-* — vertical layout, status-icon
glyphs per status (✓/…/!/▢/░/⊕), kind-chip pastels, Heute → rule with
borders extending into the spacing.

Design ref: docs/design-smart-timeline-2026-05-08.md §3.1-3.3.
2026-05-08 23:40:49 +02:00
m
afd3aab2b2 feat(t-paliad-171): SmartTimeline backend skeleton — ProjectionService + /timeline endpoint
Slice 1 of the SmartTimeline (Verlauf-tab redesign). Adds a new service
layer + two HTTP endpoints; no projection logic yet (Slice 2). The wire
shape (TimelineEvent) is frozen so future slices add Kind="projected"
rows additively without breaking the frontend consumer.

ProjectionService.For composes three actuals streams for one project:
  - paliad.deadlines           → Kind="deadline"
  - paliad.appointments        → Kind="appointment"
  - paliad.project_events with
    timeline_kind IS NOT NULL  → Kind="milestone"

Visibility goes through the existing inline mirror of
paliad.can_see_project on each underlying service — no new RLS surface.
DirectOnly mirrors the existing "Inkl. Unterprojekte" toggle on
/projects/{id}; IncludeAuditFull broadens project_events to the full
audit log behind the upcoming "Audit-Log anzeigen" header toggle.

ProjectionService.RecordCustomMilestone backs POST /timeline/milestone
("Eigener Meilenstein") — the only write path in Slice 1.

Tests: unit (sort order, status mapping, kind tiebreak — runs by default)
plus a live integration test that seeds one project + dl + appt +
milestone and asserts the merge surfaces all three with the right
ordering. Live test gated on TEST_DATABASE_URL per the existing
convention.

Design ref: docs/design-smart-timeline-2026-05-08.md §2.3 + §9.2 + §10.
2026-05-08 23:34:06 +02:00
m
49c260b888 feat(t-paliad-171): migration 075 — project_events.timeline_kind opt-in column
Adds a nullable text column on paliad.project_events so a subset of
audit rows can opt into surfacing as SmartTimeline content. Existing
rows stay NULL (audit-only); the partial index keeps the lookup tiny
because the SmartTimeline read filter is the indexed predicate.

Value space (enforced in code in internal/services/projection_service.go):
  'milestone'        — structural event (counterclaim_filed, ...)
  'custom_milestone' — free-text "Eigener Meilenstein"
  NULL               — audit only (default)

Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
2026-05-08 23:33:53 +02:00
m
12b35fc9fe Merge: t-paliad-170 — FilterBar mounted in /projects/<id> Verlauf tab
riemann's Phase 2 slice on top of own 1faffb6 Phase 1: the universal
<FilterBar> is now in the project Verlauf tab. Filter facets:
project_event_kind (chip cluster), time (presets including new
HorizonPast7d), personal_only. Empty URL preserves current behaviour
(unfiltered list); ?time=past_30d&pe_kind=deadline_created narrows.

Two extension points added to the bar primitive (forward-compat with
SmartTimeline t-paliad-169 work):
- customRunner: lets a host page own the data fetch (Verlauf keeps
  the legacy /api/projects/{id}/events pipeline so subtree + cursor
  pagination survive — substrate-side scope-with-descendants stays
  SmartTimeline territory).
- timePresets: opt-in past-only horizon set for backward-looking
  surfaces (vs the default future-leaning set used on /inbox).

3-way merge with main: clean. fourier's t-paliad-168 + lagrange's
SmartTimeline design doc preserved.

bun build clean; frontend/dist regenerated. go test internal/... ok
on riemann's worktree (filter-bar url-codec + filter_spec tests).

ebcda13 from mai/riemann/filterbar-phase-2-slice.
2026-05-08 23:23:49 +02:00
m
ebcda13f88 feat(t-paliad-170): mount <FilterBar> in /projects/<id> Verlauf tab
Phase 2 slice of the universal-filter migration (Phase 1 was
t-paliad-163 → /inbox; remaining /agenda /events /deadlines
/appointments stay queued).

What ships:

- FilterBar gains two non-invasive options that future surfaces will
  also need:
    customRunner — bypass the substrate POST and hand the effective
                   spec to a surface-supplied runner. Required by
                   surfaces whose data path can't move to the substrate
                   yet (Verlauf still uses /api/projects/{id}/events for
                   subtree expansion + cursor pagination, both absent
                   from the substrate's project_event runner).
    timePresets  — per-surface override of the time chip cluster, so
                   backward-looking surfaces can show past_*+all without
                   forcing forward-looking next_* chips on every host.

  systemViewSlug becomes optional; the bar enforces "exactly one of
  customRunner | systemViewSlug" at construction.

- project_event_kind axis renderer (was a null stub) — chip cluster
  over KnownProjectEventKinds, labels reuse the existing
  event.title.<kind> i18n table so the chip text matches the Verlauf
  row title for the same kind.

- HorizonPast7d added end-to-end (substrate validate +
  computeViewSpecBounds; FilterBar TimeOverlay + parseHorizon; views
  TimeHorizon mirror) so the chip value is valid in every layer when a
  later SystemView reuses it.

- Verlauf tab on /projects/<id> mounts the bar with
  axes=["time","project_event_kind"], timePresets=
  ["past_7d","past_30d","past_90d","any"], showSaveAsView=false. The
  customRunner reads predicates.project_event.event_types + time.horizon
  off the effective spec, sets a verlaufFilters global, and routes
  through the legacy loadEvents/loadMoreEvents pipeline (which now
  applies the filter set client-side and tracks raw cursor IDs so
  "Mehr laden" still walks the underlying pagination boundary even when
  most rows get filtered out of a page).

- Subtree toggle drives loadEvents through verlaufBar.refresh() so the
  current filter state survives the toggle.

URL state reuses the bar's existing keys (?time=past_30d, ?pe_kind=…).
Empty filter → identity passthrough → current behaviour preserved.

Out of scope (deferred to t-paliad-169 SmartTimeline):
  - Migrating Verlauf to the substrate (needs scope-with-descendants)
  - Past/future split, dated/undated split, source-track facet

Refs m/paliad#23.
2026-05-08 23:22:23 +02:00
m
487fec2672 Merge: t-paliad-169 — SmartTimeline design doc (DESIGN READY FOR REVIEW)
lagrange's inventor pass on m's 23:02 request: redesign the Verlauf tab on
/projects/{id} as a SmartTimeline composing past actuals + future-projected
(via existing AnchorOverrides reflow on FristenrechnerService.Calculate) +
off-script events. Counterclaim shape: sub-project with new counterclaim_of
FK; parent renders parallel right-track when populated.

Doc covers: data-model recommendation (virtual view, ONE optional column),
UI mockup (3 states), counterclaim shape (defended), parent-node aggregation
(per-level kinds/statuses/lanes rule), date-anchoring + reflow semantics,
off-script event UX, 12 open questions, 4-slice phasing.

12 open questions parked for m's review before Slices 2-4. Slice 1 is the
skeleton (no projection yet) — must merge AFTER riemann's t-paliad-170
FilterBar port; pending riemann.

Issue m/paliad#27. Single commit f8cc86c, 739 lines, design only — no
implementation in this merge.
2026-05-08 23:17:48 +02:00
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
m
69544bf3fb Merge: t-paliad-168 — Verfahrensablauf entry points (Step 2 third card + sidebar nav)
m's complaint @ 22:49 'Verfahrensablauf section… is gone' — the Pathway A wizard
was reachable only via Step 1 → Step 2 (einreichen) → Step 3a (file), three clicks
deep and framed as 'I'm filing a brief.' fourier restores two top-level entries:

- Step 2 third card 'Verfahrensablauf einsehen' (browse / learn) → ?path=a
- Sidebar Werkzeuge entry 'Verfahrensablauf' (open-book icon) → ?path=a

In both browse paths the save-to-project CTA disables (no Akte to save against).
Deliverable 3 (project-page Verfahrensablauf tab) deferred — the SmartTimeline
redesign (t-paliad-169, lagrange) will determine the right component shape.

Commits: 7238b12 (Step 2 card), 7fef641 (sidebar entry).

Closes part of m/paliad coverage; SmartTimeline tracked separately.
2026-05-08 23:05:47 +02:00
m
7fef64159b feat(sidebar): add Verfahrensablauf nav entry
t-paliad-168 deliverable 2. New "Verfahrensablauf" entry under
Werkzeuge, right after Fristenrechner — opens
/tools/fristenrechner?path=a (Pathway A wizard, browse-/learn-mode).

Uses a distinct open-book icon to read separate from the closed-book
Glossar. Both /tools/fristenrechner sidebar entries share the same
pathname, so SSR navItem matching can't pick the right "active" one
on its own — fixVerfahrensablaufActive() in sidebar.ts disambiguates
based on ?path=a at hydration.

i18n key: nav.verfahrensablauf (DE: "Verfahrensablauf",
EN: "Procedure Roadmap"). i18n-keys.ts is regenerated by build.ts.
2026-05-08 23:04:29 +02:00
m
7238b12b05 feat(fristenrechner): Step 2 third card "Verfahrensablauf einsehen"
t-paliad-168 deliverable 1. Adds a discoverable browse-/learn-mode
entry to the determinator alongside "Etwas einreichen" / "Etwas ist
passiert". Click drops straight into Pathway A's proceeding-tile
picker (navigateToPathway("a")).

The save-to-project CTA disables itself in this mode — extends
isAdhocMode() to also return true when no Step 1 context is set,
mirroring the existing ad-hoc explore behaviour.

i18n keys: deadlines.step2.browse.title / .desc (DE + EN).
2026-05-08 23:03:52 +02:00
m
54cf7ac2f6 Merge: t-paliad-167 — Determinator coverage audit (research)
f4815a9 — Determinator coverage audit @ docs/research-determinator-
coverage-2026-05-08.md (394 lines).

Headline numbers (n=76 true Fristenrechner deadlines across 19 active
proceedings):
  Reachable from cascade:    71  (93 %)
  No concept_id:               1  (1 %)
  Concept exists, dead-end:    4  (5 %)

§4 frames the smart-navigation question into a taxonomy of three
"I don't see my event" failure modes — α (real content gap), β
(reachable but mis-modelled path), γ (court-side trigger needs to be
tagged, not reacted-to) — and maps each to the candidate UX patterns
(P1 free-text search / P2 escape-with-telemetry / P3 weiter-unten-
suchen). The recommendation surfaces:

  - P2 + telemetry for type-α (capture which events users actually
    want; drives prioritised migration backlog rather than guessing)
  - P1 + P3 for type-β (search collapses labelling mismatches; flat-
    branch search recovers from wrong-root entries)
  - Type-γ flagged as a separate "tag, don't react" workstream out of
    this scope

Pure research — no code, no schema. Feeds m's next decision: extend
the row-by-row B1 refactor (m/paliad#25 / minkowski's parked task) or
spin a separate inventor pass on the smart-navigation surface.

Refs m/paliad#26.
2026-05-08 22:35:24 +02:00
m
f4815a9f9a docs(t-paliad-167): Determinator coverage audit + smart-navigation framing
Builds on t-paliad-159's UPC RoP audit. Drives from paliad's own corpus
outward: every active rule, every firm-wide event_type, every cascade
leaf — and asks whether a Determinator user can actually reach the row.

Headline finding: 71/76 (93%) of true Fristenrechner deadlines are
reachable from the cascade. The 5 unreachable cluster into one fix:
EP_GRANT (4 rules) plus UPC_INF.inf.app_to_amend lack cascade entry.
Adding an `ich-moechte-einreichen.ep-erteilung` subtree lifts coverage
to 100%.

Per-jurisdiction inventory (UPC, DE, EPO, DPMA) plus a §2.6 cross-cutting
table for the procedural-order categories m flagged (Hinweisbeschluss,
Beweisbeschluss, Streitwertbeschluss, Versäumnisurteil, R.71(3),
Beanstandungsbescheid, etc.).

§4 frames the smart-navigation choice: recommends P2 (persistent escape
button with capture) + P1 (free-text search per cascade level), defers
P3 (flatten deeper levels) until telemetry justifies it. The captured
"Mein Ereignis ist nicht dabei" texts feed both the gap-fill roadmap
and P1's ranking corpus.

No code changes; one markdown doc, 394 lines.
2026-05-08 22:34:23 +02:00
m
ce180123c3 Merge: t-paliad-165 follow-up — Regel+Typ as one field + jurisdiction-aware defaults
Two slices on mai/noether/collapse-regel-typ-on (after rebase onto main):

  6058d21  fix(deadline-rules): pick rule's jurisdiction-aware event_type default
  7a35cad  feat(deadlines/new): collapse Regel + Typ to ONE field when rule sets type

What ships:

- Migration 074 audits the deadline_concept_event_types seed and adds
  per-jurisdiction defaults so a German rule (RoP.029.b /
  § 276 Abs. 1 S. 2 ZPO) maps to the DE event_type
  (de_klageerwiderung) and a UPC rule maps to the UPC event_type
  (upc_statement_of_defence). The text label "Klageerwiderung" reads
  the same in both — the bug m hit at 22:08 was the seed defaulting
  to UPC for every concept regardless of which rule asked.
  Idempotent (IF NOT EXISTS / DO blocks). Live tracker advanced
  73 → 74 during noether's dev run; deploy will see tracker=74 with
  file 074 present and have nothing to apply.

- Frontend deadline create form (m's "these two are connected — it's
  the same thing", #18 + 22:08 dogfood):
    When a Regel is selected and a default event_type exists for it,
    the Typ chip COLLAPSES into an inline pill beneath the rule:
      "Typ: Klageerwiderung (vorgegeben durch Regel)  [Anderen Typ wählen]"
    Clicking [Anderen Typ wählen] re-expands the picker so the user
    can override.
    When the rule has no junction row (or the user hasn't picked a
    rule), the Typ field stays as today (free-text + chip cluster).

- deadline_rule_service.go switched to the jurisdiction-aware lookup;
  the form receives the right default in one round-trip.

Refs m/paliad#18 + the 2026-05-08 22:08 inline-correction thread.
2026-05-08 22:21:48 +02:00
37 changed files with 8531 additions and 136 deletions

View File

@@ -168,6 +168,7 @@ func main() {
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
Pin: services.NewPinService(pool, projectSvc),
CardLayout: services.NewCardLayoutService(pool),
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
}
// Paliadin backend selection (t-paliad-146 + t-paliad-151):

View File

@@ -0,0 +1,739 @@
# Design — SmartTimeline (Verlauf-tab redesign)
**Author:** lagrange (inventor)
**Date:** 2026-05-08
**Task:** t-paliad-169
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. Premises verified live (before designing)
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
- **Verlauf today** is `frontend/src/projects-detail.tsx:74-101``<ul className="entity-events" id="project-events-list">` rendered from `paliad.project_events` via `loadEvents(id)` at `client/projects-detail.ts:305`. Pure audit log: `event_type` distribution in prod is 100 % administrative — `deadline_completed/updated/created/...`, `note_created`, `appointment_*`, `checklist_*`, `project_type_changed`, `our_side_changed`, `deadlines_imported`. No "future-tense" or "off-script" events surface anywhere on the project page today.
- **Projection logic** lives in `internal/services/fristenrechner.go:Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)` returning a `UIResponse{Deadlines []UIDeadline}` keyed by `rule_code`. `CalcOptions.AnchorOverrides map[string]string` lets callers replace any rule's date and downstream rules re-anchor — already the load-bearing primitive for "actual dates anchor downstream projections" (t-paliad-131 Phase A).
- **`paliad.deadline_rules`** carries 172 active rules across 19 fristenrechner proceeding types (UPC×8, DE×5, EPA×2, EP×1, DPMA×3). `condition_flag text[]` already drives counterclaim cross-flows: `with_ccr` enables 7 UPC_INF cross-flow rules (Defence-to-CCR R.29.a, Application to amend R.30.1, Defence to App-to-amend R.32.1, Reply to Defence-to-CCR R.29.d, Rejoinder R.29.e, +2). `with_amend` / `with_cci` work on UPC_REV.
- **`paliad.projects.our_side`** column exists (added in t-paliad-164) but is **null on every live row today**. The CCR perspective-flip the cascade implements via Determinator B1 (t-paliad-167) is not yet exercised by real data.
- **CCR is not a separate project today.** It's a flag (`with_ccr=true`) on a parent UPC_INF project. m's vision asks us to revisit that.
- **FilterBar** (`frontend/src/client/filter-bar/`, riemann's t-paliad-163 Phase 1) ships with axis stubs `deadline_event_type` + `project_event_kind` already wired into `BarState` and `AxisKey` — Phase 2 is supposed to fill them in. The SmartTimeline's facet set is exactly the kind of thing those stubs were left pending for.
- **Project hierarchy in prod** is the canonical 4-level shape: Client (`Siemens AG`) → Litigation (`Siemens ./. Huawei`) → Patent (`EP3456789`) → Case (`UPC-CFI München — Klage Siemens ./. Huawei`). 11 projects total.
- **t-paliad-168 deliverable 3 is dropped** per task brief — there will be no separate Verfahrensablauf-as-its-own-tab on the project page. The wizard's projection logic is the SmartTimeline's future-skeleton feeder.
---
## 1. Vision + scope
m's vision (verbatim 2026-05-08 23:02):
> The Verlauf tab inside the case should hold past + future events. If we know the proceeding type, there is a timeline. We adapt the Verfahrensablauf logic and fix dates for things when they happened. A smart timeline. If a counterclaim is filed, that is also included. Hold it flexible — add events regardless of whether they fit the normal course.
The **SmartTimeline** is one composed view that answers *"what has happened in this matter, what is happening now, and what is on the standard road from here"*. Three time-zones in one widget:
| Zone | What it shows | Data source |
|---|---|---|
| **Past** | Filings, decisions, appointments, audit milestones — all dated, anchored to reality | `paliad.deadlines` (status=`done`) `paliad.appointments` (start_at < today) `paliad.project_events` (selected `timeline_kind`) |
| **Now** | Open deadlines + appointments today | same tables, today-bracket |
| **Future (predicted)** | Standard-course rules from `deadline_rules` projected forward, faded only those without an actual `paliad.deadlines` row yet | `fristenrechner.Calculate` against project's proceeding type + trigger anchor |
| **Future (off-script)** | User-added events that don't fit the standard tree (counterclaim filed, ad-hoc Anhörung, party amendment) | `paliad.deadlines` with `source='off_script'` child counterclaim sub-project's actuals `project_events` with `timeline_kind` |
### What changes
- The `tab=history` panel on `/projects/{id}` becomes a SmartTimeline component that renders all four zones in one column.
- The audit-only Verlauf view does not disappear it survives as a "Audit-Log" sub-toggle inside the SmartTimeline ("Alle Audit-Events anzeigen") and on the existing `/admin/audit-log` page (t-paliad-071).
- The existing FilterBar primitive grows two facets (`timeline_track`, `timeline_status`) and re-uses three (`time`, `personal_only`, `deadline_event_type`).
### What stays
- Step 2 third-card + sidebar entry from t-paliad-168 are unaffected the standalone Verfahrensablauf wizard at `/tools/fristenrechner` remains a knowledge-platform tool.
- `paliad.project_events` keeps its full audit-log role for `/admin/audit-log`.
- `paliad.deadlines` + `paliad.appointments` schemas don't migrate (only one optional column added; details in §2).
- The existing "Inkl. Unterprojekte" toggle on the project page stays the SmartTimeline reads child events through it.
### Out of scope (v1)
- Horizontal-Gantt rendering. We pick a vertical timeline; Gantt is a future shape (t-paliad-144 substrate already supports `shape` switching, so adding a Gantt shape is later, not now).
- Outlook/Exchange sync. CalDAV stays the only sync path.
- Cross-matter timelines (e.g. "everything happening on EP3456789 across Siemens ./. Huawei AND any related opposition"). The patent-level aggregation in §5 is a step in that direction but cross-matter view is a separate task.
- Rendering documents (Schriftsätze) on the timeline. That's the t-paliad-17 Incoming-Submission workflow, separate.
---
## 2. Data model
**Recommendation: virtual view, ONE optional column.** No new top-level table for v1. The four zones above are computed at read time from the existing tables. The single schema change is a nullable `timeline_kind text` column on `paliad.project_events` so a subset of audit rows can opt into surfacing as timeline content.
### 2.1 Why no new `timeline_events` table
A first-instinct design would materialise a new `paliad.timeline_events` table with columns `(project_id, kind, date, title, status, source_track, rule_code?, actual_deadline_id?, …)`. I recommend against it for v1:
1. **Three of the four zones already have authoritative tables.** `paliad.deadlines` is the source-of-truth for legal deadlines (with completion + approval state); `paliad.appointments` for hearings + court dates; `paliad.project_events` for audit. Forcing a copy into `timeline_events` creates a sync problem on every mutation.
2. **The future-projected zone is a function of proceeding-type + trigger date + actual anchors** not stored data. Materialising it would require invalidation on every `paliad.deadlines` change. Cheaper to recompute per request: 19 proceeding types × at most ~15 rules = ~285 ms with cold pg cache, well under the page-render budget. Re-uses the cached `FristenrechnerService` (already memoised per request via service instantiation).
3. **t-paliad-144 set the precedent** that ViewService composes per request without materialising. The SmartTimeline is a project-scoped instance of the same pattern.
If load testing later shows the projection cost matters, we materialise into a `paliad.projected_timeline_cache` table indexed by (project_id, rule_code) but design that when load shows it, not now.
### 2.2 The one column added
```sql
-- migration NNN_project_events_timeline_kind.up.sql
ALTER TABLE paliad.project_events
ADD COLUMN timeline_kind text NULL;
-- nullable + no CHECK — enum lives in code (services/projection_service.go).
-- Value space (v1):
-- 'milestone' — a structural event worth pinning to the timeline
-- (counterclaim_filed, third_party_intervened,
-- party_amendment, our_side_changed, scope_change)
-- 'custom_milestone' — free-text user-added event
-- NULL — audit only (default, all existing rows)
CREATE INDEX project_events_timeline_kind_idx
ON paliad.project_events (project_id, timeline_kind)
WHERE timeline_kind IS NOT NULL;
```
Existing event types stay `NULL` they remain audit-only and don't clutter the timeline. New write paths (counterclaim-link, off-script milestone) set the column on insert.
### 2.3 The discriminated `TimelineEvent` shape
Composed in `internal/services/projection_service.go` (new). One Go struct, one TS mirror. Frontend renders without knowing where each row came from:
```go
type TimelineEvent struct {
Kind string // "deadline" | "appointment" | "milestone" | "projected"
Status string // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
Track string // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
Date *time.Time // nil = undated (court-set + counterclaim-pending)
Title string
Description string
RuleCode string // empty when not deadline-rule-derived
// Provenance — exactly one is non-nil for actual rows; both nil for projected.
DeadlineID *uuid.UUID
AppointmentID *uuid.UUID
ProjectEventID *uuid.UUID
// For projected rows (Kind=="projected") — the rule it came from, for
// the click-to-anchor affordance (§6).
DeadlineRuleID *uuid.UUID
DeadlineRuleParty string // 'claimant' | 'defendant' | 'court' | 'both'
// For child-track rows — the sub-project this event belongs to.
SubProjectID *uuid.UUID
SubProjectTitle string
}
```
### 2.4 Read path
```
GET /api/projects/{id}/timeline?
from=...&to=...&direct_only=true|false&
tracks=parent,counterclaim,...&kinds=deadline,appointment,projected,...
```
The handler:
1. Calls `ProjectionService.For(ctx, projectID, opts)` which:
- Loads the project (proceeding_type_id, our_side, parent chain).
- Loads child counterclaim sub-projects (if any see §4).
- Loads `paliad.deadlines` (project_id IN [self, child counterclaims]) emits Kind=deadline rows.
- Loads `paliad.appointments` (same) emits Kind=appointment rows.
- Loads `paliad.project_events WHERE timeline_kind IS NOT NULL` emits Kind=milestone rows.
- For each (project, child) with a proceeding_type_id, calls `FristenrechnerService.Calculate` with `AnchorOverrides` derived from completed actuals emits Kind=projected rows for any rule that does **not** have a matching `paliad.deadlines.rule_id` row.
- Sorts by Date ASC, undated rows last (with secondary sort on rule sequence_order so undated court-set rows preserve the standard course's order).
Visibility is inherited via existing `visibilityPredicate` on each underlying service no new RLS surface to design.
### 2.5 What does NOT need to change
- `paliad.deadlines` schema unchanged. (The existing `original_due_date`, `source`, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)
- `paliad.appointments` unchanged.
- `paliad.deadline_rules` unchanged. The existing `condition_flag text[]` keeps doing its job.
- `paliad.projects` unchanged. (See §4 for the counterclaim sub-project shape: it uses existing columns.)
---
## 3. UI mockup — three states
The SmartTimeline replaces the current `<ul className="entity-events">` block (~30 lines of TSX) with a vertically-flowing two-column timeline:
- Left column: date (or "Datum offen" placeholder).
- Right column: stacked card per event with a status icon, title, kind chip, and (for actuals) a deep-link to `/deadlines/{id}` etc. Same `.entity-event` row contract as today (cf. CLAUDE.md whole-card click rule), no `::before` overlay.
A horizontal "**Heute →**" rule separates past from future. Past goes below (most-recent first), future above (chronological). Today's events sit on the rule.
### 3.1 State A — empty / no proceeding type set
```
┌──────────────────────────────────────────────────────────────┐
│ SmartTimeline [Filter ▼] [+ Eintrag] │
├──────────────────────────────────────────────────────────────┤
│ │
│ Noch keine Ereignisse erfasst. │
│ │
│ Setze einen Verfahrenstyp im Projekt-Header, um den │
│ Standardverlauf als Vorhersage zu sehen, oder lege │
│ einen Eintrag manuell an. │
│ │
│ [+ Frist anlegen] [+ Termin anlegen] [+ Meilenstein] │
│ │
└──────────────────────────────────────────────────────────────┘
```
The empty state actively guides toward the two unlocks: setting a proceeding type (enables future-projection) or adding manual events (works without one).
### 3.2 State B — UPC_INF, infringement-only
```
┌──────────────────────────────────────────────────────────────┐
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Filter ▼] │
├──────────────────────────────────────────────────────────────┤
│ Zukunft (vorhergesagt) │
│ ───────────────────────────── │
│ 2027-02-20 ░ Hauptverhandlung │
│ ░ wird vom Gericht bestimmt [Datum setzen] │
│ ─ │
│ 2026-12-02 ░ Duplik (RoP.029.c) [voraussichtlich]│
│ 2026-11-02 ░ Replik (RoP.029.b) [voraussichtlich]│
│ 2026-08-31 ░ Klageerwiderung (RoP.023) [voraussichtlich]│
│ │
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━ │
│ │
│ Vergangenheit │
│ ───────────────────────────── │
│ 2026-04-29 ✓ Klageschrift zugestellt (Anker) │
│ 2026-04-25 ✓ Akte angelegt (Audit) │
└──────────────────────────────────────────────────────────────┘
```
- `░` (faded) = projected, `✓` = done, `!` = overdue (red), `…` = open (amber), `▢` = court-set (dashed border).
- "Datum setzen" on the Hauptverhandlung row is the click-to-anchor affordance 6).
- "voraussichtlich" pill is the projected-status visual; tooltip explains "Anhand des Standardverlaufs aus dem Fristenrechner berechnet".
- Filter chip selector reveals the FilterBar primitive directly above the list (collapsed by default to reduce noise on first load same affordance riemann shipped on /inbox).
### 3.3 State C — UPC_INF + Counterclaim (CCR-Subprojekt)
```
┌──────────────────────────────────────────────────────────────────────────────┐
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Track ▼ Beide] [Filter ▼] │
├──────────────────────────────────────────────────────────────────────────────┤
│ Verletzung (Klägerseite) ┊ Widerklage (Beklagtenseite, CCR) │
│ ──────────────────────────────────────┊──────────────────────────────────────│
│ Zukunft (vorhergesagt) │
│ 2027-02-20 ░ Hauptverhandlung ┊ │
│ [Datum setzen] ┊ │
│ 2027-01-29 ░ Rejoinder R.29.e ┊ 2026-12-29 ░ Rejoinder R.32.3 │
│ 2026-12-29 ░ Reply to Defence-CCR ┊ │
│ 2026-11-29 ░ Defence to App-amend ┊ 2026-11-29 ░ Reply to Defence-amend│
│ 2026-10-31 ░ Defence to CCR (R.29a)┊ 2026-09-30 ░ Defence to amend │
│ 2026-08-31 ░ Klageerwiderung mit CCR┊ │
│ ┊ │
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ┊ │
│ Vergangenheit ┊ │
│ 2026-04-29 ✓ Klageschrift zugestellt┊ ⊕ Widerklage angekündigt │
│ ┊ (off-script, 2026-05-02) │
│ 2026-04-25 ✓ Akte angelegt ┊ │
└──────────────────────────────────────────────────────────────────────────────┘
```
- Two parallel tracks left is the parent infringement, right is the linked counterclaim sub-project (see §4).
- `[Track ▼]` chip toggles between "Beide" (default when a CCR sub-project exists), "Nur Verletzung", "Nur Widerklage".
- "⊕" marks an off-script milestone (the counterclaim was *announced* before being formally filed a `project_events` row with `timeline_kind='custom_milestone'`).
- Mobile: stacks vertically with collapsible per-track headers.
---
## 4. Counterclaim shape — sub-project, defended
m's framing offered two shapes. Inventor recommendation: **sub-project**. Trade-off explicit.
### 4.1 The choice
| | **Sub-project (recommended)** | **Same-project, parallel proceeding-overlay** |
|---|---|---|
| Project rows | One per proceeding (parent INF + child CCR) | One project, two proceeding-types attached |
| `our_side` flip | Independent on the child (parent: claimant; child: defendant in CCR-on-validity, claimant on CCR-of-infringement) | Needs a "perspective per proceeding" sub-table |
| Determinator routing (t-paliad-167) | Existing child gets its own cascade | Needs proceeding-aware routing inside one project |
| Project tree (t-paliad-149) | Naturally appears as a nested node | Same-row, no tree change |
| Dashboard per-project counts | Each gets its own count | Mixing needs new "by-proceeding" aggregator |
| Visibility / RLS | Inherits `can_see_project` cascade | Same |
| CCR Number from CMS | Stored on child's `case_number` | Stored on parent in a new `case_numbers jsonb` |
| New schema | None (uses existing project + parent_id) | New `project_proceedings` join table |
### 4.2 Why sub-project
- **Cheap.** Zero schema migration. The hierarchy already supports arbitrary nesting (4 types: client / litigation / patent / case but `parent_id` is type-agnostic).
- **Consistent with the data we just built.** t-paliad-164 our_side, t-paliad-149 project tree, t-paliad-167 Determinator cascade, t-paliad-168 deadline-rule jurisdiction defaults all assume "one project = one proceeding perspective". Counterclaim being a sub-project just means we keep that assumption.
- **CCR Number.** The counterclaim has its own CCR number in the UPC CMS which means it is in fact a separate proceeding artifact, not just a phase of the parent. Modeling it as a separate project row with its own `case_number` reflects reality. The "case-complex-wise" closeness m asks about is the parent_id link, not collapsing them into one row.
- **Independent timeline math.** UPC R.49(2) puts CCI / app-to-amend "as part of" Defence to revocation but that just means zero-duration filed-with-parent. The downstream re-anchoring is independent in each tree.
### 4.3 The link
A new optional FK on `paliad.projects`:
```sql
-- migration NNN_projects_counterclaim_of.up.sql
ALTER TABLE paliad.projects
ADD COLUMN counterclaim_of uuid NULL
REFERENCES paliad.projects(id) ON DELETE SET NULL;
CREATE INDEX projects_counterclaim_of_idx
ON paliad.projects (counterclaim_of)
WHERE counterclaim_of IS NOT NULL;
-- A project can be EITHER a parent (counterclaim_of IS NULL) OR a
-- counterclaim against another project (counterclaim_of points at it),
-- but not both. Enforced by a CHECK on the union of FKs (see §10).
```
`parent_id` keeps the standard hierarchy (the counterclaim child still lives under the same patent / litigation tree). `counterclaim_of` is an *additional* relation expressing "this project is the CCR against project X". The two are both set on a counterclaim sub-project.
### 4.4 Creating a counterclaim from the timeline
The "+ Eintrag" button on the parent's SmartTimeline opens a typed-add modal 7). Picking type=`Counterclaim` (UPC) creates a child project with:
- `parent_id` = parent's parent (so CCR appears as a sibling under the patent, not a grandchild debatable; see §11 Q4).
- `counterclaim_of` = parent project id.
- `proceeding_type_id` = `UPC_REV` (CCR-on-validity is the standard case; UPC_CCI is the rarer R.49.2.b path).
- `our_side` = inverted from parent (parent claimant child defendant, parent defendant child claimant).
- `title` = `<patent> — Widerklage (CCR)` auto-suggested.
The same flow applies to `case_amend` (UPC R.30 application to amend) a separate child sub-project. *Whether to model R.30 as a child project or as a flag on the parent is open: amendments are usually just a flag in our existing model. Default v1 = stay as flag, do **not** create a sub-project for application-to-amend; only formal counterclaims (CCR / CCI) get sub-projects.*
### 4.5 What the parent's SmartTimeline shows for the child
When `counterclaim_of` exists pointing at this project, the SmartTimeline renders a parallel right-track with the child's events (limited to `kind IN ('deadline','appointment','milestone')` child's projected rows are also included). User can collapse/hide the child track via the `[Track ▼]` chip.
The child's own SmartTimeline shows its own events as the primary track plus the parent as a left-side faded-context track (so the lawyer working on the CCR can see what's happening on the main proceeding without leaving the page).
---
## 5. Parent-node aggregation rule
What does the SmartTimeline render at higher levels of the project hierarchy? The four levels we have today:
### 5.1 Per-level rendering
| Level | Default render | Why |
|---|---|---|
| **Case** (UPC-CFI X) | Full SmartTimeline of self + parallel-track for any linked CCR sub-project. All zones, all kinds. | The lawyer working a single proceeding sees everything in one view. |
| **Patent** (EP3456789) | Lanes one per child case. Each lane shows only `kind IN ('deadline','milestone')` + status `IN ('done','open','overdue')`. Projected rows hidden by default (unfold-per-lane on click). | A patent typically has 1-3 active cases (CFI + CoA + opposition). Showing all projected rows from every case = overwhelming. Showing actuals + structural milestones gives the matter-level view. |
| **Litigation** (Siemens ./. Huawei) | Lanes one per child patent's primary case (most-recently-active case). Show only `kind='milestone'` + status=`done` + per-case "next due" pill. | Litigation level is portfolio-of-patents-against-this-defendant. Useful to see when each patent's current proceeding is, not the granular deadlines. |
| **Client** (Siemens AG) | Default = matter list (existing project tree). Behind a "Timeline-Ansicht" toggle, lanes = one per litigation. Shows only `kind='milestone'` + status=`done`. | Client level can have 100+ matters. A timeline across all is meaningless. The toggle makes it discoverable for the partner who wants the bird's-eye view. |
### 5.2 The single rule
> Each level removes one tier of detail and adds one tier of grouping. Going up: fewer kinds rendered, fewer statuses surfaced, more lanes.
| Level | Kinds | Statuses | Lanes |
|---|---|---|---|
| Case | all | all | self + CCR child |
| Patent | deadline + milestone | done + open + overdue | one per child case |
| Litigation | milestone | done | one per child patent |
| Client | milestone (toggle) | done | one per child litigation |
This rule is implementable as a single `levelPolicy(projectType)` function in `ProjectionService` returning a `(kinds, statuses, lane_grouping)` triple. All four cases share the same render component; only the input filter varies.
### 5.3 Off-script events at higher levels
Off-script milestones (counterclaim filed, party amendment, scope change) are first-class at every level they're the events m most cares about seeing at the litigation/patent overview. The "milestone" kind survives the level filter at all levels.
### 5.4 Not in v1
Cross-matter aggregation (e.g. "all my UPC matters, one timeline") is a Custom-View concern (t-paliad-144 substrate). The SmartTimeline is project-scoped; cross-project goes through `/views/{slug}` with a sources=`timeline` ViewSpec. Phase 5+, after t-paliad-163 Phase B lands.
---
## 6. Date-anchoring + reflow semantics
### 6.1 The rule (explicit)
> An actual date — recorded as a `paliad.deadlines.due_date` (status `done`) or `paliad.appointments.start_at` (in the past) or a milestone date — anchors every downstream projected event whose parent rule is the corresponding deadline_rule. The reflow propagates one parent-step at a time, until the next actual takes over or the chain bottoms out.
In other words: the existing `AnchorOverrides` mechanism in `FristenrechnerService.Calculate` is exactly the load-bearing primitive. The SmartTimeline's `ProjectionService` builds the override map at request time:
```go
overrides := map[string]string{}
for _, d := range completedDeadlines {
if d.RuleCode == "" || d.CompletedAt == nil { continue }
overrides[d.RuleCode] = d.CompletedAt.Format("2006-01-02")
}
// Court-set rules pick up the actual date too — set when the user enters
// "Hauptverhandlung fand statt am ..." via the inline anchor affordance.
opts := CalcOptions{AnchorOverrides: overrides, Flags: flagsForProject(p)}
result := frist.Calculate(ctx, p.ProceedingCode, p.TriggerDate, opts)
```
### 6.2 The UI affordance
Each projected row carries a `[Datum setzen]` link (or full-row click on tap-targets). Click inline date input expands inline. On submit:
- If the row corresponds to a `deadline_rules` entry that has a *real* deadline (not court-set), the action creates a `paliad.deadlines` row with `rule_id` set, `due_date=entered`, `original_due_date=projected`, `source='anchor'`, `status='done'`, `completed_at=entered`. (The "anchor" source is new; existing values are `manual`, `rule`, `import`. v1 adds `'anchor'` to the existing CHECK list.) This is the "we just learned the parent fact" path.
- If the row is court-set (decision / hearing / order), the action creates a `paliad.appointments` row with `start_at=entered`, `appointment_type='hearing'|'decision'|'order'` derived from the rule's `event_type`. The appointment links back to `rule_code` via a new optional FK column `paliad.appointments.deadline_rule_id` (nullable; existing rows stay null).
- Either way, the next read recomputes the projection with the new override and downstream rows reflow.
### 6.3 Editing an actual date later
If the user clicks an existing actual row's date, the inline editor PATCHes the underlying record (`/api/deadlines/{id}` or `/api/appointments/{id}`), and the next read re-projects.
### 6.4 What happens to overdue projected rows
A projected row whose date is in the past but no actual exists yet renders as "vorhergesagt überfällig" (faded amber). Clicking it lets the user either (a) anchor it as actual on a different date, or (b) explicitly mark "ist nicht eingetreten / wurde verschoben" which writes a `project_events` row with `event_type='rule_skipped'` + `timeline_kind='milestone'` so the audit trail records the decision.
---
## 7. Off-script event UX
The cardinal constraint: "We must hold it flexible add events regardless of whether they fit the normal course." Off-script events are first-class.
### 7.1 The "+ Eintrag" CTA
Persistent button in the SmartTimeline header. Click typed-add modal:
```
┌──────────────────────────────────────────────────┐
│ Neuer Eintrag im SmartTimeline │
├──────────────────────────────────────────────────┤
│ │
│ Was ist passiert? (oder wird passieren?) │
│ │
│ ◯ Frist → /deadlines/new │
│ ◯ Termin → /appointments/new │
│ ◯ Widerklage (CCR) → Anlegen Sub-Akte │
│ ◯ Anwendung auf Änderung (R.30) → Flag setzen │
│ ◯ Schriftsatz / Order → Off-script │
│ ◯ Eigener Meilenstein → Off-script (frei) │
│ │
│ [ Abbrechen ] [ Weiter ▶ ] │
└──────────────────────────────────────────────────┘
```
The visible options depend on the project's `proceeding_type_id`. UPC_INF gets the CCR + R.30 routes; UPC_REV gets CCI; DE_INF gets none of these. The "Schriftsatz / Order" + "Eigener Meilenstein" routes are universal.
### 7.2 The off-script branch
For "Schriftsatz / Order" and "Eigener Meilenstein" a small form:
```
Off-script Meilenstein
Titel: [Widerklage angekündigt durch Beklagten ]
Datum: [2026-05-02]
Beschreibung: [Schreiben des Beklagtenanwalts vom 02.05., … ]
Verknüpfung: ☐ Frist daraus erzeugen ☐ Termin daraus erzeugen
Sichtbar in: ◉ Diese Akte ◯ Diese Akte + Eltern
↑ Will it bubble up to higher levels?
[ Abbrechen ] [ Speichern ]
```
On submit, writes a `paliad.project_events` row with:
- `event_type='off_script_milestone'` (new value in the event_type enum-ish CHECK; today's CHECK is open-ended text confirm during impl).
- `timeline_kind='custom_milestone'`.
- `event_date=entered`.
- `description=...`.
- `metadata={"track": "parent" | "off_script", "links": [...]}`.
The optional checkboxes "Frist daraus erzeugen / Termin daraus erzeugen" open the standard deadline/appointment-create flow with the milestone's data prefilled and the milestone's id linked via metadata for audit trail.
### 7.3 Curated catalogue per proceeding type (NICE TO HAVE)
A small lookup table `paliad.timeline_event_catalogue (proceeding_type_id, kind, slug, name_de, name_en, primary_party)` could surface in the modal as a "Häufige Ereignisse" section above the universal "Eigener Meilenstein" route. Examples:
- UPC_INF: Counterclaim Filed, Third Party Intervention, Hearing Postponement, Cost Decision Issued
- UPC_REV: Application to Amend Filed, Substantive Decision, Costs Order
- DE_INF: Hinweisbeschluss Issued, Verteidigungsanzeige, Termin Hauptverhandlung, Versäumnisurteil
The catalogue is a v2 nice-to-have. v1 ships with "Eigener Meilenstein" as the universal escape hatch and the few proceeding-specific routes named above (CCR, CCI, R.30) hardcoded on the modal.
---
## 8. Filter facets — first-pass refinement
Refining the task brief's first-pass list against the FilterBar API (riemann's `BarState` / `AxisKey`). Each axis maps to either a universal axis (already shipped), an existing per-source stub (riemann left ready), or a new one.
### 8.1 Reused universal axes (already in BarState)
- **`time`** (universal, chip cluster + custom range) past 30/90d, next 30/90/any/custom. Default = `any`. Re-used verbatim; no work.
- **`personal_only`** (universal, chip) re-used. "Nur meine Einträge" `created_by=me`. Behavior same as on `/events` (t-paliad-128).
### 8.2 New per-source axes (extend `AxisKey`)
```ts
// frontend/src/client/filter-bar/types.ts — additions
export type AxisKey =
| existing
| "timeline_kind" // multi-select chip cluster
| "timeline_status" // multi-select chip cluster
| "timeline_track" // multi-select chip cluster
;
export interface BarState {
existing
timeline_kind?: ("deadline" | "appointment" | "milestone" | "projected")[];
timeline_status?: ("done" | "open" | "overdue" | "court_set" | "predicted" | "off_script")[];
timeline_track?: ("parent" | "counterclaim" | string /* child:<projectid> */)[];
}
```
### 8.3 The facet set on the SmartTimeline surface
The surface declares this `axes` array when it mounts the bar:
```ts
mountFilterBar(host, {
axes: [
"time", // universal — past/future filter
"timeline_kind", // deadline | appointment | milestone | projected
"timeline_status", // done | open | overdue | court_set | predicted | off_script
"timeline_track", // parent | counterclaim | child:<id>
"personal_only", // optional — toggle "nur meine Einträge"
"deadline_event_type", // existing stub — wired in t-paliad-117 multi-select
"shape", // timeline (default) | list | cards
"sort", // chronological asc/desc
"density", // comfortable | compact
],
surfaceKey: "project-smart-timeline",
systemViewSlug: "project-timeline",
});
```
### 8.4 Defaults
- `time = any`
- `timeline_kind = [deadline, appointment, milestone]` (projected hidden by default the user opts in via chip; reduces noise on first load when most projects don't have a proceeding type set)
- `timeline_status = [done, open, overdue, off_script]` (predicted + court_set hidden by default if `projected` kind is hidden the chip group is one logical "show future" toggle)
- `timeline_track = all available`
- `shape = timeline`
- `sort = date_desc` (most recent first; matches today's Verlauf default)
- `density = comfortable`
### 8.5 The "show future" macro
Most users will only want one toggle: "Zukunft anzeigen". We render that as a primary chip pair next to `time`:
```
[ Vergangenheit | Heute | Zukunft ] ← primary toggle
```
Internally this maps to `time + timeline_kind` (Vergangenheit hides projected, Zukunft shows projected, Heute is just today). Power users can drill into the granular axes via the bar.
### 8.6 What riemann's port (t-paliad-170) needs to know
Riemann is porting FilterBar onto the Verlauf surface in parallel. Three things they need:
1. **Three new axis keys** (`timeline_kind`, `timeline_status`, `timeline_track`). They render as chip clusters the same primitive `chipRow + chipBtn` riemann already factored.
2. **`shape: "timeline"`** is a new render shape. Existing shapes are `list | cards | calendar` (t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse to `list` (compact audit log) or `cards` (chronological card grid) without losing the data. Implementation = new `frontend/src/client/views/shape-timeline.ts` mirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).
3. **The `timeline_track` axis options are dynamic** they depend on whether the project has a counterclaim child. The bar already supports lazy axes (the `project` axis pattern in `axes.ts:30` `"populated lazily"`). `timeline_track` follows the same shape: surface fetches available tracks at mount, passes them to the bar.
---
## 9. Verfahrensablauf-logic sharing — extract, don't import
**Recommendation: extract into a shared module first.**
### 9.1 The decision
The wizard's projection logic is currently in two places:
1. `internal/services/fristenrechner.go:Calculate(...)` the canonical Go implementation. Already returns a `UIResponse{Deadlines []UIDeadline}` keyed by rule_code, supports `AnchorOverrides`. ~1000 lines, tested.
2. `frontend/src/client/fristenrechner.ts:calculate()` the frontend wrapper that POSTs `/api/tools/fristenrechner` and handles flags + overrides. ~3500 lines including the wizard UI, but the projection-relevant slice is small (call + render).
The SmartTimeline's `ProjectionService.For(projectID)` needs the *Go calculator*, not the frontend code path. So the question is really: *do we add a new Go service that wraps `FristenrechnerService.Calculate` for projects?*
Yes a thin adapter, not a parallel implementation.
### 9.2 The adapter
```go
// internal/services/projection_service.go (new, ~200 LoC)
type ProjectionService struct {
db *sqlx.DB
fristen *FristenrechnerService
deadlines *DeadlineService
appointments *AppointmentService
projects *ProjectService
courts *CourtService
}
// For builds a SmartTimeline for one project (and its CCR child if any).
// Composes the four zones described in §1; returns sorted TimelineEvent[].
func (s *ProjectionService) For(ctx context.Context, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
p, err := s.projects.GetVisible(ctx, projectID, opts.ViewerID)
// ...
children := s.projects.LoadCounterclaimChildrenVisible(ctx, projectID, opts.ViewerID)
actuals := s.collectActuals(ctx, []uuid.UUID{p.ID, children...}) // dl + appt + milestones
overrides := buildAnchorOverrides(actuals)
var projected []TimelineEvent
if p.ProceedingTypeCode != "" && p.TriggerDate != nil {
proj := s.fristen.Calculate(ctx, p.ProceedingTypeCode, p.TriggerDate.Format("2006-01-02"),
CalcOptions{AnchorOverrides: overrides, Flags: flagsFor(p), CourtID: p.CourtID})
projected = projectionToTimeline(proj, p, actuals)
}
// (same for each child counterclaim)
return mergeAndSort(actuals, projected, opts.LevelPolicy), nil
}
```
The adapter does not duplicate the calculator it calls `FristenrechnerService.Calculate` exactly once per (project, child). Same code path as `/api/tools/fristenrechner` uses today; same tests cover both.
### 9.3 What the standalone wizard keeps
`/tools/fristenrechner` continues to use `FristenrechnerService.Calculate` directly it's a knowledge-platform tool, not a project-scoped view. It does not gain anchoring affordances or off-script events. The projection there is hypothetical ("if you start a UPC_INF on date X, here's the timeline"), not project-actual.
`ProjectionService` is a project-scoped composition layer; it lives one level above `FristenrechnerService` in the dependency graph.
### 9.4 The test split
- `fristenrechner_test.go` keeps testing the calculator (duration math, AnchorOverrides, CourtID resolution).
- `projection_service_test.go` (new) tests the composition: mixing actuals + projected, level policy, counterclaim child merging, sort order.
---
## 10. Phasing — 4 sequential slices
Each slice is independently shippable and reviewable. m's go/no-go gate after each.
### Slice 1 — SmartTimeline skeleton (no projection yet)
What lands:
- New `internal/services/projection_service.go` with `For()` returning only actuals (deadlines + appointments + opted-in `project_events`). No `fristenrechner` call yet.
- Migration `NNN_project_events_timeline_kind.up.sql` adds the optional column + partial index 2.2).
- New endpoint `GET /api/projects/{id}/timeline?…` returning `[]TimelineEvent`.
- `frontend/src/client/projects-detail.ts:loadEvents` rewritten to call `/timeline` instead of `/events`. The current Verlauf list is replaced by the new vertical timeline component (`client/views/shape-timeline.ts` new file, ~300 LoC).
- "+ Eintrag" CTA in the timeline header (modal partially implemented only "Eigener Meilenstein" route lit; CCR / R.30 / Frist / Termin routes are link buttons to existing flows).
- "Audit-Log anzeigen" toggle that switches to the legacy chronological list rendering (`paliad.project_events` ALL not just `timeline_kind IS NOT NULL`).
What it gives m: a working SmartTimeline showing past actuals + open/upcoming deadlines + appointments + off-script milestones, with the audit log surviving as a toggle. No future-projection yet.
### Slice 2 — Future-projection + click-to-anchor
What lands:
- `ProjectionService.For` calls `FristenrechnerService.Calculate` and emits projected rows.
- Click-to-anchor inline date editor 6.2). New endpoint `POST /api/projects/{id}/timeline/anchor` taking `{rule_code, actual_date, kind?}` and writing the appropriate `paliad.deadlines` (`source='anchor'`) or `paliad.appointments` (`deadline_rule_id` FK new) row.
- Migration `NNN_appointments_deadline_rule_id.up.sql` adds the optional FK on appointments + extends `paliad.deadlines.source` CHECK to include `'anchor'`.
- "voraussichtlich" / "Datum vom Gericht" status pills + projected-row CSS (faded + dashed border for court-set).
- New "Zukunft anzeigen" macro chip pair 8.5).
- `event_type='rule_skipped'` write path for the "ist nicht eingetreten" decision 6.4).
What it gives m: predicted future course based on standard timeline; click to fix any date when something happens; downstream reflows automatically.
### Slice 3 — Counterclaim sub-project
What lands:
- Migration `NNN_projects_counterclaim_of.up.sql` the new `counterclaim_of` FK + index + the CHECK (a project either has counterclaim_of OR is parent not both to keep the invariant clean).
- "+ Eintrag Widerklage (CCR)" route in the modal 7.1) creates child project with auto-suggested `our_side` flip, `proceeding_type_id`, and title, then navigates to it for the user to fill in `case_number`.
- `ProjectionService` loads CCR children + emits parallel-track rows.
- `[Track ▼]` chip in the header reads `available_tracks` from the timeline response.
- The two-column rendering on State C 3.3).
- `paliad.project_events` audit row written on counterclaim creation (`event_type='counterclaim_created'`, `timeline_kind='milestone'`).
What it gives m: counterclaims as proper sub-projects, parallel timelines, CCR perspective-flip works end-to-end.
### Slice 4 — Parent-node aggregation
What lands:
- `levelPolicy(projectType)` in `ProjectionService` kinds/statuses/lane filter per level 5.1).
- Lane-grouped rendering at Patent / Litigation / Client levels.
- "Timeline-Ansicht" toggle on Client-level project page (default off; lanes-of-litigations when on).
- Off-script milestones bubble up to higher levels via the `metadata.bubble_up: true` flag 7.2 form's "Sichtbar in: Diese Akte + Eltern" checkbox).
What it gives m: portfolio-level timelines without overload the bird's-eye view he asked about.
### What's NOT in any slice
- Curated per-proceeding event catalogue 7.3) v2 nice-to-have.
- Gantt rendering separate `shape: "gantt"` follow-up.
- Cross-matter timeline Custom Views path.
- Outlook integration out of scope.
---
## 11. Open questions for m
Listed with my (inventor) pick where I have one m decides.
**Q1 — Counterclaim sub-project vs proceeding-overlay (§4).** I recommend sub-project. Confirm before Slice 3 design lock.
**Q2 — Should `our_side` flip automatically on counterclaim sub-project creation?** My pick: yes, default-flip with a "Stimmt nicht?" toggle on the create modal. The R.49.2.b CCI is the edge case (parent claimant child claimant in CCI of the *separate* infringement claim), but the standard CCR-on-validity always inverts. Default-flip + toggle handles both.
**Q3 — Should `paliad.deadlines.source` gain `'anchor'` or should we re-use `'manual'`?** My pick: new `'anchor'` value separates "user-typed-it-in" from "user-recorded-an-actual-after-projection-fired" for analytics + future automated import (Outlook event anchor).
**Q4 — Counterclaim sub-project's `parent_id` — under the patent (sibling to parent case) or under the parent case (grandchild)?** My pick: under the patent (sibling). The CCR is its own proceeding with its own case_number; modeling it as a sibling to the parent infringement, both under the patent, mirrors how UPC CMS sees them. Grandchild placement would imply CCR is "part of" the parent case which it structurally isn't.
**Q5 — Off-script milestone bubble-up default.** My pick: default-on for `event_type IN ('counterclaim_created', 'third_party_intervention', 'scope_change')`; default-off for `event_type='custom_milestone'`. Form has the override checkbox in either case.
**Q6 — Should `/tools/fristenrechner` keep its standalone existence?** Brief says yes knowledge tool, separate from project context. My pick: yes, agree. It stays.
**Q7 — Application-to-amend (UPC R.30) as sub-project or flag?** My pick: stay as flag (`with_amend`). Amendments are not a separate proceeding artifact in the CMS they ride on the parent's record. The cross-flow rules already activate via `condition_flag`.
**Q8 — On the parent's SmartTimeline, do CCR rows mix into one column or stay in a parallel right-track?** My pick: parallel right-track when both are populated; collapses into one column on mobile (vertical stacking with sub-headers per track). The `[Track ▼]` chip lets desktop users opt into single-column mode.
**Q9 — Court-set anchor (Hauptverhandlung) creates a `paliad.appointments` row or a `paliad.deadlines` row?** My pick: `paliad.appointments` it's an appointment, not a deadline. The new `appointments.deadline_rule_id` FK preserves the link back to the rule for downstream re-anchoring.
**Q10 — Is `timeline_kind` the right column name?** Alternatives: `is_timeline_milestone bool`, `surface_on_timeline bool`. My pick: keep `timeline_kind text NULL` because it lets us distinguish `milestone` (structural) from `custom_milestone` (free-form) without a second column.
**Q11 — Should the SmartTimeline be the only view of the project's events?** Or do we keep a "klassisch (chronologisch)" sidebar tab? My pick: SmartTimeline as the only Verlauf tab; "Audit-Log anzeigen" toggle inside the timeline reveals the chronological rendering. m uses `/admin/audit-log` (t-paliad-071) for the cross-project audit query.
**Q12 — Patent-level "matter list vs lane timeline" default.** My pick: lanes by default at Patent + Litigation; matter list by default at Client. The Litigation level has 1-3 child patents typically 1-3 lanes is fine. Client can have 100+ lanes are a toggle.
---
## 12. Files implementer will touch (Slice 1 only)
Aggregated for the coder shift kickoff:
**Backend (Go):**
- `internal/services/projection_service.go` new, ~250 LoC.
- `internal/handlers/projection.go` new, GET /api/projects/{id}/timeline, ~80 LoC.
- `internal/handlers/handlers.go` register the new route.
- `internal/db/migrations/NNN_project_events_timeline_kind.{up,down}.sql` new.
**Frontend (TS / TSX):**
- `frontend/src/client/views/shape-timeline.ts` new render shape, ~300 LoC.
- `frontend/src/client/projects-detail.ts:loadEvents` replace with timeline fetch.
- `frontend/src/projects-detail.tsx:74-101` replace Verlauf markup with `<div id="project-smart-timeline">`.
- `frontend/src/styles/global.css` `.smart-timeline-*` styles, ~150 LoC.
- `frontend/src/client/i18n.ts` ~30 keys under `projects.detail.smarttimeline.*`.
**Tests:**
- `internal/services/projection_service_test.go` new (live-DB integration test, skipped without `TEST_DATABASE_URL`).
- `internal/services/projection_service_unit_test.go` pure-function tests (sort, level policy, override-build).
Slices 2-4 are scoped in §10; coder picks them up after m's gate.
---
## 13. Trade-offs flagged
- **Per-request projection cost.** Recomputing on every Verlauf load is fine for a single project. If m navigates to a Client-level lane view with 50 child litigations × 3 cases each, that's 150 calculator invocations. Mitigation: lane-rendering at Litigation+Client levels excludes `kind='projected'` by default 5), so the calculator is only called on the leaf rendering. Watch in production; add per-(project, hash(overrides)) cache if needed.
- **Migration order across active workers.** riemann is on t-paliad-170 (FilterBar Verlauf port) in parallel. Slice 1 must merge **after** their port because Slice 1 mounts the bar with new axis keys. Coordinate via head before Slice 1 PR opens.
- **Sub-project counterclaim adds a tier.** The project tree gets deeper (Patent Case + Patent CCR-Sub-Case as siblings). Existing tree visualisation in t-paliad-149 handles arbitrary depth, but the per-card "in 3 children" badge needs to count the CCR child correctly verify in Slice 3.
- **`appointments.deadline_rule_id`** is a backward-pointing FK that doesn't exist yet. Adding it in Slice 2 is clean (nullable, no backfill needed). Just flagging that this ties appointments to deadline_rules where they previously had no link.
- **Anchor write path can race.** Two users clicking "Datum setzen" on the same row simultaneously could both write `paliad.deadlines` rows. Mitigation: server-side check `WHERE NOT EXISTS (SELECT 1 FROM paliad.deadlines WHERE project_id=... AND rule_id=...)` before insert, otherwise PATCH the existing row. Standard pattern.
- **What if the proceeding type changes mid-flight?** The user changes `paliad.projects.proceeding_type_id` after deadlines have been calculated. Existing actuals stay (they have `rule_id` FK pointing to the OLD rule tree). Projected rows recompute against the NEW rule tree; rule_codes that don't exist in the new tree drop out. This is the same behaviour today flagging because the SmartTimeline makes it more visible.
---
## 14. Recommendation for implementer
Pattern-fluent Sonnet coder. Slice 1 is largely boilerplate (new service + handler + render shape). Slice 2 needs the calculator integration which is well-trodden (t-paliad-131 Phase A shipped overrides). Slice 3 needs the sub-project FK design (one careful migration) and the parallel-track CSS. Slice 4 is render-policy logic, low-risk.
Lagrange (this worktree) parks. NOT pre-emptively flipping to coder m gates.
---
**DESIGN READY FOR REVIEW**

View File

@@ -0,0 +1,394 @@
# Research — Determinator coverage audit (gaps + smart-navigation framing)
**Author:** curie (researcher)
**Date:** 2026-05-08
**Task:** t-paliad-167 (Gitea m/paliad#26)
**Mode:** read-only research; produces a gap matrix + design framing, not migrations.
Builds on `docs/audit-upc-rop-deadlines-2026-05-08.md` (t-paliad-159) which drove from the UPC Rules of Procedure outward. This one drives from **paliad's own corpus** outward: every active rule, every firm-wide event_type, every cascade leaf — and asks "can a Determinator user actually reach this row?"
m's prompt (verbatim, 2026-05-08 22:24 Determinator dogfooding):
> We are still missing all kinds of orders in our decision tree. What do we need to do to cover everything? Can we maybe check what "options" we have covered in our tree and which we don't? I want to have a smart way to navigate people through the tree to determine what's next.
---
## 1. Scope and method
**Five surfaces, three pathways.**
paliad currently has three independent ways to land on a deadline:
- **Pathway A — Fristenrechner (proceeding tree).** User picks a proceeding type (`UPC_INF`, `DE_NULL`, `EPA_OPP`, …) and a trigger date; the engine emits the entire timeline. Source: `paliad.deadline_rules` rows where the parent proceeding has `category='fristenrechner'` (19 active proceeding types).
- **Pathway B — Determinator cascade.** User answers "what just happened?" by drilling 1-3 levels through `paliad.event_categories` (6 roots → 27 → 49 → 43 leaves; 103 leaves total). Each leaf maps to one or more `paliad.deadline_concepts` via `paliad.event_category_concepts`. Concepts then resolve to rules (`deadline_rules.concept_id`) and event_types (`deadline_concept_event_types`, mig 072).
- **Pathway C — Trigger-event search.** Free-text `paliad.trigger_events` lookup (102 youpc-imported rows). Used by the t-paliad-086 "Was kommt nach…" mode and by autocomplete. Out of audit scope here — no Determinator surface uses it.
**Reachability rule.** For this audit, "reachable from the Determinator cascade" means: there exists some leaf `L` in `event_categories` such that `event_category_concepts(L → C)` and either:
- (rule-side) `deadline_rules.concept_id = C` for the rule under test, or
- (event_type-side) `deadline_concept_event_types(C, E)` for the event_type under test.
Concepts that exist but never appear in `event_category_concepts` are **dead-end concepts** — Pathway A may use them, Pathway B can't.
**Inventory snapshot (live youpc Supabase, 2026-05-08 22:30):**
| Surface | Rows | Notes |
|---|---|---|
| `proceeding_types` (`category='fristenrechner'`) | 19 | UPC×8, DE×5, EPA×2, EP×1, DPMA×3 |
| `proceeding_types` (`category='litigation'`, legacy/dormant) | 7 | INF, REV, CCR, AMD, APM, APP, ZPO_CIVIL — see §2.1 |
| `deadline_rules` active | 172 | 95 true deadlines (`duration_value > 0`), rest are anchors / court-set |
| `deadline_rules` true deadlines, `category='fristenrechner'` only | **76** | The audit denominator |
| `event_categories` active | 125 | 6 roots, 103 leaves |
| `event_category_concepts` mappings | 153 | 45 distinct concepts in cascade |
| `deadline_concepts` active | 57 | 45 in cascade, 12 dead-end |
| `event_types` firm-wide active | 44 | 26 reachable, 18 unreachable |
| `deadline_concept_event_types` (mig 072) | 32 rows / 25 concepts / 30 event_types | The Regel↔Typ junction |
**Cascade root inventory (Pathway B entry chips):**
| Root | Children | Leaves | Purpose |
|---|---|---|---|
| `cms-eingang` | gericht / gegenseite | 50 | Inbound — paper just landed |
| `muendl-verhandlung` | geladen / gehalten / verlegt / zwischenverfahren | 4 | Hearing-pivot |
| `beschluss-entscheidung` | (11 leaf decisions per forum) | 11 | Decision-pivot — duplicate of `cms-eingang.gericht.endentscheidung.*` |
| `frist-verpasst` | upc / de-patg / de-zpo / epa / dpma | 5 | Wiedereinsetzung family |
| `ich-moechte-einreichen` | klage / berufung / widerklage / spätere-schriftsätze / einspruch | 32 | Outbound — file something |
| `sonstiges` | — | 1 (dangling, no concept) | Escape hatch |
**Per-forum cascade depth:** UPC has 38 reachable leaves, DE 35, EPA 11, DPMA 7. The DE corpus is now within 8% of UPC's — the imbalance flagged in earlier audits is largely closed. EPA/DPMA remain underbuilt.
---
## 2. Inventory by jurisdiction
Each section answers the same three questions: (a) which rules exist, (b) are they reachable from the cascade, (c) what's missing relative to a real practitioner's everyday surface area.
### 2.1 Legacy / dormant proceedings (out of scope but worth flagging)
The 7 `category='litigation'` proceedings (INF, REV, CCR, APM, AMD, APP, ZPO_CIVIL) carry **40 active rules** between them but:
- 0 cascade references (`event_category_concepts.proceeding_type_code` never names them),
- 0 concept_id linkage on any of their 18 true deadlines,
- not surfaced in the Fristenrechner UI (filtered by `category='fristenrechner'` in `deadline_rule_service.go:740`).
These rows are zombie taxonomy from migration 008/009 — superseded by the `UPC_*` / `DE_*` / `EPA_*` / `DPMA_*` family in mig 012/042/043/044. **Recommendation:** flag them `is_active=false` in a follow-up cleanup migration; they only confuse audits.
The audit denominator is therefore **76 true Fristenrechner deadlines across 19 active proceedings**.
### 2.2 UPC
Most-mature jurisdiction. 8 proceedings, 40 true deadlines, 39 reachable from cascade.
| Proceeding | True deadlines | Reachable | Notes |
|---|---|---|---|
| UPC_INF | 11 | 10 | `inf.app_to_amend` (RoP.030.1, 2mo) has no concept_id — Pathway A only |
| UPC_REV | 9 | 9 | Plus 2 duration bugs flagged in t-paliad-159 (R.49.1 3→2mo, R.52 2→1mo) |
| UPC_PI | 0 | n/a | All 4 rules are anchors / court-set (no calendar arithmetic) |
| UPC_APP | 5 | 5 | 3 rule_code-drift bugs flagged in t-paliad-159 (R.224.1.a, R.224.2.a, R.235.2) |
| UPC_DAMAGES | 3 | 3 | |
| UPC_DISCOVERY | 3 | 3 | |
| UPC_COST_APPEAL | 1 | 1 | Tree-end leaf still missing R.155 chain |
| UPC_APP_ORDERS | 4 | 4 | R.224.2.b grounds-on-orders missing entirely (RoP audit gap 6) |
**Cascade-side gaps that t-paliad-159 surfaced and remain open:**
- R.19 Preliminary Objection (no leaf, no rule, no event_type — but `upc_preliminary_objection` event_type exists, archived from cascade)
- R.197.3 Saisie review request, R.198/R.213 31d-or-20wd start-of-merits
- R.262.2 Confidentiality response (14d) — daily occurrence in HLC infringement, completely absent from both pathways
- R.333.2 Review of CMO (15d) — trigger event #16 exists, no rule, no leaf
- R.353 Rectification (1mo) — trigger event #41 exists, no rule, no leaf
- R.207.6.a / R.229.2 / R.71 Mängelbeseitigung — registry-correction family entirely missing
- R.109.1 / R.109.4 / R.109.5 oral-hearing translation prep (only `before`-mode rules in the corpus)
### 2.3 DE (Zivilgericht + Bundesinstanzen)
5 proceedings, 22 true deadlines, all 22 reachable from cascade.
| Proceeding | True deadlines | Reachable | Cascade entry |
|---|---|---|---|
| DE_INF | 6 | 6 | `cms-eingang.gegenseite.de-inf.*` + `urteil-de-inf-lg` |
| DE_NULL | 5 | 5 | `cms-eingang.gegenseite.de-null.*` + `urteil-de-null-bpatg` |
| DE_INF_OLG | 3 | 3 | `urteil-de-inf-lg` (Berufung-Begründung) |
| DE_INF_BGH | 5 | 5 | `urteil-de-inf-olg` (NZB / NZB-Begründung / Revisionsfrist / Revisionsbegründung) |
| DE_NULL_BGH | 3 | 3 | `urteil-de-null-bpatg` (Berufung BGH) |
**Headline DE gaps (entirely uncovered by both pathways):**
- **Hinweisbeschluss** — `cms-eingang.gericht.hinweisbeschluss` leaf exists and links to `response-to-preliminary-opinion` concept, but **no rule row computes a deadline from it**. The concept has 1 rule (`r79-further-stellungnahme`, 2mo) wired to EPA_OPP only. The DE Hinweisbeschluss deadline (4 weeks under §139 ZPO is judge-set; under § 522 ZPO Berufung-Hinweis is judge-set with min 2 weeks) is not in the rule corpus.
- **Beweisbeschluss / Beweissicherungsanordnung (DE)** — `cms-eingang.gericht.anordnung` leaf exists but only links to `request-for-discretionary-review` (UPC R.220.3). No DE-side reaction (e.g. Stellungnahme nach Beweisaufnahme, § 411 ZPO 2-week comment on Sachverständigengutachten).
- **Streitwertbeschluss** — neither cascade leaf nor rule. Streitwertbeschwerde is § 68 GKG, 6 months → frequent and unrepresented.
- **Versäumnisurteil** — leaf `versaeumnisurteil` exists with concept `versaeumnisurteil-einspruch`, but the concept has 0 rules. The 2-week Einspruch deadline (§ 339 Abs. 1 ZPO) is documented in the concept text but doesn't compute. A user lands on the leaf and gets a hint card, no calendar entry.
- **ZPO Klage as starting point** — Pathway A has a legacy `ZPO_CIVIL` proceeding (dormant per §2.1) but no live equivalent; Pathway B's `cms-eingang.gegenseite.de-inf.klageschrift` covers the *defendant*'s perspective only. A claimant entering "I just filed a Klageschrift" has no path.
- **Schriftsatznachfristsetzung (§ 283 ZPO)** — concept `schriftsatznachreichung` exists in cascade with 0 rules; "court grants me a 3-week response window" produces no calendar entry.
### 2.4 EPO
2 active proceedings (EPA_OPP, EPA_APP) plus 1 grant-side outlier (EP_GRANT). 12 true deadlines, 8 reachable from cascade.
| Proceeding | True deadlines | Reachable | Notes |
|---|---|---|---|
| EPA_OPP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-opp.einspruchsschrift` + `entscheidung-epa-opp` |
| EPA_APP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-app` + `entscheidung-epa-boa` |
| **EP_GRANT** | **4** | **0** | All 4 unreachable — concepts (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) have no `event_category_concepts` row |
**EP_GRANT is the single biggest blanket-gap in the audit.** The 4 most fundamental EPO grant-side deadlines (R.70(1) examination request 6mo, Art. 93 publication, R.71(3) approval+translation 4mo, search-report 6mo) are computable in Pathway A but the cascade has zero entry points for them. A user landing on the Determinator says "EP-Anmeldung erteilt, was nun?" and finds nothing.
**Headline EPO gaps (both pathways):**
- **R.71(3) communication received** — `cms-eingang.gericht.rechtsverlust-epa` covers the *negative* outcome (Rechtsverlust → Weiterbehandlung/Wiedereinsetzung) but the *positive* outcome (Mitteilung nach R.71(3) → 4-month approval+translation) has no leaf. The concept exists (`approval-and-translation`) but no leaf binds it.
- **R.94(3) examination-stage Bescheid** — entirely absent. Most-frequent EPO deadline in prosecution practice ("4-month period to respond to examination report"); no rule, no leaf, no event_type.
- **EPO opposition reply** — event_type `epo_opposition_reply` exists, archived from cascade (no concept link). Pathway A's EPA_OPP has the rule but no Pathway B path.
- **R.116 EPO oral-proceedings final-submissions** — covered (`r116-final-submissions` concept, 2 rules, leaf `muendl-verhandlung.geladen` + `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`).
- **Annual renewal fees (Art. 86 EPC)** — `epo_renewal_fee` event_type exists, archived from cascade. No concept, no rule.
### 2.5 DPMA
3 active proceedings (DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB). 6 true deadlines, all 6 reachable from cascade.
| Proceeding | True deadlines | Reachable | Cascade entry |
|---|---|---|---|
| DPMA_OPP | 2 | 2 | `cms-eingang.gegenseite.dpma-opp` + `entscheidung-dpma` |
| DPMA_BPATG_BESCHWERDE | 2 | 2 | `entscheidung-dpma` (Beschwerde) + `beschluss-bpatg-beschwerde` |
| DPMA_BGH_RB | 2 | 2 | `beschluss-bpatg-beschwerde` (Rechtsbeschwerde) |
**Headline DPMA gaps (both pathways):**
- **Beanstandungsbescheid (Prüfungsverfahren)** — DPMA examination-stage objection notice with 4-month default response window (§ 45 PatG). No rule, no leaf, no event_type. Most-frequent DPMA deadline in real practice and entirely unrepresented.
- **Aktenversendungsbescheid / Anhörungsbescheid (Einspruchsverfahren)** — § 59 PatG opposition oral-hearing summons; no leaf.
- **Anmeldetag-Mitteilung / Recherchenbericht (DPMA)** — `dpma_examination_request` event_type exists with concept link to `request-for-examination`, but the concept is a Pathway-A-only dead-end (not in cascade).
- **Patenterteilungsbeschluss** — no leaf for the positive grant decision (the negative-outcome Beschluss-BPatG path covers appeals, not the grant-stage event).
### 2.6 Cross-cutting (procedural orders that span jurisdictions)
The categories m specifically called out — "court orders that aren't entry events but procedural orders." Status:
| Order type | UPC | DE | EPA | DPMA | Notes |
|---|---|---|---|---|---|
| Hinweisbeschluss / vorläufige Würdigung | concept-only | concept-only (no rule) | n/a | n/a | Leaf `cms-eingang.gericht.hinweisbeschluss` exists; the only rule wired to `response-to-preliminary-opinion` is EPA-side R.79. Judge-set period in DE/UPC; the leaf produces no calendar entry. |
| Beweisbeschluss / Beweissicherungsanordnung | partial (R.196/R.197) | absent | n/a | n/a | Trigger events #26 / #44 / #65 / #66 exist; only R.197.3 (saisie review 30d) is missing as a rule. § 411 ZPO 2-week Stellungnahme-Frist nowhere. |
| Streitwertbeschluss | n/a | absent | n/a | n/a | § 68 GKG 6-month Streitwertbeschwerde — common, unrepresented. |
| Versäumnisurteil | n/a | leaf-only (no rule) | n/a | n/a | § 339 ZPO 2-week Einspruch — concept `versaeumnisurteil-einspruch` carries 0 rules. |
| Case-Management-Order (R.220.1.c / § 273 ZPO) | partial | absent | n/a | n/a | UPC R.333.2 review-of-CMO 15d missing; trigger event #16 exists. |
| Berichtigungsbeschluss / Tatbestandsberichtigung | absent | absent | n/a | n/a | UPC R.353 1mo / § 320 ZPO 2-week — both unrepresented. |
| Konfidentialitätsantrag der Gegenseite | absent | n/a | n/a | n/a | UPC R.262.2 14d — high-frequency in HLC infringement work. |
| R.71(3) communication | n/a | n/a | absent | n/a | The most-common EPO prosecution deadline. |
| Examination-stage Bescheid | n/a | n/a | absent (R.94(3)) | absent (§ 45 PatG) | 4-month response. Single biggest *prosecution* gap. |
| Mängelbeseitigung notification | absent (R.71/R.207.6.a/R.229.2) | absent | absent | absent | Cross-jurisdictional gap. Trigger event #71 exists for UPC. |
| Translation lodging order | absent (R.109.5) | n/a | n/a | n/a | `before`-mode rules — schema supports, no data. |
| Rechtsverlust-Mitteilung | n/a | n/a | leaf-only (covered) | n/a | Only EPA branch wired (`weiterbehandlung` + `wiedereinsetzung`). |
---
## 3. Cascade reachability tables
### 3.1 Rule reachability per proceeding
| Proceeding | True deadlines | No concept | Reachable | Unreachable (concept exists, not in cascade) |
|---|---|---|---|---|
| UPC_INF | 11 | 1 (`inf.app_to_amend`) | 10 | 0 |
| UPC_REV | 9 | 0 | 9 | 0 |
| UPC_APP | 5 | 0 | 5 | 0 |
| UPC_DAMAGES | 3 | 0 | 3 | 0 |
| UPC_DISCOVERY | 3 | 0 | 3 | 0 |
| UPC_COST_APPEAL | 1 | 0 | 1 | 0 |
| UPC_APP_ORDERS | 4 | 0 | 4 | 0 |
| EP_GRANT | 4 | 0 | 0 | **4** |
| DE_INF | 6 | 0 | 6 | 0 |
| DE_NULL | 5 | 0 | 5 | 0 |
| DE_INF_OLG | 3 | 0 | 3 | 0 |
| DE_INF_BGH | 5 | 0 | 5 | 0 |
| DE_NULL_BGH | 3 | 0 | 3 | 0 |
| EPA_OPP | 4 | 0 | 4 | 0 |
| EPA_APP | 4 | 0 | 4 | 0 |
| DPMA_OPP | 2 | 0 | 2 | 0 |
| DPMA_BPATG_BESCHWERDE | 2 | 0 | 2 | 0 |
| DPMA_BGH_RB | 2 | 0 | 2 | 0 |
| **Total** | **76** | **1** | **71** | **4** |
**Reachability rate: 71/76 = 93.4 %.** The 5 unreachable rules concentrate in two clusters:
- `UPC_INF.inf.app_to_amend` (RoP.030.1, 2mo) — no concept_id assigned. Recommended fix: link to `defence-to-application-to-amend` or create a new `application-to-amend` concept.
- All 4 `EP_GRANT` rules — concepts exist (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) but none has an `event_category_concepts` row. Recommended fix: add an EP-Grant subtree under either `cms-eingang.gericht` or a new `ich-moechte-einreichen.ep-grant` branch.
### 3.2 Event_type reachability (firm-wide active types only, n=44)
**Reachable via cascade (26 of 44):**
| Slug | Category | Jurisdiction |
|---|---|---|
| de_klageerwiderung | submission | DE |
| dpma_appeal | submission | DPMA |
| dpma_opposition | submission | DPMA |
| epo_appeal_grounds, epo_appeal_notice, epo_opposition_filing | submission | EPO |
| upc_application_for_cost_decision, upc_application_for_damages | submission | UPC |
| upc_counterclaim_for_infringement, upc_counterclaim_for_revocation | submission | UPC |
| upc_cross_appeal_2242a (×2 concepts) | submission | UPC |
| upc_defence_to_amend_patent, upc_defence_to_revocation | submission | UPC |
| upc_grounds_of_appeal_2242a (×2 concepts) | submission | UPC |
| upc_protective_letter, upc_rejoinder_to_reply, upc_reply_to_defence | submission | UPC |
| upc_reply_to_defence_to_amend_patent, upc_reply_to_defence_to_revocation | submission | UPC |
| upc_request_to_lay_open_books | submission | UPC |
| upc_statement_for_revocation, upc_statement_of_appeal_2201 | submission | UPC |
| upc_statement_of_claim, upc_statement_of_defence | submission | UPC |
| upc_statement_of_defence_no_ccr, upc_statement_of_defence_with_ccr | submission | UPC |
**Unreachable (18 of 44):**
| Slug | Category | Why unreachable |
|---|---|---|
| upc_decision_of_epo | decision | Concept missing, no junction row |
| upc_decision_on_costs | decision | Junction → `cost-decision` concept; that concept is dead-end (not in cascade) |
| upc_decision_on_merits | decision | No junction row |
| upc_final_decision | decision | No junction row |
| upc_oral_hearing | hearing | Junction → `oral-hearing` concept; dead-end |
| upc_case_management_order | order | Junction → `order` concept; dead-end |
| upc_order_lodge_translations | order | No junction row |
| upc_summons_oral_hearing | service | No junction row |
| upc_application_to_amend_patent | submission | No junction row (parallel to UPC_INF gap above) |
| upc_defence_to_statement_dni, upc_statement_dni | submission | DNI family (RoP audit gap 23) — no rule, no concept, no leaf |
| upc_grounds_of_appeal_2242b | submission | RoP audit gap 6 — R.224.2.b orders-track grounds entirely missing |
| upc_preliminary_objection | submission | RoP audit gap 5 — R.19 entirely missing |
| dpma_examination_request | submission | Junction → `request-for-examination`; dead-end |
| epo_renewal_fee, contract_renewal | fee | No junction row, no concept |
| epo_opposition_reply | submission | No junction row |
| stellungnahme | submission | No junction row, no concept (generic catch-all) |
**Pattern.** The 18 unreachable types split into three groups:
- **Court-side trigger types (8/18)**: decisions, orders, hearings, summons. The cascade is *reaction*-oriented (clicking a leaf yields "what's next") and cannot represent these as endpoints because they are themselves the entry points of reaction trees. Adding them via the `ich-moechte-einreichen` root is structurally wrong; they're not user filings. Adding them via `cms-eingang.gericht` would require an explicit "tag this incoming court event" sub-mode that the Determinator currently doesn't have.
- **Genuinely missing UPC content (5/18)**: DNI family, R.19 PO, R.224.2.b orders-track grounds, EP-grant `application_to_amend_patent`. These are real gaps the RoP audit already named.
- **Prosecution-side gaps (5/18)**: EPO renewal fees, R.94(3) reply, DPMA examination request, generic Stellungnahme, contract renewal. Both pathways skip prosecution; the platform is litigation-first today.
### 3.3 Cascade-side dangling (leaves with no concept attached)
3 leaves carry no concept link:
- `cms-eingang.gericht.bescheid-mit-frist` ("Bescheid mit explizit gesetzter Frist") — intentional escape hatch but produces no calendar entry. A user lands here when no specific Bescheid type matches; without a concept, no autofill, no "I'll do the math for you."
- `muendl-verhandlung.verlegt` — when an oral hearing is rescheduled, no follow-on deadline (correct: judge re-issues with new date).
- `sonstiges` — top-level "Anderes" escape hatch.
These three leaves are the existing "not in the tree" UX — a user already CAN bottom out, but only with zero downstream support. §4 below proposes how to make those moments useful.
### 3.4 Concept-side dead-ends (concepts with rules but no cascade entry)
12 concepts have `is_active=true` and ≥1 rule attached but never appear in `event_category_concepts`:
| Concept | Rules | Comment |
|---|---|---|
| `decision` | 14 | Generic decision-anchor — used by every proceeding's `*.decision` row. Not a reaction target. |
| `oral-hearing` | 11 | Same as decision — anchor not reaction. |
| `publication` | 3 | EP grant publication, A1/B1 dates. |
| `order` | 2 | Generic order-anchor. |
| `cost-decision` | 1 | R.157 fixation-of-costs. Should arguably be reachable since post-cost-decision reactions exist (`application-for-leave-to-appeal`); the leaf `kostenfestsetzung` already maps to `notice-of-appeal` and `application-for-leave-to-appeal`, so the *reaction* path is covered — `cost-decision` itself just doesn't need to be in the cascade. |
| `preliminary-opinion` | 1 | EPA preliminary opinion — used by EPA_OPP. |
| `grant` | 1 | EP grant decision. |
| `filing` | 1 | EP filing date. |
| `search-report` | 1 | EPO search-report 6mo period. |
| `request-for-examination` | 1 | EPO R.70(1) 6mo. |
| `approval-and-translation` | 1 | EPO R.71(3) 4mo. |
| `communication-r71-3` | 1 | Same family as approval-and-translation; intermediate. |
**Reading.** 8 of these are court-side anchors (decision, order, hearing, publication, grant, filing, search-report, preliminary-opinion) — by design not reactions, so their absence from the cascade is structurally correct. The remaining 4 are all the EP-grant family (request-for-examination, approval-and-translation, communication-r71-3, plus the implicit `publication` for EP_GRANT) — these *should* be reachable and currently aren't. Confirms §3.1's EP_GRANT cluster as the single biggest fixable cluster.
---
## 4. Smart-navigation framing — which pattern fits the gap distribution?
Issue §3 names three candidate patterns:
- **(P1) Free-text search at every cascade depth.** "Beweisbeschluss" → suggests closest leaves with a "that's not it" fallback.
- **(P2) Persistent "Mein Ereignis ist nicht dabei" escape button.** Visible at every level → opens a manual entry form with rule-only / no-rule paths.
- **(P3) Breadcrumb-aware "weiter unten suchen".** Flattens deeper levels into the current row's chip set when the user can't pick at the current depth.
The gap distribution we just enumerated tells us which pattern earns its keep. There are four kinds of "I don't see my event" moments:
**Type α — Real gap, content missing.** The user wants a real event paliad genuinely doesn't model (Streitwertbeschluss, R.19 PO, DPMA Beanstandungsbescheid, R.71(3), R.94(3), § 411 ZPO Stellungnahme nach Beweisaufnahme). Count: ~18-22 events from §2.6 plus the RoP audit's 25 missing. **What helps:** an escape that captures *what* the user wanted, so we can prioritise the right migration rather than guess. P2 + telemetry.
**Type β — Reachable but mis-modelled cascade path.** The leaf exists, the user can't find it (different mental label, deeper than expected, wrong root). E.g. R.116 final submissions live under `muendl-verhandlung.geladen` AND `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`; if the user starts at `cms-eingang` they hit a dead end. Or: Wiedereinsetzung is under `frist-verpasst.*` but a user might look under `ich-moechte-einreichen.spaetere-schriftsaetze`. **What helps:** P1 (search collapses the labelling problem) and P3 (flat-search within current branch when nothing matches).
**Type γ — Court-side trigger event needs to be tagged, not reacted-to.** The user has a `upc_decision_on_merits` and wants to *file it as an event in their project*, not get a reaction list. The cascade doesn't model this — it always assumes "reaction wanted." Count: ~8 of the 18 unreachable event_types. **What helps:** none of P1/P2/P3 directly — this is a separate "tag, don't react" mode. Out of scope here but worth flagging.
**Type δ — Dead-end leaf with no concept (the 3 dangling leaves).** User selected `bescheid-mit-frist` and lands on a content-free card. **What helps:** P2's "manual entry with rule-only path" is exactly the escape these leaves need — turn the dangle into a deliberate fall-through.
### 4.1 Recommendation: **P2 + P1, in that order, with P3 as a stretch.**
**Why P2 first.** Of the four types, only Type α (real content gaps) is genuinely closed by P2, but Type α is also the *only* type that produces actionable feedback for paliad's roadmap. A persistent "Ich finde mein Ereignis nicht" button at every cascade depth, opening a `<dialog>` with:
- a free-text "What event are you trying to file/respond to?" input,
- a date input,
- "kein Regelwerk verfügbar" rule-only path that creates a deadline with `event_type=null, rule_id=null, manual_due_date=...`,
- an opt-in checkbox "Mein Hinweis hilft, paliad zu verbessern" that posts the captured text to a (future) `paliad.coverage_gaps` table,
…does three things at once: (a) unblocks the user immediately, (b) gives m a backlog that's *exactly* the prioritisation signal this audit can't provide alone (which gaps are real demand vs. theoretical RoP completeness), (c) repurposes the 3 dangling leaves and `sonstiges` from "looks broken" to "deliberate fall-through."
Implementation cost: one `<dialog>` modal reused at every depth + one new `coverage_gap` event sink + one feedback-style admin view. The button itself can hang off the existing FilterBar primitive (t-paliad-163) or attach to the bottom of every cascade list.
**Why P1 second.** Type β (mis-modelled paths) is the *quietest* failure mode — the user gives up before clicking anywhere relevant. Search would catch it but the gap data alone doesn't tell us how many such users exist. Layering P1 on top of P2 turns the captured "Mein Ereignis nicht dabei" texts into the very query corpus that powers fuzzy-search ranking. A search input at the top of every cascade level (`<input type="search">` filtering the current set of children + drilling into matching deeper leaves via FTS over `label_de` / `label_en` / `aliases` / linked `concept.aliases`) closes Type β cheaply once the corpus is decent.
**Why P3 is a stretch.** "Flatten deeper levels into current chip-set" reads cleanly but trades depth for breadth: the cascade currently has 38 reachable UPC leaves under 2-3 levels — flattening to 38 chips at depth 1 produces analysis paralysis. The cascade's depth is a feature, not a bug. P3 is only worth building if telemetry from P2 shows a cluster of users bottoming out at level 2 with the *right* root selected. Defer.
### 4.2 What this means for current scope
- **m/paliad#25 (minkowski's row-by-row)** is orthogonal — that fixes individual rule rows. Keep that going.
- **Type α gap fill** is a separate workstream driven by the Wave 1-5 RoP-audit sequencing in `audit-upc-rop-deadlines-2026-05-08.md` §6. The smart-navigation work doesn't replace it; it gives the work a feedback loop.
- **Type γ (tag-don't-react)** is its own design problem — file as a separate ticket if/when it shows up in P2 telemetry.
- **The 5 unreachable rules from §3.1** (4 EP_GRANT + 1 UPC_INF) should be fixed with a 5-row migration regardless of the navigation work. Independent. EP-grant in particular is the single change that lifts cascade reachability from 93.4 % to 100 % of the audited rule corpus.
### 4.3 Suggested next steps (not implementation, just ordering)
1. **5-row reachability migration** (no design needed): link `inf.app_to_amend` to `defence-to-application-to-amend` concept; add cascade leaves for the 4 EP_GRANT concepts under a new `ich-moechte-einreichen.ep-erteilung` subtree. Wave-0 alongside the t-paliad-159 duration bug fixes.
2. **Inventor pass on P2 + P1** as one design ticket: persistent escape button + free-text search at each level + capture-table schema + admin view. This is where m's "smart navigation" intuition lives — keep P1 and P2 as a pair so the captured texts feed search ranking.
3. **Type α gap fill** continues independently per RoP audit waves — capture-table data in (2) refines priorities after a few weeks of real use.
4. **Defer P3 + Type γ** until telemetry justifies them.
---
## 5. Summary
**Coverage today (n=76 true Fristenrechner deadlines across 19 active proceedings):**
| Status | Count | Share |
|---|---|---|
| Reachable from cascade | 71 | 93 % |
| No concept_id | 1 | 1 % |
| Concept exists, dead-end | 4 | 5 % |
**Event_type reachability (n=44 firm-wide active types):**
| Status | Count | Share |
|---|---|---|
| Reachable | 26 | 59 % |
| Unreachable | 18 | 41 % |
**Headline gap categories** (entirely uncovered by both pathways, ordered by daily-practice frequency):
1. EPO R.94(3) examination-stage Bescheid (4mo) — most-frequent EPO prosecution deadline, **completely absent**.
2. EPO R.71(3) communication → approval+translation (4mo) — concept exists but no cascade entry.
3. DPMA § 45 PatG Beanstandungsbescheid (4mo) — most-frequent DPMA prosecution deadline, completely absent.
4. UPC R.262.2 confidentiality response (14d) — high-frequency in HLC infringement.
5. DE Hinweisbeschluss reaction — leaf exists, no rule.
6. DE Versäumnisurteil-Einspruch (§ 339 ZPO 2 weeks) — leaf exists, no rule.
7. DE Streitwertbeschwerde (§ 68 GKG 6mo) — neither leaf nor rule.
8. UPC R.19 Preliminary Objection (1mo) — neither pathway.
9. UPC R.224.2.b grounds-on-orders-track (15d) — neither pathway.
10. UPC R.353 Rectification (1mo) — neither pathway.
11. UPC EP-grant family (R.70(1), Art. 93, R.71(3), search-report) — Pathway A only, no cascade entry.
12. UPC R.109 oral-hearing translation prep (1mo / 2w / 2w `before`-mode) — schema-supported, no data.
**Recommended smart-navigation pattern:** P2 (persistent "Ich finde mein Ereignis nicht" escape with capture) + P1 (free-text search per cascade level), in that order. P2 alone unblocks users and produces the feedback loop the rest of the gap-fill roadmap needs; P1 layered on top closes mis-labelling. P3 is over-scoped for current data.
---
## Appendix A — files consulted
- `internal/services/deadline_rule_service.go` (proceeding-type filtering, `category='fristenrechner'` gate)
- `internal/services/event_category_service.go` (cascade traversal)
- `internal/services/fristenrechner.go` (Pathway A composer)
- `internal/db/migrations/008_seed_proceeding_types.up.sql` (legacy 7 codes)
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (UPC/DE/EPA seed)
- `internal/db/migrations/042_de_expansion_b3.up.sql` (DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH)
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
- `internal/db/migrations/044_dpma_proceedings.up.sql`
- `internal/db/migrations/045_epa_gap_fill.up.sql`
- `internal/db/migrations/048_event_categories.up.sql` (cascade seed)
- `internal/db/migrations/049_event_categories_seed.up.sql`
- `internal/db/migrations/051_proceeding_display_order.up.sql`
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` (cascade-side RoP fixes)
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` (R.320 leaf)
- `internal/db/migrations/072_deadline_concept_event_types.up.sql` (Regel↔Typ junction)
## Appendix B — companion audits
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — RoP-driven UPC audit (t-paliad-159, curie). Half the data for §2.2.
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad (t-paliad-084, curie).
- `docs/design-deadline-data-model-2026-05-08.md` — current data-model design.

View File

@@ -21,12 +21,19 @@ export interface AxisCtx {
patch(delta: Partial<BarState>): void;
}
// RenderAxisOpts — per-surface tuning the bar threads through to axis
// renderers. Currently only time-axis chip presets; future axes can grow
// here without changing every call site.
export interface RenderAxisOpts {
timePresets?: NonNullable<BarState["time"]>["horizon"][];
}
// renderAxis returns the HTML element for a single axis. The bar's
// mountFilterBar appends the result to its internal toolbar. Returns
// null when the axis is ignored (e.g. surface didn't declare it).
export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): HTMLElement | null {
switch (axis) {
case "time": return renderTimeAxis(ctx);
case "time": return renderTimeAxis(ctx, opts?.timePresets);
case "project": return null; // populated lazily — see attachProjectAxis below
case "personal_only": return renderPersonalOnlyAxis(ctx);
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
@@ -34,15 +41,17 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
case "deadline_status": return renderDeadlineStatusAxis(ctx);
case "appointment_type": return renderAppointmentTypeAxis(ctx);
case "project_event_kind": return renderProjectEventKindAxis(ctx);
case "timeline_status": return renderTimelineStatusAxis(ctx);
case "timeline_track": return renderTimelineTrackAxis(ctx);
case "shape": return renderShapeAxis(ctx);
case "density": return renderDensityAxis(ctx);
case "sort": return renderSortAxis(ctx);
// Per-source predicates that need their own widgets and a roundtrip
// through fetched option lists. Phase 2+ will fill these in by
// wiring the existing event-types / project-list components.
// wiring the existing event-types component.
case "deadline_event_type":
case "project_event_kind":
return null;
}
}
@@ -51,25 +60,44 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
// time — chip cluster (presets + Anpassen)
// ----------------------------------------------------------------------
const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [
{ value: "next_7d", key: "views.bar.time.next_7d" },
{ value: "next_30d", key: "views.bar.time.next_30d" },
{ value: "next_90d", key: "views.bar.time.next_90d" },
{ value: "past_30d", key: "views.bar.time.past_30d" },
{ value: "any", key: "views.bar.time.any" },
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
next_7d: "views.bar.time.next_7d",
next_30d: "views.bar.time.next_30d",
next_90d: "views.bar.time.next_90d",
past_7d: "views.bar.time.past_7d",
past_30d: "views.bar.time.past_30d",
past_90d: "views.bar.time.past_90d",
any: "views.bar.time.any",
all: "views.bar.time.all",
custom: "views.bar.time.custom",
};
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
"next_7d", "next_30d", "next_90d", "past_30d", "any",
];
function renderTimeAxis(ctx: AxisCtx): HTMLElement {
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
const wrap = group("views.bar.label.time");
const row = chipRow();
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
// "any" / "all" are both unbounded — clearing state is the cleanest
// representation, so each maps to "no overlay" rather than a stored
// horizon. The chip's active state then keys off "no time set".
const current = ctx.get("time")?.horizon ?? "any";
for (const preset of TIME_PRESETS) {
const chip = chipBtn(t(preset.key), preset.value === current);
for (const preset of presets) {
if (preset === "custom") continue; // custom rendered separately below
const isUnbounded = preset === "any" || preset === "all";
const isActive = isUnbounded
? !ctx.get("time")
: preset === current;
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
chip.addEventListener("click", () => {
if (preset.value === "any") {
if (isUnbounded) {
ctx.patch({ time: undefined });
} else {
ctx.patch({ time: { horizon: preset.value } });
ctx.patch({ time: { horizon: preset } });
}
});
row.appendChild(chip);
@@ -249,6 +277,140 @@ function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
return wrap;
}
// ----------------------------------------------------------------------
// project_event_kind — chip cluster (multi-select)
//
// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go.
// Labels reuse the existing `event.title.<kind>` translation table so
// the chip text matches the Verlauf row title for the same event type.
// ----------------------------------------------------------------------
const PROJECT_EVENT_KINDS: string[] = [
"project_created",
"project_archived",
"project_reparented",
"project_type_changed",
"status_changed",
"deadline_created",
"deadline_completed",
"deadline_reopened",
"appointment_created",
"appointment_updated",
"appointment_deleted",
"approval_decided",
"member_role_changed",
];
function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.project_event_kind");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length);
all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("project_event_kind") ?? []);
for (const kind of PROJECT_EVENT_KINDS) {
const label = tDyn(`event.title.${kind}`);
const chip = chipBtn(label, current.has(kind));
chip.addEventListener("click", () => {
if (current.has(kind)) current.delete(kind);
else current.add(kind);
ctx.patch({ project_event_kind: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// timeline_status — chip cluster (multi-select)
//
// SmartTimeline (t-paliad-173) status vocabulary spans actuals +
// projections. Default: all. Macro chip pair "Zukunft anzeigen" /
// "Nur vergangenes" toggles the [predicted, court_set] subset on
// or off in one click.
// ----------------------------------------------------------------------
const TIMELINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
{ value: "done", key: "views.bar.timeline_status.done" },
{ value: "open", key: "views.bar.timeline_status.open" },
{ value: "overdue", key: "views.bar.timeline_status.overdue" },
{ value: "predicted", key: "views.bar.timeline_status.predicted" },
{ value: "predicted_overdue", key: "views.bar.timeline_status.predicted_overdue" },
{ value: "court_set", key: "views.bar.timeline_status.court_set" },
{ value: "off_script", key: "views.bar.timeline_status.off_script" },
];
function renderTimelineStatusAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.timeline_status");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_status")?.length);
all.addEventListener("click", () => ctx.patch({ timeline_status: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("timeline_status") ?? []);
for (const s of TIMELINE_STATUSES) {
const chip = chipBtn(t(s.key), current.has(s.value));
chip.addEventListener("click", () => {
if (current.has(s.value)) current.delete(s.value);
else current.add(s.value);
ctx.patch({ timeline_status: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
// Macro chips. "Zukunft anzeigen" = include predicted+court_set; "Nur
// vergangenes" = strip them. Implemented in terms of timeline_status.
const future = chipBtn(t("views.bar.timeline_status.macro.future"), false);
future.classList.add("filter-bar-chip-macro");
future.addEventListener("click", () => {
const next = new Set(["done", "open", "overdue", "predicted", "court_set", "predicted_overdue", "off_script"]);
ctx.patch({ timeline_status: [...next] });
});
row.appendChild(future);
const past = chipBtn(t("views.bar.timeline_status.macro.past"), false);
past.classList.add("filter-bar-chip-macro");
past.addEventListener("click", () => {
ctx.patch({ timeline_status: ["done", "overdue", "off_script"] });
});
row.appendChild(past);
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// timeline_track — chip cluster (multi-select)
//
// Slice 2 only renders parent + off_script; counterclaim and child:<id>
// values land with Slice 3's CCR sub-project FK migration. The renderer
// stays ready for those values — chip rendering is dynamic on the
// state set, not hard-coded to the catalogue below.
// ----------------------------------------------------------------------
const TIMELINE_TRACKS: Array<{ value: string; key: I18nKey }> = [
{ value: "parent", key: "views.bar.timeline_track.parent" },
{ value: "counterclaim", key: "views.bar.timeline_track.counterclaim" },
{ value: "off_script", key: "views.bar.timeline_track.off_script" },
];
function renderTimelineTrackAxis(ctx: AxisCtx): HTMLElement {
const wrap = group("views.bar.label.timeline_track");
const row = chipRow();
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_track")?.length);
all.addEventListener("click", () => ctx.patch({ timeline_track: undefined }));
row.appendChild(all);
const current = new Set(ctx.get("timeline_track") ?? []);
for (const tr of TIMELINE_TRACKS) {
const chip = chipBtn(t(tr.key), current.has(tr.value));
chip.addEventListener("click", () => {
if (current.has(tr.value)) current.delete(tr.value);
else current.add(tr.value);
ctx.patch({ timeline_track: current.size ? [...current] : undefined });
});
row.appendChild(chip);
}
wrap.appendChild(row);
return wrap;
}
// ----------------------------------------------------------------------
// shape — segmented control (list / cards / calendar)
// ----------------------------------------------------------------------
@@ -321,10 +483,6 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
return wrap;
}
// Suppress unused warning for tDyn — it's available for future axes
// (deadline_event_type) that need dynamic enum labels.
void tDyn;
// ----------------------------------------------------------------------
// shared helpers — group + chip + row
// ----------------------------------------------------------------------

View File

@@ -24,7 +24,7 @@ import {
parseBar,
encodeBar,
} from "./url-codec";
import { renderAxis, type AxisCtx } from "./axes";
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
import { openSaveModal } from "./save-modal";
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
@@ -39,6 +39,11 @@ interface PrefsBlob {
}
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
if (!!opts.customRunner === !!opts.systemViewSlug) {
throw new Error(
"mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
);
}
let state: BarState = {};
const ns = opts.urlNamespace;
@@ -64,18 +69,25 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
lastEffective = effective;
const myVersion = ++runVersion;
try {
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filter: effective.filter }),
});
if (myVersion !== runVersion) return; // a newer click superseded us
if (!r.ok) {
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
return;
let result: ViewRunResult;
if (opts.customRunner) {
result = await opts.customRunner(effective);
} else {
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filter: effective.filter }),
});
if (myVersion !== runVersion) return; // a newer click superseded us
if (!r.ok) {
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
return;
}
result = (await r.json()) as ViewRunResult;
}
const result = (await r.json()) as ViewRunResult;
if (myVersion !== runVersion) return;
opts.onResult(result, effective);
} catch (_e) {
if (myVersion !== runVersion) return;
@@ -104,11 +116,15 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
},
};
const axisRenderOpts: RenderAxisOpts = {
timePresets: opts.timePresets,
};
// First paint.
const renderToolbar = () => {
toolbar.innerHTML = "";
for (const axis of opts.axes) {
const el = renderAxis(axis as AxisKey, ctx);
const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
if (el) toolbar.appendChild(el);
}
if (showSave) {

View File

@@ -21,6 +21,8 @@ export type AxisKey =
| "approval_status"
| "approval_entity_type"
| "project_event_kind"
| "timeline_status"
| "timeline_track"
| "shape"
| "sort"
| "density";
@@ -49,6 +51,12 @@ export interface BarState {
approval_status?: string[];
approval_entity_type?: string[];
project_event_kind?: string[];
// SmartTimeline axes (t-paliad-173). timeline_status spans actuals +
// projections; timeline_track is parent / counterclaim / off_script
// and grows once Slice 3 lands the CCR sub-project FK (child:<id>
// values dynamically populated then).
timeline_status?: string[];
timeline_track?: string[];
// Render
shape?: RenderShape;
@@ -57,7 +65,7 @@ export interface BarState {
}
export interface TimeOverlay {
horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
from?: string; // ISO 8601 — only when horizon === "custom"
to?: string;
}
@@ -98,10 +106,23 @@ export interface MountOpts {
showSaveAsView?: boolean;
// Slug of the surface's underlying system view (or saved user view).
// POSTed to /api/views/{slug}/run with the override body. Required
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
// so the substrate's reserved-slug path stays the canonical entry.
systemViewSlug: string;
// POSTed to /api/views/{slug}/run with the override body. Required
// unless `customRunner` is supplied — see below. When the bar runs
// through this endpoint it is the substrate's canonical entry.
systemViewSlug?: string;
// Custom runner. When set, the bar bypasses the substrate POST and
// hands the effective spec to this function instead. Used by surfaces
// that haven't migrated to the substrate yet (Verlauf tab still hits
// /api/projects/{id}/events to keep subtree expansion + cursor
// pagination, t-paliad-170). Must be either this OR systemViewSlug —
// the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec) => Promise<ViewRunResult>;
// Per-surface override of the time-axis chip presets. Order is
// preserved. Default presets are forward-looking (next_*+past_30d+any)
// — backward-looking surfaces (Verlauf, audit) pass past_*+all here.
timePresets?: NonNullable<BarState["time"]>["horizon"][];
// When true, the bar exposes an "Aktualisieren" affordance that
// PATCHes /api/user-views/{userViewId} with the effective spec.

View File

@@ -90,6 +90,12 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
const peKind = params.get(k("pe_kind"));
if (peKind) out.project_event_kind = parseCSV(peKind);
// SmartTimeline (t-paliad-173) — status + track axes.
const tlStatus = params.get(k("tl_status"));
if (tlStatus) out.timeline_status = parseCSV(tlStatus);
const tlTrack = params.get(k("tl_track"));
if (tlTrack) out.timeline_track = parseCSV(tlTrack);
// render.shape
const shape = params.get(k("shape"));
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
@@ -119,6 +125,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
"app_type",
"a_role", "a_status", "a_entity_type",
"pe_kind",
"tl_status", "tl_track",
"shape", "sort", "density",
]) {
params.delete(k(key));
@@ -155,6 +162,8 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
if (state.timeline_status?.length) params.set(k("tl_status"), state.timeline_status.join(","));
if (state.timeline_track?.length) params.set(k("tl_track"), state.timeline_track.join(","));
if (state.shape) params.set(k("shape"), state.shape);
if (state.sort) params.set(k("sort"), state.sort);
@@ -166,6 +175,7 @@ function parseHorizon(s: string): TimeOverlay["horizon"] | null {
case "next_7d":
case "next_30d":
case "next_90d":
case "past_7d":
case "past_30d":
case "past_90d":
case "any":

View File

@@ -2490,8 +2490,12 @@ function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
// project to save against, so the CTA disables and renders a hint.
// t-paliad-168: also true when no Step 1 context is set at all (the
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
// Pathway A without an Akte). In both cases the user has no project
// to save against; the CTA renders disabled with the same hint.
function isAdhocMode(): boolean {
return currentStep1Context.kind === "adhoc";
return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
}
function adhocSummaryLabel(forum: AdhocForum): string {
@@ -2705,6 +2709,12 @@ function initPathwayFork() {
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
navigateToPathway("b", "tree");
});
// t-paliad-168 — Verfahrensablauf einsehen (browse / learn). Drops
// straight into Pathway A's proceeding-tile picker. The save CTA
// disables itself in this mode (see isBrowseOrAdhocMode below).
document.getElementById("fristen-step2-browse")?.addEventListener("click", () => {
navigateToPathway("a");
});
// Step 3a cards — File / Draft / Enter. File drops into the existing
// Pathway A wizard; Enter routes to the manual-create form;

View File

@@ -21,6 +21,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.home": "Home",
"nav.kostenrechner": "Kostenrechner",
"nav.fristenrechner": "Fristenrechner",
"nav.verfahrensablauf": "Verfahrensablauf",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossar",
@@ -263,6 +264,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step2.file.desc": "Outgoing — eine Frist tritt aus eigener Handlung ein.",
"deadlines.step2.happened.title": "Etwas ist passiert",
"deadlines.step2.happened.desc": "Incoming — ein Ereignis hat eine Frist ausgelöst.",
"deadlines.step2.browse.title": "Verfahrensablauf einsehen",
"deadlines.step2.browse.desc": "Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.",
"deadlines.save.cta.adhoc.hint": "Ad-hoc — kein Projekt, kein Speichern",
"deadlines.step3a.heading": "Was möchten Sie einreichen?",
"deadlines.step3a.back": "zurück zur Auswahl",
@@ -1160,6 +1163,79 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.checklisten": "Checklisten",
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"projects.detail.verlauf.loadMore": "Mehr laden",
// SmartTimeline (t-paliad-171, Slice 1).
"projects.detail.smarttimeline.empty": "Noch keine Ereignisse erfasst.",
"projects.detail.smarttimeline.today": "Heute",
"projects.detail.smarttimeline.section.past": "Vergangenheit",
"projects.detail.smarttimeline.section.future": "Zukunft",
"projects.detail.smarttimeline.section.undated": "Ohne Datum",
"projects.detail.smarttimeline.kind.deadline": "Frist",
"projects.detail.smarttimeline.kind.appointment": "Termin",
"projects.detail.smarttimeline.kind.milestone": "Meilenstein",
"projects.detail.smarttimeline.kind.projected": "Vorhersage",
"projects.detail.smarttimeline.status.done": "Erledigt",
"projects.detail.smarttimeline.status.open": "Offen",
"projects.detail.smarttimeline.status.overdue": "Überfällig",
"projects.detail.smarttimeline.status.court_set": "Datum vom Gericht",
"projects.detail.smarttimeline.status.predicted": "Voraussichtlich",
"projects.detail.smarttimeline.status.off_script": "Eigener Eintrag",
"projects.detail.smarttimeline.audit.toggle.show": "Audit-Log anzeigen",
"projects.detail.smarttimeline.audit.toggle.hide": "Nur Timeline-Einträge",
"projects.detail.smarttimeline.add.cta": "+ Eintrag",
"projects.detail.smarttimeline.add.modal.title": "Neuer Eintrag im SmartTimeline",
"projects.detail.smarttimeline.add.choice.deadline": "Frist anlegen",
"projects.detail.smarttimeline.add.choice.appointment": "Termin anlegen",
"projects.detail.smarttimeline.add.choice.counterclaim": "Widerklage (CCR)",
"projects.detail.smarttimeline.add.choice.amend": "Antrag auf Änderung (R.30)",
"projects.detail.smarttimeline.add.choice.milestone": "Eigener Meilenstein",
"projects.detail.smarttimeline.add.choice.disabled": "Kommt mit Slice 3",
"projects.detail.smarttimeline.add.cancel": "Abbrechen",
"projects.detail.smarttimeline.add.submit": "Speichern",
"projects.detail.smarttimeline.milestone.title": "Titel",
"projects.detail.smarttimeline.milestone.date": "Datum (optional)",
"projects.detail.smarttimeline.milestone.description": "Beschreibung (optional)",
"projects.detail.smarttimeline.error.title_required": "Bitte einen Titel angeben.",
"projects.detail.smarttimeline.error.generic": "Konnte den Eintrag nicht speichern.",
"projects.detail.smarttimeline.status.predicted_overdue": "Überfällig (vorhergesagt)",
"projects.detail.smarttimeline.lookahead.more": "+ Mehr anzeigen",
"projects.detail.smarttimeline.lookahead.less": " Weniger",
"projects.detail.smarttimeline.depends_on.prefix": "Folgt aus",
"projects.detail.smarttimeline.depends_on.date_open": "Datum offen",
"projects.detail.smarttimeline.depends_on.show_path": "Pfad anzeigen",
"projects.detail.smarttimeline.depends_on.hide_path": "Pfad verbergen",
"projects.detail.smarttimeline.depends_on.path_hint": "Klicke die übergeordnete Zeile, um deren Abhängigkeit zu sehen.",
"projects.detail.smarttimeline.anchor.set": "Datum setzen",
"projects.detail.smarttimeline.anchor.save": "Speichern",
"projects.detail.smarttimeline.anchor.cancel": "Abbrechen",
"projects.detail.smarttimeline.anchor.saving": "Speichere …",
"projects.detail.smarttimeline.anchor.saved": "Gespeichert.",
"projects.detail.smarttimeline.anchor.error": "Konnte das Datum nicht setzen.",
"projects.detail.smarttimeline.anchor.invalid_date": "Ungültiges Datum (YYYY-MM-DD).",
"projects.detail.smarttimeline.track.label": "Track",
"projects.detail.smarttimeline.track.both": "Beide",
"projects.detail.smarttimeline.track.only.parent": "Nur Hauptverfahren",
"projects.detail.smarttimeline.track.only.counterclaim": "Nur Widerklage",
"projects.detail.smarttimeline.track.only.parent_context": "Nur Hauptverfahren (Kontext)",
"projects.detail.smarttimeline.track.header.parent": "Hauptverfahren",
"projects.detail.smarttimeline.track.header.counterclaim": "Widerklage (CCR)",
"projects.detail.smarttimeline.track.header.parent_context": "Hauptverfahren (Kontext)",
"projects.detail.smarttimeline.counterclaim.procedure": "Verfahrenstyp",
"projects.detail.smarttimeline.counterclaim.title": "Titel (optional)",
"projects.detail.smarttimeline.counterclaim.case_number": "CCR-Aktenzeichen (optional)",
"projects.detail.smarttimeline.counterclaim.flip_override": "Unsere Seite NICHT umkehren (Stimmt nicht?)",
"projects.detail.smarttimeline.counterclaim.flip_hint": "Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.",
"projects.detail.smarttimeline.counterclaim.submit": "Widerklage anlegen",
"projects.detail.smarttimeline.counterclaim.saving": "Lege Widerklage an …",
"projects.detail.smarttimeline.lane.empty": "Keine Einträge in dieser Spur.",
"projects.detail.smarttimeline.lane.filter.label": "Spuren",
"projects.detail.smarttimeline.lane.filter.all": "Alle",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline-Ansicht",
"projects.detail.smarttimeline.client.toggle.matter_list": "Mandatsliste",
"projects.detail.smarttimeline.client.matter_list.heading": "Verfahren des Mandanten",
"projects.detail.smarttimeline.client.matter_list.hint": "Klicke ein Verfahren an, um die Detail-Timeline zu öffnen, oder schalte oben auf „Timeline-Ansicht“.",
"projects.detail.smarttimeline.client.matter_list.empty": "Noch keine Verfahren angelegt.",
"projects.detail.smarttimeline.milestone.bubble_up": "In übergeordneten Akten anzeigen",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.",
"projects.detail.team.form.user": "Benutzer",
"projects.detail.team.form.role": "Rolle",
"projects.detail.team.form.responsibility": "Rolle im Projekt",
@@ -2179,6 +2255,21 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.label.approval_entity": "Art",
"views.bar.label.deadline_status": "Frist-Status",
"views.bar.label.appointment_type": "Termin-Typ",
"views.bar.label.project_event_kind": "Ereignis",
"views.bar.label.timeline_status": "Timeline-Status",
"views.bar.label.timeline_track": "Track",
"views.bar.timeline_status.done": "Erledigt",
"views.bar.timeline_status.open": "Offen",
"views.bar.timeline_status.overdue": "Überfällig",
"views.bar.timeline_status.predicted": "Voraussichtlich",
"views.bar.timeline_status.predicted_overdue": "Überfällig (vorhergesagt)",
"views.bar.timeline_status.court_set": "Gerichtsdatum",
"views.bar.timeline_status.off_script": "Eigener Eintrag",
"views.bar.timeline_status.macro.future": "Zukunft anzeigen",
"views.bar.timeline_status.macro.past": "Nur vergangenes",
"views.bar.timeline_track.parent": "Hauptverfahren",
"views.bar.timeline_track.counterclaim": "Widerklage",
"views.bar.timeline_track.off_script": "Off-Script",
"views.bar.label.shape": "Darstellung",
"views.bar.label.density": "Dichte",
"views.bar.label.sort": "Sortierung",
@@ -2186,8 +2277,11 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.time.next_7d": "7 Tage",
"views.bar.time.next_30d": "30 Tage",
"views.bar.time.next_90d": "90 Tage",
"views.bar.time.past_7d": "Letzte 7 T.",
"views.bar.time.past_30d": "Letzte 30 T.",
"views.bar.time.past_90d": "Letzte 90 T.",
"views.bar.time.any": "Beliebig",
"views.bar.time.all": "Alle Zeit",
"views.bar.time.custom": "Anpassen",
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
"views.bar.personal.on": "Nur eigene",
@@ -2233,6 +2327,7 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.home": "Home",
"nav.kostenrechner": "Cost Calculator",
"nav.fristenrechner": "Deadline Calculator",
"nav.verfahrensablauf": "Procedure Roadmap",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossary",
@@ -2472,6 +2567,8 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.step2.file.desc": "Outgoing — your action triggers a deadline.",
"deadlines.step2.happened.title": "Something happened",
"deadlines.step2.happened.desc": "Incoming — an event triggered a deadline.",
"deadlines.step2.browse.title": "Browse procedure roadmap",
"deadlines.step2.browse.desc": "Browse / Learn — see what happens when. No deadline entered.",
"deadlines.save.cta.adhoc.hint": "Ad-hoc — no matter, no save",
"deadlines.step3a.heading": "What do you want to file?",
"deadlines.step3a.back": "back to selection",
@@ -3357,6 +3454,78 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.detail.tab.checklisten": "Checklists",
"projects.detail.verlauf.empty": "No events recorded yet.",
"projects.detail.verlauf.loadMore": "Load more",
"projects.detail.smarttimeline.empty": "No events captured yet.",
"projects.detail.smarttimeline.today": "Today",
"projects.detail.smarttimeline.section.past": "Past",
"projects.detail.smarttimeline.section.future": "Future",
"projects.detail.smarttimeline.section.undated": "Undated",
"projects.detail.smarttimeline.kind.deadline": "Deadline",
"projects.detail.smarttimeline.kind.appointment": "Appointment",
"projects.detail.smarttimeline.kind.milestone": "Milestone",
"projects.detail.smarttimeline.kind.projected": "Predicted",
"projects.detail.smarttimeline.status.done": "Done",
"projects.detail.smarttimeline.status.open": "Open",
"projects.detail.smarttimeline.status.overdue": "Overdue",
"projects.detail.smarttimeline.status.court_set": "Court-set date",
"projects.detail.smarttimeline.status.predicted": "Predicted",
"projects.detail.smarttimeline.status.off_script": "Custom",
"projects.detail.smarttimeline.audit.toggle.show": "Show audit log",
"projects.detail.smarttimeline.audit.toggle.hide": "Timeline only",
"projects.detail.smarttimeline.add.cta": "+ Entry",
"projects.detail.smarttimeline.add.modal.title": "New SmartTimeline entry",
"projects.detail.smarttimeline.add.choice.deadline": "Add a deadline",
"projects.detail.smarttimeline.add.choice.appointment": "Add an appointment",
"projects.detail.smarttimeline.add.choice.counterclaim": "Counterclaim (CCR)",
"projects.detail.smarttimeline.add.choice.amend": "Application to amend (R.30)",
"projects.detail.smarttimeline.add.choice.milestone": "Custom milestone",
"projects.detail.smarttimeline.add.choice.disabled": "Coming in Slice 3",
"projects.detail.smarttimeline.add.cancel": "Cancel",
"projects.detail.smarttimeline.add.submit": "Save",
"projects.detail.smarttimeline.milestone.title": "Title",
"projects.detail.smarttimeline.milestone.date": "Date (optional)",
"projects.detail.smarttimeline.milestone.description": "Description (optional)",
"projects.detail.smarttimeline.error.title_required": "Please enter a title.",
"projects.detail.smarttimeline.error.generic": "Could not save the entry.",
"projects.detail.smarttimeline.status.predicted_overdue": "Overdue (predicted)",
"projects.detail.smarttimeline.lookahead.more": "+ Show more",
"projects.detail.smarttimeline.lookahead.less": " Show less",
"projects.detail.smarttimeline.depends_on.prefix": "Follows from",
"projects.detail.smarttimeline.depends_on.date_open": "Date open",
"projects.detail.smarttimeline.depends_on.show_path": "Show path",
"projects.detail.smarttimeline.depends_on.hide_path": "Hide path",
"projects.detail.smarttimeline.depends_on.path_hint": "Click the parent row to see its dependency.",
"projects.detail.smarttimeline.anchor.set": "Set date",
"projects.detail.smarttimeline.anchor.save": "Save",
"projects.detail.smarttimeline.anchor.cancel": "Cancel",
"projects.detail.smarttimeline.anchor.saving": "Saving…",
"projects.detail.smarttimeline.anchor.saved": "Saved.",
"projects.detail.smarttimeline.anchor.error": "Could not set the date.",
"projects.detail.smarttimeline.anchor.invalid_date": "Invalid date (YYYY-MM-DD).",
"projects.detail.smarttimeline.track.label": "Track",
"projects.detail.smarttimeline.track.both": "Both",
"projects.detail.smarttimeline.track.only.parent": "Main proceeding only",
"projects.detail.smarttimeline.track.only.counterclaim": "Counterclaim only",
"projects.detail.smarttimeline.track.only.parent_context": "Main proceeding only (context)",
"projects.detail.smarttimeline.track.header.parent": "Main proceeding",
"projects.detail.smarttimeline.track.header.counterclaim": "Counterclaim (CCR)",
"projects.detail.smarttimeline.track.header.parent_context": "Main proceeding (context)",
"projects.detail.smarttimeline.counterclaim.procedure": "Proceeding type",
"projects.detail.smarttimeline.counterclaim.title": "Title (optional)",
"projects.detail.smarttimeline.counterclaim.case_number": "CCR case number (optional)",
"projects.detail.smarttimeline.counterclaim.flip_override": "Do NOT flip our side („Stimmt nicht?”)",
"projects.detail.smarttimeline.counterclaim.flip_hint": "In the standard case (CCR on validity) our side flips (claimant ↔ defendant). Enable for the R.49.2.b CCI edge case.",
"projects.detail.smarttimeline.counterclaim.submit": "Create counterclaim",
"projects.detail.smarttimeline.counterclaim.saving": "Creating counterclaim…",
"projects.detail.smarttimeline.lane.empty": "No entries in this lane.",
"projects.detail.smarttimeline.lane.filter.label": "Lanes",
"projects.detail.smarttimeline.lane.filter.all": "All",
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline view",
"projects.detail.smarttimeline.client.toggle.matter_list": "Matter list",
"projects.detail.smarttimeline.client.matter_list.heading": "Matters of this client",
"projects.detail.smarttimeline.client.matter_list.hint": "Click a matter to open its detailed timeline, or switch to „Timeline view“ above.",
"projects.detail.smarttimeline.client.matter_list.empty": "No matters yet.",
"projects.detail.smarttimeline.milestone.bubble_up": "Show on parent matters",
"projects.detail.smarttimeline.milestone.bubble_up_hint": "When checked, this milestone surfaces on patent, litigation, and client SmartTimelines.",
"projects.detail.team.form.user": "User",
"projects.detail.team.form.role": "Role",
"projects.detail.team.form.responsibility": "Project role",
@@ -4372,6 +4541,21 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.label.approval_entity": "Kind",
"views.bar.label.deadline_status": "Deadline status",
"views.bar.label.appointment_type": "Appointment type",
"views.bar.label.project_event_kind": "Event",
"views.bar.label.timeline_status": "Timeline status",
"views.bar.label.timeline_track": "Track",
"views.bar.timeline_status.done": "Done",
"views.bar.timeline_status.open": "Open",
"views.bar.timeline_status.overdue": "Overdue",
"views.bar.timeline_status.predicted": "Predicted",
"views.bar.timeline_status.predicted_overdue": "Overdue (predicted)",
"views.bar.timeline_status.court_set": "Court date",
"views.bar.timeline_status.off_script": "Custom",
"views.bar.timeline_status.macro.future": "Show future",
"views.bar.timeline_status.macro.past": "Past only",
"views.bar.timeline_track.parent": "Main proceeding",
"views.bar.timeline_track.counterclaim": "Counterclaim",
"views.bar.timeline_track.off_script": "Off-script",
"views.bar.label.shape": "Display",
"views.bar.label.density": "Density",
"views.bar.label.sort": "Sort",
@@ -4379,8 +4563,11 @@ const translations: Record<Lang, Record<string, string>> = {
"views.bar.time.next_7d": "7 days",
"views.bar.time.next_30d": "30 days",
"views.bar.time.next_90d": "90 days",
"views.bar.time.past_7d": "Past 7d",
"views.bar.time.past_30d": "Past 30 d.",
"views.bar.time.past_90d": "Past 90 d.",
"views.bar.time.any": "Any",
"views.bar.time.all": "All time",
"views.bar.time.custom": "Custom",
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
"views.bar.personal.on": "Mine only",

View File

@@ -9,6 +9,9 @@ import {
prefillForm,
readPayload,
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
interface Project {
id: string;
@@ -222,6 +225,86 @@ const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
// SmartTimeline (t-paliad-171 / t-paliad-173) — row set + audit-toggle
// + Slice 2 lookahead state. timelineRows is what we render; the count
// of future-projected rows the backend knows about is held separately
// in timelineProjectedTotal so "Mehr anzeigen" can be shown when the
// cap clipped some rows.
let timelineRows: SmartTimelineEvent[] = [];
let timelineAuditFull = parseAuditFullPersisted();
let timelineLookahead = 7; // backend default; overridden from localStorage
let timelineProjectedTotal = 0;
// Slice 3 — counterclaim parallel tracks. timelineAvailableTracks is
// parsed from the X-Projection-Tracks response header; selectedTrack
// is the user's [Track ▼] choice (default "all" → render every track).
let timelineAvailableTracks: string[] = [];
let timelineSelectedTrack = "all";
// Slice 4 — parent-node lane aggregation (t-paliad-175). Lanes come
// from the response envelope's .lanes array. selectedLanes is the
// user's lane-filter state — null = "all selected" (the default);
// set explicitly when the user toggles a chip.
let timelineLanes: SmartTimelineLane[] = [];
let timelineSelectedLanes: string[] | null = null;
// Slice 4 — Client-level "Timeline-Ansicht" toggle. At Client-level
// project pages, the Verlauf tab defaults to the matter-list rendering
// (project tree); flipping the toggle swaps to the SmartTimeline lane
// view. State persists in localStorage per project so navigating away
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
// drives loadEvents through its customRunner. Filtering is client-side
// against the legacy /api/projects/{id}/events response so subtree mode
// + cursor pagination stay intact (substrate-side scope expansion lands
// with t-paliad-169 SmartTimeline). Empty filter → identity passthrough.
let verlaufBar: BarHandle | null = null;
interface VerlaufFilters {
eventKinds?: Set<string>;
// Bounds are inclusive lower / exclusive upper, matching
// computeViewSpecBounds in internal/services/view_service.go so the
// semantics align when this surface eventually moves to the substrate.
fromDate?: Date;
toDate?: Date;
}
let verlaufFilters: VerlaufFilters = {};
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
const f = verlaufFilters;
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
return rows.filter((r) => {
if (f.eventKinds && !f.eventKinds.has(r.event_type ?? "")) return false;
const created = new Date(r.created_at);
if (f.fromDate && created < f.fromDate) return false;
if (f.toDate && created >= f.toDate) return false;
return true;
});
}
// horizonBounds mirrors computeViewSpecBounds in view_service.go for the
// horizons that show up on the Verlauf bar. Forward-looking horizons
// (next_*) are absent on this surface — the timePresets override hides
// them — but the function tolerates them for forward-compatibility with
// the SmartTimeline redesign.
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
const now = new Date();
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
switch (horizon) {
case "past_7d": return { from: offset(-7), to: offset(1) };
case "past_30d": return { from: offset(-30), to: offset(1) };
case "past_90d": return { from: offset(-90), to: offset(1) };
case "next_7d": return { from: day, to: offset(7) };
case "next_30d": return { from: day, to: offset(30) };
case "next_90d": return { from: day, to: offset(90) };
default: return {};
}
}
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
// Verlauf show rows from this project AND all descendant projects with an
// attribution chip per non-direct row. URL param `?subtree=false` flips to
@@ -302,27 +385,277 @@ function subtreeParam(): string {
return subtreeMode ? "" : "&direct_only=true";
}
// rawEventsCursor tracks the last *raw* (pre-filter) event ID returned by
// the legacy endpoint so cursor pagination keeps working when filters
// drop most rows from a page. Without it, "Mehr laden" with a tight
// filter could stall because events[] (post-filter) wouldn't reach back
// to the actual pagination boundary.
let rawEventsLastID: string | null = null;
let rawEventsLastPageFull = false;
async function loadEvents(id: string) {
try {
const resp = await fetch(
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
events = (await resp.json()) ?? [];
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
const raw: ProjectEvent[] = (await resp.json()) ?? [];
rawEventsLastID = raw.length ? raw[raw.length - 1].id : null;
rawEventsLastPageFull = raw.length === EVENTS_PAGE_SIZE;
events = applyVerlaufFilters(raw);
eventsHasMore = rawEventsLastPageFull;
} else {
events = [];
rawEventsLastID = null;
rawEventsLastPageFull = false;
eventsHasMore = false;
}
} catch {
events = [];
rawEventsLastID = null;
rawEventsLastPageFull = false;
eventsHasMore = false;
}
}
// SmartTimeline (t-paliad-171) — fetches the merged timeline from the
// new /api/projects/{id}/timeline endpoint. Slice 1 returns actuals
// (deadlines + appointments + opted-in project_events); future slices
// add projected rows additively. The audit-full toggle broadens the
// project_events filter to include rows without timeline_kind set.
async function loadTimeline(id: string): Promise<void> {
const params = new URLSearchParams();
if (timelineAuditFull) params.set("include", "audit_full");
if (!subtreeMode) params.set("direct_only", "true");
if (timelineLookahead && timelineLookahead !== 7) {
params.set("lookahead", String(timelineLookahead));
}
const qs = params.toString();
const url = `/api/projects/${encodeURIComponent(id)}/timeline${qs ? "?" + qs : ""}`;
try {
const resp = await fetch(url);
if (resp.ok) {
// Slice 4 (t-paliad-175) — wire shape changed from
// []TimelineEvent to envelope {events, lanes} so lane metadata
// can ride alongside the rows. Defensive parse: tolerate both
// shapes during the rolling deploy window (any cached older
// backend response is treated as events-only).
const body = await resp.json();
if (Array.isArray(body)) {
timelineRows = body;
timelineLanes = [];
} else {
timelineRows = (body?.events ?? []) as SmartTimelineEvent[];
timelineLanes = (body?.lanes ?? []) as SmartTimelineLane[];
}
// Pull projection meta from headers (Slice 2). When absent (e.g.
// proxy strips them), fall back to the visible projected count
// so "Mehr anzeigen" stays hidden — defensible default.
const totalHdr = resp.headers.get("X-Projection-Total");
timelineProjectedTotal = totalHdr ? parseInt(totalHdr, 10) || 0 : 0;
const lookaheadHdr = resp.headers.get("X-Projection-Lookahead");
if (lookaheadHdr) {
const n = parseInt(lookaheadHdr, 10);
if (!isNaN(n) && n > 0) timelineLookahead = n;
}
// Slice 3 — track list comes back as comma-separated tags.
const tracksHdr = resp.headers.get("X-Projection-Tracks");
timelineAvailableTracks = tracksHdr
? tracksHdr.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
: ["parent"];
// Drop a previously-selected track if it disappeared from the
// response (e.g. CCR child was deleted between renders) — fall
// back to "all" so the user doesn't get an empty pane.
if (timelineSelectedTrack !== "all" && !timelineAvailableTracks.includes(timelineSelectedTrack)) {
timelineSelectedTrack = "all";
}
// Drop selected lanes that disappeared between renders (e.g. a
// child case was deleted). null sentinel means "all" so leave it.
if (timelineSelectedLanes !== null) {
const laneIds = new Set(timelineLanes.map((l) => l.id));
timelineSelectedLanes = timelineSelectedLanes.filter((id) => laneIds.has(id));
if (timelineSelectedLanes.length === 0) {
timelineSelectedLanes = null;
}
}
} else {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
} catch {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
}
function renderTimeline() {
const host = document.getElementById("project-smart-timeline");
if (!host) return;
const projectId = project?.id;
// Slice 4 — Client-level Timeline-Ansicht toggle. At Client-level
// pages, the Verlauf default is the matter-list (project tree).
// Flipping the toggle swaps to the SmartTimeline lane view.
if (project?.type === "client" && !timelineClientShowLanes) {
renderClientMatterList(host);
return;
}
renderSmartTimeline(host, timelineRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
lookahead: timelineLookahead,
projectedTotal: timelineProjectedTotal,
availableTracks: timelineAvailableTracks,
selectedTrack: timelineSelectedTrack,
lanes: timelineLanes,
selectedLanes: timelineSelectedLanes ?? undefined,
onLaneFilterChange: async (next) => {
// Persist the explicit selection so a re-fetch doesn't reset it.
// Empty array = user unchecked everything → fall back to "all"
// so we never render a blank pane.
timelineSelectedLanes = next.length === 0 ? null : next;
renderTimeline();
},
onTrackChange: async (next) => {
timelineSelectedTrack = next;
// Track filter is purely client-side (rows are already loaded);
// re-render in place without a re-fetch.
renderTimeline();
},
onChange: async () => {
if (!projectId) return;
await loadTimeline(projectId);
renderTimeline();
},
onLookaheadChange: async (next) => {
if (!projectId) return;
timelineLookahead = next;
writeLookaheadPersisted(next);
await loadTimeline(projectId);
renderTimeline();
},
});
}
// renderClientMatterList renders the Client-level default Verlauf view
// — a simple list of direct child litigations with their reference and
// status. This stands in for the existing project-tree component when
// Timeline-Ansicht is OFF (the default at Client level per design §5.1
// + Q12). User can flip the Timeline-Ansicht toggle to see the lane
// SmartTimeline.
function renderClientMatterList(host: HTMLElement) {
host.innerHTML = "";
host.classList.add("smart-timeline");
const wrap = document.createElement("div");
wrap.className = "smart-timeline-matter-list";
const heading = document.createElement("h3");
heading.className = "smart-timeline-matter-list-heading";
heading.textContent = t("projects.detail.smarttimeline.client.matter_list.heading");
wrap.appendChild(heading);
const hint = document.createElement("p");
hint.className = "form-hint";
hint.textContent = t("projects.detail.smarttimeline.client.matter_list.hint");
wrap.appendChild(hint);
// The lane info from the backend already contains the direct child
// litigations (one entry per child). When empty, the message guides
// the user to add a litigation first.
if (timelineLanes.length === 0) {
const empty = document.createElement("p");
empty.className = "entity-events-empty";
empty.textContent = t("projects.detail.smarttimeline.client.matter_list.empty");
wrap.appendChild(empty);
host.appendChild(wrap);
return;
}
const list = document.createElement("ul");
list.className = "smart-timeline-matter-list-items";
for (const lane of timelineLanes) {
const li = document.createElement("li");
li.className = "smart-timeline-matter-list-item";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
li.appendChild(link);
} else {
li.textContent = lane.label;
}
list.appendChild(li);
}
wrap.appendChild(list);
host.appendChild(wrap);
}
function lookaheadStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.lookahead.${id}`;
}
function writeLookaheadPersisted(n: number) {
try {
if (n === 7) localStorage.removeItem(lookaheadStorageKey());
else localStorage.setItem(lookaheadStorageKey(), String(n));
} catch {
// ignore
}
}
function readLookaheadPersisted(): number {
try {
const raw = localStorage.getItem(lookaheadStorageKey());
if (!raw) return 7;
const n = parseInt(raw, 10);
if (isNaN(n) || n < 1 || n > 50) return 7;
return n;
} catch {
return 7;
}
}
// Audit-full toggle persistence: per-project flag in localStorage so a
// user who flips the legacy view on for one project doesn't see the
// audit clutter on every other project they open.
function auditFullStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.audit_full.${id}`;
}
function parseAuditFullPersisted(): boolean {
// Project ID isn't known yet at module init; fall back to false here
// and re-read in initSmartTimelineAuditToggle once project is loaded.
return false;
}
function readPersistedAuditFull(): boolean {
try {
return localStorage.getItem(auditFullStorageKey()) === "1";
} catch {
return false;
}
}
function writePersistedAuditFull(on: boolean) {
try {
if (on) localStorage.setItem(auditFullStorageKey(), "1");
else localStorage.removeItem(auditFullStorageKey());
} catch {
// ignore
}
}
async function loadMoreEvents(id: string) {
if (eventsLoadingMore || !eventsHasMore || events.length === 0) return;
const cursor = events[events.length - 1].id;
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
const cursor = rawEventsLastID;
const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null;
eventsLoadingMore = true;
if (btn) {
@@ -335,8 +668,10 @@ async function loadMoreEvents(id: string) {
);
if (resp.ok) {
const page: ProjectEvent[] = await resp.json();
events = events.concat(page);
eventsHasMore = page.length === EVENTS_PAGE_SIZE;
rawEventsLastID = page.length ? page[page.length - 1].id : rawEventsLastID;
rawEventsLastPageFull = page.length === EVENTS_PAGE_SIZE;
events = events.concat(applyVerlaufFilters(page));
eventsHasMore = rawEventsLastPageFull;
}
} catch {
/* swallow — the button re-enables and the user can retry */
@@ -346,7 +681,7 @@ async function loadMoreEvents(id: string) {
btn.disabled = false;
btn.textContent = t("projects.detail.verlauf.loadMore");
}
renderEvents();
renderTimeline();
}
}
@@ -529,8 +864,8 @@ function initProjectAppointmentForm() {
addBtn.style.display = "";
await loadAppointments(project.id);
renderAppointments();
await loadEvents(project.id);
renderEvents();
await loadTimeline(project.id);
renderTimeline();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("projects.error.generic");
@@ -618,8 +953,8 @@ function renderDeadlines() {
if (resp.ok) {
await loadDeadlines(project.id);
renderDeadlines();
await loadEvents(project.id);
renderEvents();
await loadTimeline(project.id);
renderTimeline();
} else {
cb.checked = false;
cb.disabled = false;
@@ -727,62 +1062,10 @@ function renderHeader() {
}
}
function renderEvents() {
const list = document.getElementById("project-events-list")!;
const empty = document.getElementById("project-events-empty")!;
const moreWrap = document.getElementById("project-events-loadmore-wrap");
if (events.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
if (moreWrap) moreWrap.style.display = "none";
return;
}
empty.style.display = "none";
list.innerHTML = events
.map((e) => {
const { title, description } = translateEvent(e.event_type, e.title, e.description ?? null);
const titleHTML = wrapEventTitleLink(e, esc(title));
return `<li class="entity-event">
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
<div class="entity-event-body">
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
</div>
</li>`;
})
.join("");
// Row-level click handler: clicking anywhere on the card navigates to the
// same target as the inner .entity-event-link, but inner <a>/<button> still
// win (so the title link, Cmd-click open-in-new-tab, and any future action
// buttons keep working) and text selection is unaffected — same pattern as
// .entity-table rows (t-098/099). Cards without a link target render no
// .entity-event-link and stay non-clickable. Replaces the t-102 ::before
// overlay (t-paliad-103).
list.querySelectorAll<HTMLLIElement>(".entity-event").forEach((eventEl) => {
const link = eventEl.querySelector<HTMLAnchorElement>(".entity-event-link");
if (!link) return;
eventEl.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button")) return;
window.location.href = link.href;
});
});
if (moreWrap) moreWrap.style.display = eventsHasMore ? "" : "none";
}
// wrapEventTitleLink turns the event title into a hyperlink to the originating
// entity when the metadata carries the right ID. Wired-up event families:
// - checklist_* (except _deleted) → /checklists/instances/{checklist_instance_id}
// - deadline_* (except _deleted, deadlines_imported) → /deadlines/{deadline_id}
// - appointment_* (except _deleted) → /appointments/{appointment_id}
// - note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
// (notes have no standalone page; route to the most-specific parent)
// _deleted events are intentionally not linked — the entity is gone.
// deadlines_imported is bulk and has no single deadline_id, so it stays plain.
// Pairs with the row-level click handler in renderEvents() (t-paliad-103):
// the inner <a class="entity-event-link"> is the canonical, keyboard-tabbable
// target; the surrounding card grows the click surface without breaking
// text-selection or nested anchors.
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
// eventDetailHref. The renderEvents() orphan it paired with was removed
// in t-paliad-173; the SmartTimeline (renderTimeline) is now the only
// project-page render path.
function wrapEventTitleLink(e: ProjectEvent, escapedTitle: string): string {
const href = eventDetailHref(e);
if (href) {
@@ -835,6 +1118,346 @@ function initEventsLoadMore() {
});
}
// initSmartTimelineAuditToggle — wires the "Audit-Log anzeigen" button
// in the Verlauf tab header. When ON, the next /timeline fetch passes
// ?include=audit_full so every paliad.project_events row surfaces (the
// legacy chronological Verlauf view); OFF only shows rows that opted
// into timeline_kind. State persists in localStorage per project.
function initSmartTimelineAuditToggle(id: string) {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Re-read from localStorage now that project is loaded.
timelineAuditFull = readPersistedAuditFull();
// Slice 2: lookahead state is also project-scoped — same pattern.
timelineLookahead = readLookaheadPersisted();
refreshAuditToggleLabel();
btn.addEventListener("click", async () => {
timelineAuditFull = !timelineAuditFull;
writePersistedAuditFull(timelineAuditFull);
refreshAuditToggleLabel();
await loadTimeline(id);
renderTimeline();
});
}
function refreshAuditToggleLabel() {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineAuditFull ? "true" : "false");
btn.textContent = timelineAuditFull
? t("projects.detail.smarttimeline.audit.toggle.hide")
: t("projects.detail.smarttimeline.audit.toggle.show");
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
}
// Slice 4 — Client-level "Timeline-Ansicht" toggle (t-paliad-175 §5.1
// Q12). Visible only on Client-level projects; default OFF (matter-list
// view). When ON, the SmartTimeline lane view replaces the matter list.
// State persists in localStorage per project.
function clientShowLanesStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.client_show_lanes.${id}`;
}
function readClientShowLanes(): boolean {
try {
return localStorage.getItem(clientShowLanesStorageKey()) === "1";
} catch {
return false;
}
}
function writeClientShowLanes(on: boolean) {
try {
if (on) localStorage.setItem(clientShowLanesStorageKey(), "1");
else localStorage.removeItem(clientShowLanesStorageKey());
} catch {
// ignore
}
}
function initSmartTimelineClientToggle(id: string) {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Toggle is markup-rendered always; hide on non-Client projects.
if (project?.type !== "client") {
btn.style.display = "none";
return;
}
btn.style.display = "";
timelineClientShowLanes = readClientShowLanes();
refreshClientToggleLabel();
btn.addEventListener("click", async () => {
timelineClientShowLanes = !timelineClientShowLanes;
writeClientShowLanes(timelineClientShowLanes);
refreshClientToggleLabel();
// Reload to make sure lanes are populated when flipping ON.
await loadTimeline(id);
renderTimeline();
});
}
function refreshClientToggleLabel() {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineClientShowLanes ? "true" : "false");
btn.textContent = timelineClientShowLanes
? t("projects.detail.smarttimeline.client.toggle.matter_list")
: t("projects.detail.smarttimeline.client.toggle.lanes");
btn.classList.toggle("subtree-toggle--active", timelineClientShowLanes);
}
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
// buttons to the existing flows; CCR + R.30 are disabled with a
// "Slice 3" tooltip per the brief.
function initSmartTimelineAddModal(id: string) {
const cta = document.getElementById("smart-timeline-add-btn") as HTMLButtonElement | null;
const modal = document.getElementById("smart-timeline-add-modal") as HTMLDivElement | null;
if (!cta || !modal) return;
const choices = document.querySelector<HTMLDivElement>(".smart-timeline-add-choices");
const form = document.getElementById("smart-timeline-milestone-form") as HTMLFormElement | null;
const milestoneBtn = document.getElementById("smart-timeline-add-milestone") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("smart-timeline-milestone-cancel") as HTMLButtonElement | null;
const closeBtn = document.getElementById("smart-timeline-modal-close") as HTMLButtonElement | null;
const titleInput = document.getElementById("smart-timeline-milestone-title") as HTMLInputElement | null;
const dateInput = document.getElementById("smart-timeline-milestone-date") as HTMLInputElement | null;
const descInput = document.getElementById("smart-timeline-milestone-desc") as HTMLTextAreaElement | null;
const msg = document.getElementById("smart-timeline-milestone-msg") as HTMLDivElement | null;
const dlLink = document.getElementById("smart-timeline-add-deadline") as HTMLAnchorElement | null;
const apptLink = document.getElementById("smart-timeline-add-appointment") as HTMLAnchorElement | null;
if (dlLink) dlLink.href = `/deadlines/new?project=${encodeURIComponent(id)}`;
if (apptLink) apptLink.href = `/appointments/new?project=${encodeURIComponent(id)}`;
const open = () => {
modal.style.display = "";
if (choices) choices.style.display = "";
if (form) form.style.display = "none";
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
};
const close = () => {
modal.style.display = "none";
if (form) form.reset();
};
cta.addEventListener("click", open);
if (closeBtn) closeBtn.addEventListener("click", close);
if (cancelBtn) cancelBtn.addEventListener("click", close);
// Click outside the card → close.
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
if (milestoneBtn && form) {
milestoneBtn.addEventListener("click", () => {
if (choices) choices.style.display = "none";
form.style.display = "";
titleInput?.focus();
});
}
if (form && titleInput) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title) {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.title_required");
msg.className = "form-msg form-msg-error";
}
return;
}
const payload: Record<string, unknown> = { title };
const desc = descInput?.value.trim();
if (desc) payload.description = desc;
const date = dateInput?.value;
if (date) payload.occurred_at = date;
// Slice 4 — bubble-up checkbox (t-paliad-175 §7.2 Q5). Default OFF
// for custom_milestone; user opts in to surface this milestone on
// Patent / Litigation / Client SmartTimelines.
const bubbleEl = document.getElementById("smart-timeline-milestone-bubble-up") as HTMLInputElement | null;
if (bubbleEl?.checked) payload.bubble_up = true;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}/timeline/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
close();
await loadTimeline(id);
renderTimeline();
} else {
const data = (await resp.json().catch(() => ({}))) as { error?: string };
if (msg) {
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
}
} catch {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} finally {
submitBtn.disabled = false;
}
});
}
// Slice 3 — Widerklage (CCR) route: opens an inline form, fetches
// proceeding types lazily on first open, posts to
// /api/projects/{id}/counterclaim, navigates to the new child page on
// success.
initCounterclaimRoute(id, modal, choices, form);
}
interface ProceedingTypeRow {
id: number;
code: string;
name: string;
name_en: string;
jurisdiction?: string;
is_active: boolean;
}
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
if (proceedingTypesCache) return proceedingTypesCache;
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return [];
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
proceedingTypesCache = rows.filter((r) => r.is_active);
return proceedingTypesCache;
} catch {
return [];
}
}
function initCounterclaimRoute(
id: string,
modal: HTMLDivElement,
choices: HTMLDivElement | null,
milestoneForm: HTMLFormElement | null,
) {
const trigger = document.getElementById("smart-timeline-add-counterclaim") as HTMLButtonElement | null;
const form = document.getElementById("smart-timeline-counterclaim-form") as HTMLFormElement | null;
const cancel = document.getElementById("smart-timeline-counterclaim-cancel") as HTMLButtonElement | null;
const procedureSel = document.getElementById("smart-timeline-counterclaim-procedure") as HTMLSelectElement | null;
const titleInput = document.getElementById("smart-timeline-counterclaim-title") as HTMLInputElement | null;
const caseNumberInput = document.getElementById("smart-timeline-counterclaim-case-number") as HTMLInputElement | null;
const flipToggle = document.getElementById("smart-timeline-counterclaim-flip-toggle") as HTMLInputElement | null;
const msg = document.getElementById("smart-timeline-counterclaim-msg") as HTMLDivElement | null;
if (!trigger || !form) return;
const closeModal = () => {
modal.style.display = "none";
form.reset();
};
trigger.addEventListener("click", async () => {
if (choices) choices.style.display = "none";
if (milestoneForm) milestoneForm.style.display = "none";
form.style.display = "";
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
// Populate proceeding-type select on first open. Only UPC types
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
if (procedureSel && procedureSel.options.length === 0) {
const types = await loadProceedingTypes();
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
const langEN = getLang() === "en";
for (const ty of upcTypes) {
const opt = document.createElement("option");
opt.value = String(ty.id);
opt.textContent = `${ty.code}${langEN ? ty.name_en || ty.name : ty.name}`;
if (ty.code === "UPC_REV") opt.selected = true;
procedureSel.appendChild(opt);
}
}
titleInput?.focus();
});
if (cancel) cancel.addEventListener("click", closeModal);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.counterclaim.saving");
msg.className = "form-msg";
}
const payload: Record<string, unknown> = {};
if (procedureSel && procedureSel.value) {
const n = parseInt(procedureSel.value, 10);
if (!isNaN(n)) payload.proceeding_type_id = n;
}
const titleVal = titleInput?.value.trim();
if (titleVal) payload.title = titleVal;
const caseNum = caseNumberInput?.value.trim();
if (caseNum) payload.case_number = caseNum;
// flipToggle CHECKED = "Stimmt nicht?" = do NOT flip our_side.
// Backend interprets flip_our_side=false as "keep parent's side".
if (flipToggle && flipToggle.checked) {
payload.flip_our_side = false;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(id)}/counterclaim`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (resp.ok) {
const data = (await resp.json()) as { id?: string; url?: string };
const dest = data.url ?? (data.id ? `/projects/${data.id}` : null);
if (dest) {
window.location.href = dest;
return;
}
// No id back? Defensive: just close + reload timeline.
closeModal();
await loadTimeline(id);
renderTimeline();
return;
}
const data = (await resp.json().catch(() => ({}))) as { error?: string };
if (msg) {
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} finally {
submitBtn.disabled = false;
}
});
}
function renderParties() {
const tbody = document.getElementById("parties-body")!;
const empty = document.getElementById("parties-empty")!;
@@ -1153,10 +1776,10 @@ function initEditModal() {
project = await resp.json();
closeEditModal();
if (project) {
await Promise.all([loadAncestors(project.id), loadEvents(project.id)]);
await Promise.all([loadAncestors(project.id), loadTimeline(project.id)]);
renderHeader();
renderBreadcrumb();
renderEvents();
renderTimeline();
}
} catch (err) {
msg.textContent = t("projects.error.generic");
@@ -1217,8 +1840,8 @@ function initPartiesForm() {
addBtn.style.display = "";
await loadParties(project.id);
renderParties();
await loadEvents(project.id);
renderEvents();
await loadTimeline(project.id);
renderTimeline();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("projects.error.generic");
@@ -1294,9 +1917,15 @@ async function main() {
return;
}
// loadEvents stays in this Promise.all so the unfiltered Verlauf is
// ready by first paint (avoids an empty-state flash before the bar's
// customRunner finishes its first run, t-paliad-170). When the URL
// carries filter params (?time=…, ?pe_kind=…) the bar's mount triggers
// a second fetch that narrows to the requested rows — accepted cost.
await Promise.all([
loadParties(id),
loadEvents(id),
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
loadAncestors(id),
@@ -1314,7 +1943,7 @@ async function main() {
renderHeader();
renderBreadcrumb();
renderParties();
renderEvents();
renderTimeline();
renderDeadlines();
renderAppointments();
renderChildren();
@@ -1329,11 +1958,65 @@ async function main() {
initDelete();
initEventsLoadMore();
initSubtreeToggles(id);
initSmartTimelineAuditToggle(id);
initSmartTimelineClientToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
showTab(parseTab());
}
// mountVerlaufFilterBar mounts the universal FilterBar inside the
// Verlauf tab (t-paliad-170). The bar owns URL params (?time=, ?pe_kind=)
// and the displayed filter chrome; on every state change it invokes the
// customRunner below, which calls loadEvents (the legacy
// /api/projects/{id}/events endpoint) and applies client-side filtering.
//
// Why customRunner instead of the substrate POST: the legacy endpoint
// expands the project's descendant subtree server-side and returns
// cursor-paginated rows, both of which the substrate's project_event
// runner doesn't yet support (substrate only does ScopeExplicit on a
// flat ID list, no "include descendants", no cursor). Migrating to the
// substrate is the SmartTimeline redesign (t-paliad-169) — this slice
// avoids the regression by keeping the data path and wiring the bar as
// a UI primitive on top.
function mountVerlaufFilterBar(id: string): void {
const host = document.getElementById("project-events-filter-bar");
if (!host) return;
// Synthetic spec — never reaches the substrate (customRunner short-
// circuits the bar's POST), but the bar's contract requires shapes
// that the substrate validator would accept. Sources / scope mirror
// what a future ProjectHistorySystemView would look like.
const baseFilter: FilterSpec = {
version: 1,
sources: ["project_event"],
scope: { projects: { mode: "explicit", ids: [id] } },
time: { horizon: "any" },
};
const baseRender: RenderSpec = { shape: "list" };
verlaufBar = mountFilterBar(host, {
baseFilter,
baseRender,
axes: ["time", "project_event_kind"],
surfaceKey: "project-history",
showSaveAsView: false,
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
customRunner: async (effective) => {
const kinds = effective.filter.predicates?.project_event?.event_types;
verlaufFilters = {
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
...horizonBounds(effective.filter.time?.horizon ?? "any"),
};
await loadEvents(id);
return { rows: [], inaccessible_project_ids: [] };
},
onResult: () => renderTimeline(),
});
}
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
// tab (project lead / global_admin only). The select is populated from
// /api/partner-units excluding units already attached.
@@ -1431,8 +2114,18 @@ function initSubtreeToggles(id: string) {
subtreeMode = !subtreeMode;
persistSubtreeMode();
refreshLabels();
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
renderEvents();
// verlaufBar.refresh() drives loadEvents through the bar's
// customRunner (so the current filter state stays applied).
// verlaufBar.refresh() drives loadEvents through the bar's
// customRunner, but render is now driven entirely by loadTimeline.
const barRefresh = verlaufBar ? verlaufBar.refresh() : Promise.resolve();
await Promise.all([
barRefresh,
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
]);
renderTimeline();
renderDeadlines();
renderAppointments();
});
@@ -2046,7 +2739,7 @@ document.addEventListener("DOMContentLoaded", () => {
onLangChange(() => {
renderHeader();
renderBreadcrumb();
renderEvents();
renderTimeline();
renderParties();
renderDeadlines();
renderAppointments();

View File

@@ -75,6 +75,7 @@ export function initSidebar() {
initPaliadinLinks();
initUserViewsGroup();
initThemeToggle();
fixVerfahrensablaufActive();
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
@@ -443,6 +444,30 @@ function initUserViewsGroup(): void {
});
}
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
// sidebar entries (t-paliad-168). The SSR navItem helper compares
// hrefs against pathname only, which can't tell ?path=a apart from
// the no-query Fristenrechner — both would render as Fristenrechner=
// active. At the client we know the search params; flip the active
// class so the sidebar lights up the entry the user actually opened.
function fixVerfahrensablaufActive(): void {
if (window.location.pathname !== "/tools/fristenrechner") return;
const path = new URLSearchParams(window.location.search).get("path");
const fristenrechner = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner"]',
);
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
);
if (path === "a") {
fristenrechner?.classList.remove("active");
verfahrensablauf?.classList.add("active");
} else {
verfahrensablauf?.classList.remove("active");
fristenrechner?.classList.add("active");
}
}
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
const a = document.createElement("a");
a.href = `/views/${encodeURIComponent(view.slug)}`;

View File

@@ -0,0 +1,960 @@
import { t, getLang } from "../i18n";
// shape-timeline (t-paliad-171 → t-paliad-175) — vertical timeline render
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
// rule separating past from future, status icon + kind chip per row.
//
// Slice 2 (t-paliad-173) adds:
// - Kind="projected" rows in three flavours via Status:
// "predicted" — fade-grey (future)
// "court_set" — dashed border (court-determined)
// "predicted_overdue" — amber-faded (past, no anchor yet)
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
// payload as inline error with a "Stattdessen <predecessor> erfassen"
// link that pre-fills the editor for the parent rule.
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
// walks the parent chain back to the trigger.
// - "[+ Mehr anzeigen]" / "[ Weniger]" lookahead toggle after the 7th
// projected row, cap remembered in localStorage per project.
//
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
// a horizontal lane-strip with one column per lane. Time axis stays
// vertical within each lane; the lane sub-header names the child
// project. CSS Grid handles the desktop side-by-side and collapses
// to single-column on mobile (≤640px).
// - Lane filter chip (multiselect) sits in the timeline header above
// the strip; selecting a subset dims the others.
// - Single-column flow stays the default at Case level (lanes mirror
// tracks one-for-one).
//
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
// shape is the wire contract from /api/projects/{id}/timeline.events;
// LaneInfo[] from .lanes drives the lane-grouped layout.
//
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
// m/paliad#31 layered requirements.
export interface TimelineEvent {
kind: "deadline" | "appointment" | "milestone" | "projected";
status:
| "done"
| "open"
| "overdue"
| "court_set"
| "predicted"
| "predicted_overdue"
| "off_script";
track: string;
date?: string | null;
title: string;
description?: string;
rule_code?: string;
deadline_id?: string;
appointment_id?: string;
project_event_id?: string;
deadline_rule_id?: string;
deadline_rule_party?: string;
sub_project_id?: string;
sub_project_title?: string;
depends_on_rule_code?: string;
depends_on_date?: string | null;
depends_on_rule_name?: string;
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
// the row into one of the columns described by RenderOptions.lanes.
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
}
export interface LaneInfo {
id: string;
label: string;
project_id?: string;
primary?: boolean;
}
export interface PredecessorMissingPayload {
error: "predecessor_missing";
missing_rule_code: string;
missing_rule_name_de: string;
missing_rule_name_en: string;
requested_rule_code: string;
requested_rule_name_de: string;
requested_rule_name_en: string;
message_de: string;
message_en: string;
}
export interface RenderOptions {
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
today?: string;
// The project the timeline belongs to. Required for anchor / skip
// POSTs. When undefined, projected rows don't expose "Datum setzen".
projectId?: string;
// Language hint — falls back to getLang() when omitted.
lang?: "de" | "en";
// Called after a successful anchor write so the host can re-fetch
// and re-render. Skipped when omitted.
onChange?: () => void | Promise<void>;
// Lookahead state for projected rows. Default 7 = backend default.
lookahead?: number;
// Total number of future predicted rows the backend knows about
// (read from X-Projection-Total). When > visible projected count,
// "Mehr anzeigen" is shown.
projectedTotal?: number;
// Called when the user toggles "Mehr / Weniger anzeigen". The host
// updates state + re-fetches with the new ?lookahead=N.
onLookaheadChange?: (next: number) => void | Promise<void>;
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
// track tag present in the response (parsed from X-Projection-Tracks).
// When the list contains a non-"parent" entry, the [Track ▼] chip
// surfaces. selectedTrack is the user's filter ("all" = render every
// available track in parallel; otherwise render only the named tag).
availableTracks?: string[];
selectedTrack?: string;
onTrackChange?: (next: string) => void | Promise<void>;
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
// renderSmartTimeline renders a lane-strip layout (one column per
// lane) instead of the single-column flow. selectedLanes is the
// user's lane-filter chip; defaults to all lanes selected. Empty
// array = nothing rendered (defensible for the user explicitly
// unchecking every lane).
lanes?: LaneInfo[];
selectedLanes?: string[]; // ids; undefined = all lanes selected
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
}
export function renderSmartTimeline(
host: HTMLElement,
rows: TimelineEvent[],
opts: RenderOptions = {},
): void {
host.innerHTML = "";
host.classList.add("smart-timeline");
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
// backend reports more than one lane, every event already carries a
// lane_id and the layout switches from single-column to lane strip.
// Lane mode takes precedence over Track-mode (the two are different
// axes — lanes group by *direct child project*, tracks group by
// CCR-vs-parent on a single Case).
const lanes = opts.lanes ?? [];
const isLaneMode = lanes.length > 1;
if (isLaneMode) {
host.appendChild(renderLaneStrip(rows, lanes, opts));
return;
}
// Slice 3 — track filtering. The bar header carries the [Track ▼]
// chip whenever the response advertised more than the default
// "parent" track; the filter is applied here before any flow render.
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
const hasMultipleTracks = availableTracks.length > 1;
const selectedTrack = opts.selectedTrack ?? "all";
if (hasMultipleTracks) {
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
}
// Filter rows by the selected track. "all" leaves rows untouched
// (parallel layout decides per-track partitioning below).
const filteredRows =
selectedTrack === "all"
? rows
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
if (filteredRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-empty";
empty.textContent = t("projects.detail.smarttimeline.empty");
host.appendChild(empty);
return;
}
// When the user has selected "all" AND there are multiple tracks
// present, render parallel columns side-by-side. Otherwise the
// existing single-column flow serves both single-track projects and
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
if (selectedTrack === "all" && hasMultipleTracks) {
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
return;
}
// Single-column flow.
host.appendChild(renderTimelineFlow(filteredRows, opts));
}
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
// One column per lane, each column shows the lane's own past/today/
// future flow. Lane filter chip (multiselect) sits above the strip.
// Lanes the user has unchecked render dimmed but still take up the
// column slot — this preserves the time-axis alignment across lanes.
function renderLaneStrip(
rows: TimelineEvent[],
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lanes-wrap";
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
// Sits above the strip.
wrap.appendChild(renderLaneFilterChip(lanes, opts));
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const grid = document.createElement("div");
grid.className = "smart-timeline-lanes";
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
// Group rows by lane_id. Rows without a lane_id default to the first
// lane id so they don't disappear. For lane mode the backend always
// sets lane_id explicitly; this fallback is defensive.
const byLane = new Map<string, TimelineEvent[]>();
for (const l of lanes) byLane.set(l.id, []);
for (const r of rows) {
const id = r.lane_id || lanes[0].id;
if (!byLane.has(id)) byLane.set(id, []);
byLane.get(id)!.push(r);
}
for (const lane of lanes) {
const col = document.createElement("div");
col.className = "smart-timeline-lane";
if (!selected.has(lane.id)) {
col.classList.add("smart-timeline-lane--dimmed");
}
if (lane.primary) {
col.classList.add("smart-timeline-lane--primary");
}
const header = document.createElement("h4");
header.className = "smart-timeline-lane-header";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
link.className = "smart-timeline-lane-header-link";
header.appendChild(link);
} else {
header.textContent = lane.label;
}
col.appendChild(header);
const laneRows = byLane.get(lane.id) ?? [];
if (laneRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-lane-empty";
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
col.appendChild(empty);
} else {
col.appendChild(renderTimelineFlow(laneRows, opts));
}
grid.appendChild(col);
}
wrap.appendChild(grid);
return wrap;
}
// renderLaneFilterChip — multiselect chip-row for the lane filter.
// Defaults to all lanes selected; user toggles individual chips. The
// "Alle" pseudo-chip resets to all selected.
function renderLaneFilterChip(
lanes: LaneInfo[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lane-filter";
const label = document.createElement("span");
label.className = "smart-timeline-lane-filter-label";
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
wrap.appendChild(label);
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
if (selected.size === lanes.length) {
allBtn.classList.add("is-active");
}
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
allBtn.addEventListener("click", () => {
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
});
wrap.appendChild(allBtn);
for (const lane of lanes) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "smart-timeline-lane-chip";
if (selected.has(lane.id)) chip.classList.add("is-active");
chip.textContent = lane.label;
chip.addEventListener("click", () => {
const next = new Set(selected);
if (next.has(lane.id)) {
next.delete(lane.id);
} else {
next.add(lane.id);
}
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
});
wrap.appendChild(chip);
}
return wrap;
}
// renderParallelTracks builds a CSS-grid wrapper with one column per
// track. Each column is a self-contained smart-timeline-flow with its
// own past / today / future sections, plus a sub-header that names the
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
// (Kontext)" for the parent_context view on a CCR child).
//
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
// and a media query — the grid switches to a single column there with
// each sub-header preserved so the user knows which track they're on.
function renderParallelTracks(
rows: TimelineEvent[],
availableTracks: string[],
opts: RenderOptions,
): HTMLElement {
const grid = document.createElement("div");
grid.className = "smart-timeline-tracks";
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
// Group rows by track. Rows with no track default to "parent".
const byTrack = new Map<string, TimelineEvent[]>();
for (const tr of availableTracks) byTrack.set(tr, []);
for (const r of rows) {
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
if (!byTrack.has(key)) byTrack.set(key, []);
byTrack.get(key)!.push(r);
}
for (const trackTag of availableTracks) {
const trackRows = byTrack.get(trackTag) ?? [];
const col = document.createElement("div");
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
const header = document.createElement("h4");
header.className = "smart-timeline-track-header";
header.textContent = trackHeaderLabel(trackTag, trackRows);
col.appendChild(header);
if (trackRows.length === 0) {
const empty = document.createElement("div");
empty.className = "smart-timeline-track-empty";
empty.textContent = t("projects.detail.smarttimeline.empty");
col.appendChild(empty);
} else {
col.appendChild(renderTimelineFlow(trackRows, opts));
}
grid.appendChild(col);
}
return grid;
}
// renderTimelineFlow renders the past / today / future / undated flow
// for the given row set into a fresh container. Extracted from the
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
// column in the parallel layout.
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
const todayISO = opts.today ?? todayLocalISO();
const past: TimelineEvent[] = [];
const todays: TimelineEvent[] = [];
const future: TimelineEvent[] = [];
const undated: TimelineEvent[] = [];
for (const r of rows) {
const iso = dateOnlyISO(r.date);
if (!iso) {
undated.push(r);
continue;
}
if (iso < todayISO) past.push(r);
else if (iso === todayISO) todays.push(r);
else future.push(r);
}
past.sort(byDateAsc);
todays.sort(byDateAsc);
future.sort(byDateAsc);
const wrap = document.createElement("div");
wrap.className = "smart-timeline-flow";
if (past.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--past";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.past");
section.appendChild(heading);
for (const ev of past) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
const todayRule = document.createElement("div");
todayRule.className = "smart-timeline-today-rule";
const todayLabel = document.createElement("span");
todayLabel.className = "smart-timeline-today-label";
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
todayRule.appendChild(todayLabel);
wrap.appendChild(todayRule);
if (todays.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--today";
for (const ev of todays) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
if (future.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--future";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.future");
section.appendChild(heading);
for (const ev of future) section.appendChild(renderRow(ev, opts));
section.appendChild(renderLookaheadToggle(future, opts));
wrap.appendChild(section);
} else {
const lookaheadHost = renderLookaheadToggle(future, opts);
if (lookaheadHost.childElementCount > 0) {
wrap.appendChild(lookaheadHost);
}
}
if (undated.length > 0) {
const section = document.createElement("section");
section.className = "smart-timeline-section smart-timeline-section--undated";
const heading = document.createElement("h3");
heading.className = "smart-timeline-heading";
heading.textContent = t("projects.detail.smarttimeline.section.undated");
section.appendChild(heading);
for (const ev of undated) section.appendChild(renderRow(ev, opts));
wrap.appendChild(section);
}
return wrap;
}
// renderTrackChip builds the [Track ▼] selector. Options are derived
// from the response's available_tracks header — i18n keys translate
// each option label, with the sub-project title surfacing for CCR
// tracks ("Widerklage — <title>"). Persists the user's selection via
// the host through opts.onTrackChange.
function renderTrackChip(
availableTracks: string[],
selected: string,
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-track-chip";
const label = document.createElement("label");
label.className = "smart-timeline-track-chip-label";
label.textContent = t("projects.detail.smarttimeline.track.label");
wrap.appendChild(label);
const select = document.createElement("select");
select.className = "smart-timeline-track-chip-select";
const allOpt = document.createElement("option");
allOpt.value = "all";
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
select.appendChild(allOpt);
for (const trackTag of availableTracks) {
const opt = document.createElement("option");
opt.value = trackTag;
opt.textContent = trackOnlyLabel(trackTag);
select.appendChild(opt);
}
select.value = selected;
select.addEventListener("change", () => {
if (opts.onTrackChange) void opts.onTrackChange(select.value);
});
wrap.appendChild(select);
return wrap;
}
// trackClassFor maps a track tag to its CSS modifier so the column
// gets the appropriate visual treatment (lime for parent, light shade
// for counterclaim, faded for parent_context).
function trackClassFor(trackTag: string): string {
if (trackTag === "parent") return "smart-timeline-track--parent";
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
return "smart-timeline-track--other";
}
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
// the sub_project_title from the first row in the track so the user
// sees "Widerklage — <child title>". Falls back to a generic label
// when the title is empty.
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
if (trackTag === "parent") {
return t("projects.detail.smarttimeline.track.header.parent");
}
const firstWithTitle = rows.find((r) => r.sub_project_title);
const subTitle = firstWithTitle?.sub_project_title ?? "";
if (trackTag.startsWith("counterclaim:")) {
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
return subTitle ? `${base}${subTitle}` : base;
}
if (trackTag.startsWith("parent_context:")) {
const base = t("projects.detail.smarttimeline.track.header.parent_context");
return subTitle ? `${base}${subTitle}` : base;
}
return trackTag;
}
// trackOnlyLabel is the chip dropdown label for "show only this track".
function trackOnlyLabel(trackTag: string): string {
if (trackTag === "parent") {
return t("projects.detail.smarttimeline.track.only.parent");
}
if (trackTag.startsWith("counterclaim:")) {
return t("projects.detail.smarttimeline.track.only.counterclaim");
}
if (trackTag.startsWith("parent_context:")) {
return t("projects.detail.smarttimeline.track.only.parent_context");
}
return trackTag;
}
function renderLookaheadToggle(
futureRows: TimelineEvent[],
opts: RenderOptions,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-lookahead";
const total = opts.projectedTotal ?? 0;
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
const cur = opts.lookahead ?? 7;
if (total > projectedShown && opts.onLookaheadChange) {
const more = document.createElement("button");
more.type = "button";
more.className = "smart-timeline-lookahead-btn";
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
more.setAttribute(
"aria-label",
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
);
more.addEventListener("click", () => {
const next = Math.min(50, cur + 7);
void opts.onLookaheadChange?.(next);
});
wrap.appendChild(more);
}
if (cur > 7 && opts.onLookaheadChange) {
const less = document.createElement("button");
less.type = "button";
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
less.addEventListener("click", () => {
void opts.onLookaheadChange?.(7);
});
wrap.appendChild(less);
}
return wrap;
}
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
const li = document.createElement("article");
li.className =
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
`smart-timeline-row--${ev.status}`;
if (ev.deadline_rule_party) {
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
}
const dateCol = document.createElement("div");
dateCol.className = "smart-timeline-date";
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
li.appendChild(dateCol);
const body = document.createElement("div");
body.className = "smart-timeline-body";
const head = document.createElement("div");
head.className = "smart-timeline-row-head";
const icon = document.createElement("span");
icon.className = "smart-timeline-status-icon";
icon.textContent = statusGlyph(ev.status);
icon.setAttribute("aria-label", t(statusKey(ev.status)));
head.appendChild(icon);
const titleEl = document.createElement("span");
titleEl.className = "smart-timeline-title";
const href = deepLinkHref(ev);
if (href) {
const a = document.createElement("a");
a.className = "smart-timeline-link";
a.href = href;
a.textContent = ev.title;
titleEl.appendChild(a);
} else {
titleEl.textContent = ev.title;
}
head.appendChild(titleEl);
const kindChip = document.createElement("span");
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
kindChip.textContent = t(kindKey(ev.kind));
head.appendChild(kindChip);
if (ev.rule_code) {
const ruleChip = document.createElement("span");
ruleChip.className = "smart-timeline-rule-chip";
ruleChip.textContent = ev.rule_code;
head.appendChild(ruleChip);
}
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
// projected rows so the user reads the row's nature at a glance.
if (ev.kind === "projected") {
const statusPill = document.createElement("span");
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
statusPill.textContent = t(statusKey(ev.status));
head.appendChild(statusPill);
}
body.appendChild(head);
if (ev.description) {
const desc = document.createElement("div");
desc.className = "smart-timeline-desc";
desc.textContent = ev.description;
body.appendChild(desc);
}
// Depends-on footer (#31 layer 2) — surface the parent rule + its
// date right under the title so the user reads the dependency at a
// glance. "[Pfad anzeigen]" expands the full chain on demand.
if (ev.depends_on_rule_code) {
body.appendChild(renderDependsOn(ev));
}
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
// "[Datum setzen]" inline editor; actuals from rules expose a
// "[Datum ändern]" variant that PATCHes via the same endpoint.
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
body.appendChild(renderAnchorAction(ev, opts));
}
li.appendChild(body);
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
if (href) {
li.classList.add("smart-timeline-row--clickable");
li.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
window.location.href = href;
});
}
return li;
}
function renderDependsOn(ev: TimelineEvent): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-depends-on";
const code = ev.depends_on_rule_code ?? "";
const name = ev.depends_on_rule_name || code;
const dateText = ev.depends_on_date
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
: t("projects.detail.smarttimeline.depends_on.date_open");
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
const txt = document.createElement("span");
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
wrap.appendChild(txt);
const expand = document.createElement("button");
expand.type = "button";
expand.className = "smart-timeline-depends-on-expand";
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
expand.addEventListener("click", () => {
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
wrap.classList.remove("smart-timeline-depends-on--expanded");
const list = wrap.querySelector(".smart-timeline-depends-on-path");
if (list) list.remove();
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
return;
}
wrap.classList.add("smart-timeline-depends-on--expanded");
const list = document.createElement("div");
list.className = "smart-timeline-depends-on-path";
// The walked chain isn't pre-computed server-side beyond the
// immediate parent; the backend annotation gives one hop. Future
// slice can deepen this — for v1 we surface the immediate parent
// (already in the prefix line) and a hint that the user can click
// the parent's row to see its own dependency.
const hint = document.createElement("span");
hint.className = "smart-timeline-depends-on-hint";
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
list.appendChild(hint);
wrap.appendChild(list);
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
});
wrap.appendChild(expand);
return wrap;
}
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "smart-timeline-anchor";
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "smart-timeline-anchor-btn";
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
wrap.appendChild(trigger);
trigger.addEventListener("click", () => {
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
wrap.classList.add("smart-timeline-anchor--editing");
trigger.style.display = "none";
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
});
return wrap;
}
function buildAnchorEditor(
ev: TimelineEvent,
opts: RenderOptions,
wrap: HTMLElement,
): HTMLElement {
const editor = document.createElement("form");
editor.className = "smart-timeline-anchor-form";
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
editor.addEventListener("submit", (e) => e.preventDefault());
const dateInput = document.createElement("input");
dateInput.type = "date";
dateInput.className = "smart-timeline-anchor-date";
dateInput.required = true;
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
editor.appendChild(dateInput);
const submit = document.createElement("button");
submit.type = "submit";
submit.className = "smart-timeline-anchor-submit";
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
editor.appendChild(submit);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "smart-timeline-anchor-cancel";
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
cancel.addEventListener("click", () => {
wrap.innerHTML = "";
const trig = document.createElement("button");
trig.type = "button";
trig.className = "smart-timeline-anchor-btn";
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
wrap.classList.remove("smart-timeline-anchor--editing");
wrap.appendChild(trig);
trig.addEventListener("click", () => {
wrap.innerHTML = "";
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
wrap.classList.add("smart-timeline-anchor--editing");
});
});
editor.appendChild(cancel);
const msg = document.createElement("div");
msg.className = "smart-timeline-anchor-msg";
editor.appendChild(msg);
editor.addEventListener("submit", async () => {
if (!opts.projectId) return;
if (!ev.rule_code) return;
const date = dateInput.value;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
msg.classList.add("smart-timeline-anchor-msg--error");
return;
}
submit.disabled = true;
cancel.disabled = true;
msg.classList.remove("smart-timeline-anchor-msg--error");
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
rule_code: ev.rule_code,
actual_date: date,
}),
},
);
if (resp.ok) {
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
if (opts.onChange) await opts.onChange();
return;
}
if (resp.status === 409) {
const payload = (await resp.json()) as PredecessorMissingPayload;
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
return;
}
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
msg.classList.add("smart-timeline-anchor-msg--error");
} catch {
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
msg.classList.add("smart-timeline-anchor-msg--error");
} finally {
submit.disabled = false;
cancel.disabled = false;
}
});
return editor;
}
function renderPredecessorError(
msg: HTMLElement,
payload: PredecessorMissingPayload,
_ev: TimelineEvent,
opts: RenderOptions,
_dateInput: HTMLInputElement,
_submit: HTMLButtonElement,
_cancel: HTMLButtonElement,
): void {
msg.innerHTML = "";
msg.classList.add("smart-timeline-anchor-msg--error");
msg.classList.add("smart-timeline-anchor-msg--predecessor");
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
const message = lang === "en" ? payload.message_en : payload.message_de;
const main = document.createElement("p");
main.textContent = message;
msg.appendChild(main);
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
// the missing parent rule, scrolls to its row if present, falls back
// to a fresh editor in-place.
const link = document.createElement("button");
link.type = "button";
link.className = "smart-timeline-anchor-predecessor-link";
const predName =
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
link.textContent =
lang === "en"
? `Anchor „${predName}“ instead`
: `Stattdessen „${predName}“ erfassen`;
link.addEventListener("click", () => {
// Find the projected row for missing_rule_code and scroll into view;
// the row's own [Datum setzen] button takes it from there.
const targetRow = findRowForRuleCode(payload.missing_rule_code);
if (targetRow) {
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
const btn = targetRow.querySelector<HTMLButtonElement>(
".smart-timeline-anchor-btn",
);
if (btn) btn.click();
}
});
msg.appendChild(link);
}
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
for (const r of Array.from(rows)) {
const chip = r.querySelector(".smart-timeline-rule-chip");
if (chip && chip.textContent === ruleCode) return r;
}
return null;
}
function deepLinkHref(ev: TimelineEvent): string | null {
if (ev.kind === "deadline" && ev.deadline_id) {
return `/deadlines/${ev.deadline_id}`;
}
if (ev.kind === "appointment" && ev.appointment_id) {
return `/appointments/${ev.appointment_id}`;
}
return null;
}
function statusGlyph(status: TimelineEvent["status"]): string {
switch (status) {
case "done": return "✓";
case "open": return "…";
case "overdue": return "!";
case "court_set": return "▢";
case "predicted": return "░";
case "predicted_overdue": return "░!";
case "off_script": return "⊕";
default: return "·";
}
}
function statusKey(status: TimelineEvent["status"]) {
return `projects.detail.smarttimeline.status.${status}` as const;
}
function kindKey(kind: TimelineEvent["kind"]) {
return `projects.detail.smarttimeline.kind.${kind}` as const;
}
function dateOnlyISO(raw: string | null | undefined): string | null {
if (!raw) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
const d = new Date(raw);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function todayLocalISO(): string {
const d = new Date();
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
const ai = dateOnlyISO(a.date) ?? "";
const bi = dateOnlyISO(b.date) ?? "";
if (ai === bi) return a.title.localeCompare(b.title);
return ai < bi ? -1 : 1;
}
function formatDateOnly(iso: string): string {
if (!iso) return "—";
const parts = iso.split("-");
if (parts.length !== 3) return iso;
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
if (isNaN(d.getTime())) return iso;
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
}

View File

@@ -20,7 +20,7 @@ export interface ScopeSpec {
export type TimeHorizon =
| "next_7d" | "next_30d" | "next_90d"
| "past_30d" | "past_90d"
| "past_7d" | "past_30d" | "past_90d"
| "any" | "all" | "custom";
export type TimeField = "auto" | "created_at";

View File

@@ -7,6 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
// so the two affordances read as different at a glance.
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
@@ -157,6 +161,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
Gerichte / Glossar), then content (Links / Downloads). */}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +

View File

@@ -207,6 +207,20 @@ export function renderFristenrechner(): string {
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;st.
</span>
</button>
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
entry. Drops directly into Pathway A (Verfahrensablauf
wizard) with no save flow — mirrors the existing ad-hoc
explore behaviour: timeline renders, save CTA stays
disabled because there's no save intent. */}
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128214;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
Verfahrensablauf einsehen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
Browse / Learn &mdash; sehen, was wann passiert. Keine Frist eintragen.
</span>
</button>
</div>
<div className="fristen-step2-shortcut">
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">

View File

@@ -970,6 +970,8 @@ export type I18nKey =
| "deadlines.step1.selected"
| "deadlines.step1.summary.adhoc.suffix"
| "deadlines.step2"
| "deadlines.step2.browse.desc"
| "deadlines.step2.browse.title"
| "deadlines.step2.file.desc"
| "deadlines.step2.file.title"
| "deadlines.step2.happened.desc"
@@ -1466,6 +1468,7 @@ export type I18nKey =
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
| "nav.verfahrensablauf"
| "notes.cancel"
| "notes.delete"
| "notes.delete.confirm"
@@ -1692,6 +1695,78 @@ export type I18nKey =
| "projects.detail.parteien.role.defendant"
| "projects.detail.parteien.role.thirdparty"
| "projects.detail.save"
| "projects.detail.smarttimeline.add.cancel"
| "projects.detail.smarttimeline.add.choice.amend"
| "projects.detail.smarttimeline.add.choice.appointment"
| "projects.detail.smarttimeline.add.choice.counterclaim"
| "projects.detail.smarttimeline.add.choice.deadline"
| "projects.detail.smarttimeline.add.choice.disabled"
| "projects.detail.smarttimeline.add.choice.milestone"
| "projects.detail.smarttimeline.add.cta"
| "projects.detail.smarttimeline.add.modal.title"
| "projects.detail.smarttimeline.add.submit"
| "projects.detail.smarttimeline.anchor.cancel"
| "projects.detail.smarttimeline.anchor.error"
| "projects.detail.smarttimeline.anchor.invalid_date"
| "projects.detail.smarttimeline.anchor.save"
| "projects.detail.smarttimeline.anchor.saved"
| "projects.detail.smarttimeline.anchor.saving"
| "projects.detail.smarttimeline.anchor.set"
| "projects.detail.smarttimeline.audit.toggle.hide"
| "projects.detail.smarttimeline.audit.toggle.show"
| "projects.detail.smarttimeline.client.matter_list.empty"
| "projects.detail.smarttimeline.client.matter_list.heading"
| "projects.detail.smarttimeline.client.matter_list.hint"
| "projects.detail.smarttimeline.client.toggle.lanes"
| "projects.detail.smarttimeline.client.toggle.matter_list"
| "projects.detail.smarttimeline.counterclaim.case_number"
| "projects.detail.smarttimeline.counterclaim.flip_hint"
| "projects.detail.smarttimeline.counterclaim.flip_override"
| "projects.detail.smarttimeline.counterclaim.procedure"
| "projects.detail.smarttimeline.counterclaim.saving"
| "projects.detail.smarttimeline.counterclaim.submit"
| "projects.detail.smarttimeline.counterclaim.title"
| "projects.detail.smarttimeline.depends_on.date_open"
| "projects.detail.smarttimeline.depends_on.hide_path"
| "projects.detail.smarttimeline.depends_on.path_hint"
| "projects.detail.smarttimeline.depends_on.prefix"
| "projects.detail.smarttimeline.depends_on.show_path"
| "projects.detail.smarttimeline.empty"
| "projects.detail.smarttimeline.error.generic"
| "projects.detail.smarttimeline.error.title_required"
| "projects.detail.smarttimeline.kind.appointment"
| "projects.detail.smarttimeline.kind.deadline"
| "projects.detail.smarttimeline.kind.milestone"
| "projects.detail.smarttimeline.kind.projected"
| "projects.detail.smarttimeline.lane.empty"
| "projects.detail.smarttimeline.lane.filter.all"
| "projects.detail.smarttimeline.lane.filter.label"
| "projects.detail.smarttimeline.lookahead.less"
| "projects.detail.smarttimeline.lookahead.more"
| "projects.detail.smarttimeline.milestone.bubble_up"
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
| "projects.detail.smarttimeline.milestone.date"
| "projects.detail.smarttimeline.milestone.description"
| "projects.detail.smarttimeline.milestone.title"
| "projects.detail.smarttimeline.section.future"
| "projects.detail.smarttimeline.section.past"
| "projects.detail.smarttimeline.section.undated"
| "projects.detail.smarttimeline.status.court_set"
| "projects.detail.smarttimeline.status.done"
| "projects.detail.smarttimeline.status.off_script"
| "projects.detail.smarttimeline.status.open"
| "projects.detail.smarttimeline.status.overdue"
| "projects.detail.smarttimeline.status.predicted"
| "projects.detail.smarttimeline.status.predicted_overdue"
| "projects.detail.smarttimeline.today"
| "projects.detail.smarttimeline.track.both"
| "projects.detail.smarttimeline.track.header.counterclaim"
| "projects.detail.smarttimeline.track.header.parent"
| "projects.detail.smarttimeline.track.header.parent_context"
| "projects.detail.smarttimeline.track.label"
| "projects.detail.smarttimeline.track.only.counterclaim"
| "projects.detail.smarttimeline.track.only.parent"
| "projects.detail.smarttimeline.track.only.parent_context"
| "projects.detail.tab.checklisten"
| "projects.detail.tab.fristen"
| "projects.detail.tab.kinder"
@@ -1971,9 +2046,12 @@ export type I18nKey =
| "views.bar.label.deadline_status"
| "views.bar.label.density"
| "views.bar.label.personal"
| "views.bar.label.project_event_kind"
| "views.bar.label.shape"
| "views.bar.label.sort"
| "views.bar.label.time"
| "views.bar.label.timeline_status"
| "views.bar.label.timeline_track"
| "views.bar.personal.on"
| "views.bar.save.cancel"
| "views.bar.save.confirm"
@@ -1991,6 +2069,7 @@ export type I18nKey =
| "views.bar.shape.list"
| "views.bar.sort.date_asc"
| "views.bar.sort.date_desc"
| "views.bar.time.all"
| "views.bar.time.any"
| "views.bar.time.custom"
| "views.bar.time.custom.coming_soon"
@@ -1998,6 +2077,20 @@ export type I18nKey =
| "views.bar.time.next_7d"
| "views.bar.time.next_90d"
| "views.bar.time.past_30d"
| "views.bar.time.past_7d"
| "views.bar.time.past_90d"
| "views.bar.timeline_status.court_set"
| "views.bar.timeline_status.done"
| "views.bar.timeline_status.macro.future"
| "views.bar.timeline_status.macro.past"
| "views.bar.timeline_status.off_script"
| "views.bar.timeline_status.open"
| "views.bar.timeline_status.overdue"
| "views.bar.timeline_status.predicted"
| "views.bar.timeline_status.predicted_overdue"
| "views.bar.timeline_track.counterclaim"
| "views.bar.timeline_track.off_script"
| "views.bar.timeline_track.parent"
| "views.calendar.mobile_fallback"
| "views.col.actor"
| "views.col.appointment_type"

View File

@@ -82,21 +82,132 @@ export function renderProjectsDetail(): string {
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
</nav>
{/* History (Verlauf) */}
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
The legacy <ul.entity-events> + Mehr-laden controls are
replaced by the vertical timeline (rendered by
client/views/shape-timeline.ts). The bar from t-paliad-170
keeps driving filter state via its customRunner. */}
<section className="entity-tab-panel" id="tab-history">
<div className="party-controls">
<div className="smart-timeline-controls">
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
Inkl. Unterprojekte
</button>
</div>
<ul className="entity-events" id="project-events-list" />
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
</p>
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
Mehr laden
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
Audit-Log anzeigen
</button>
{/* Slice 4 — Client-level Timeline-Ansicht toggle.
Hidden by default (display:none); the client TS
flips it visible only when project.type === 'client'. */}
<button type="button" className="btn-secondary btn-small" id="smart-timeline-client-toggle" aria-pressed="false" style="display:none" data-i18n="projects.detail.smarttimeline.client.toggle.lanes">
Timeline-Ansicht
</button>
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
+ Eintrag
</button>
</div>
<div id="project-events-filter-bar" />
<div id="project-smart-timeline" className="smart-timeline" />
{/* "Eigener Meilenstein" modal. Hidden by default; opened
by the "+ Eintrag" CTA above. The other modal options
route to existing flows (see client wiring). */}
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
<div className="smart-timeline-modal-card">
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
Neuer Eintrag im SmartTimeline
</h3>
<div className="smart-timeline-add-choices">
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
Frist anlegen
</a>
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
Termin anlegen
</a>
<button type="button" id="smart-timeline-add-counterclaim" className="smart-timeline-add-choice" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
Widerklage (CCR)
</button>
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
Antrag auf Änderung (R.30)
</button>
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
Eigener Meilenstein
</button>
</div>
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
<div className="form-field">
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
</div>
<div className="form-field">
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
<input type="date" id="smart-timeline-milestone-date" />
</div>
<div className="form-field">
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
<textarea id="smart-timeline-milestone-desc" rows={3} />
</div>
{/* Slice 4 — bubble-up override (t-paliad-175 §7.2 Q5). */}
<div className="form-field form-field--checkbox">
<label className="form-checkbox">
<input type="checkbox" id="smart-timeline-milestone-bubble-up" />
<span data-i18n="projects.detail.smarttimeline.milestone.bubble_up">In übergeordneten Akten anzeigen</span>
</label>
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.milestone.bubble_up_hint">
Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.
</small>
</div>
<div id="smart-timeline-milestone-msg" className="form-msg" />
<div className="form-field-row">
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
</div>
</form>
{/* CCR sub-project create form (Slice 3, t-paliad-174). The
proceeding-type select is populated by the client at
runtime; our_side defaults to inverted with a
"Stimmt nicht?" override toggle for the R.49.2.b
edge case. Title is auto-suggested server-side and
can be overridden inline. */}
<form id="smart-timeline-counterclaim-form" className="entity-form" style="display:none" autocomplete="off">
<div className="form-field">
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
<select id="smart-timeline-counterclaim-procedure">
{/* Options injected from client; defaults to UPC_REV */}
</select>
</div>
<div className="form-field">
<label htmlFor="smart-timeline-counterclaim-title" data-i18n="projects.detail.smarttimeline.counterclaim.title">Titel (optional)</label>
<input type="text" id="smart-timeline-counterclaim-title" maxLength={200} placeholder="Auto-Vorschlag aus Patentnummer" />
</div>
<div className="form-field">
<label htmlFor="smart-timeline-counterclaim-case-number" data-i18n="projects.detail.smarttimeline.counterclaim.case_number">CCR-Aktenzeichen (optional)</label>
<input type="text" id="smart-timeline-counterclaim-case-number" maxLength={200} placeholder="ACT_xxx_2026" />
</div>
<div className="form-field form-field--checkbox">
<label className="form-checkbox">
<input type="checkbox" id="smart-timeline-counterclaim-flip-toggle" />
<span data-i18n="projects.detail.smarttimeline.counterclaim.flip_override">Unsere Seite NICHT umkehren (Stimmt nicht?)</span>
</label>
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.counterclaim.flip_hint">
Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger Beklagter). Aktivieren bei R.49.2.b CCI.
</small>
</div>
<div id="smart-timeline-counterclaim-msg" className="form-msg" />
<div className="form-field-row">
<button type="button" className="btn-secondary" id="smart-timeline-counterclaim-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.counterclaim.submit">Widerklage anlegen</button>
</div>
</form>
<div className="smart-timeline-modal-close-row">
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
Abbrechen
</button>
</div>
</div>
</div>
</section>

View File

@@ -13451,3 +13451,724 @@ dialog.quick-add-sheet::backdrop {
gap: 0.5rem;
margin-top: 0.5rem;
}
/* ========================================================================
SmartTimeline (t-paliad-171, Slice 1).
Vertical two-column timeline replacing the legacy <ul.entity-events>
on the Verlauf tab. Past chronological → "Heute →" rule → Future
chronological. Status icon + kind chip per row, deep-link via a
row-level click handler on .smart-timeline-row--clickable (NOT a
::before overlay — text selection must stay intact, project CLAUDE.md
"Whole-card click → use a JS row handler").
======================================================================== */
.smart-timeline-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
}
.smart-timeline-controls #smart-timeline-add-btn {
margin-left: auto;
}
.smart-timeline {
display: block;
}
.smart-timeline-empty {
text-align: center;
padding: 2rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.smart-timeline-flow {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.smart-timeline-section {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.smart-timeline-heading {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
margin: 0 0 0.25rem;
font-weight: 600;
}
/* "Heute →" horizontal rule. Anchors past vs future visually even when
one side is empty, so the user always has a temporal reference. */
.smart-timeline-today-rule {
display: flex;
align-items: center;
gap: 0.6rem;
color: var(--color-accent-fg);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0.25rem 0;
}
.smart-timeline-today-rule::before,
.smart-timeline-today-rule::after {
content: "";
flex: 1;
height: 0;
border-top: 2px solid var(--hlc-lime, var(--color-accent-fg));
border-radius: 1px;
}
.smart-timeline-today-label {
flex: 0 0 auto;
padding: 0 0.4rem;
}
.smart-timeline-row {
display: grid;
grid-template-columns: 130px 1fr;
gap: 1rem;
padding: 0.65rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
box-shadow: var(--shadow);
align-items: start;
}
@media (max-width: 640px) {
.smart-timeline-row {
grid-template-columns: 1fr;
}
}
.smart-timeline-row--clickable {
cursor: pointer;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.smart-timeline-row--clickable:hover {
border-color: var(--color-accent-fg);
box-shadow: var(--shadow-hover, var(--shadow));
}
.smart-timeline-date {
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
padding-top: 0.15rem;
}
.smart-timeline-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.smart-timeline-row-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.smart-timeline-status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--color-surface-alt, #f4f4f4);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 600;
flex: 0 0 auto;
}
.smart-timeline-row--done .smart-timeline-status-icon {
background: var(--hlc-lime, #c6f41c);
color: #1a1a1a;
}
.smart-timeline-row--overdue .smart-timeline-status-icon {
background: #f8d7da;
color: #842029;
}
.smart-timeline-row--off_script .smart-timeline-status-icon {
background: #fff3cd;
color: #664d03;
}
.smart-timeline-row--court_set .smart-timeline-status-icon {
background: transparent;
border: 1px dashed var(--color-text-muted);
}
.smart-timeline-title {
font-weight: 600;
font-size: 0.92rem;
flex: 1 1 auto;
min-width: 0;
}
.smart-timeline-link {
color: inherit;
text-decoration: none;
}
.smart-timeline-link:hover {
color: var(--color-accent-fg);
text-decoration: underline;
}
.smart-timeline-link:focus-visible {
outline: none;
}
.smart-timeline-kind-chip,
.smart-timeline-rule-chip {
flex: 0 0 auto;
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-surface-alt, #f4f4f4);
color: var(--color-text-muted);
font-weight: 500;
letter-spacing: 0.02em;
}
.smart-timeline-kind-chip--deadline {
background: #e0ecff;
color: #1a4a8a;
}
.smart-timeline-kind-chip--appointment {
background: #e7f5ee;
color: #2c6b46;
}
.smart-timeline-kind-chip--milestone {
background: #fdecd2;
color: #7a4f15;
}
.smart-timeline-kind-chip--projected {
background: var(--color-surface-alt, #f4f4f4);
color: var(--color-text-muted);
font-style: italic;
}
.smart-timeline-rule-chip {
font-family: var(--font-mono);
}
.smart-timeline-desc {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-top: 0.2rem;
}
/* Modal — minimal scrim + centred card. The new-entry CTA opens this;
"Eigener Meilenstein" expands the form inline, every other choice
is a plain link / disabled button. */
.smart-timeline-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.smart-timeline-modal-card {
background: var(--color-surface);
border-radius: var(--radius);
padding: 1.5rem;
max-width: 480px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
}
.smart-timeline-modal-card h3 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
.smart-timeline-add-choices {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.smart-timeline-add-choice {
display: block;
text-align: left;
padding: 0.6rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: inherit;
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
}
.smart-timeline-add-choice:hover:not(:disabled) {
border-color: var(--color-accent-fg);
background: var(--color-surface-alt, #fafafa);
}
.smart-timeline-add-choice--primary {
border-color: var(--hlc-lime, var(--color-accent-fg));
font-weight: 600;
}
.smart-timeline-add-choice--disabled,
.smart-timeline-add-choice:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.smart-timeline-modal-close-row {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
}
/* ----------------------------------------------------------------------
SmartTimeline Slice 2 (t-paliad-173) — projected rows, depends-on
footer, click-to-anchor inline editor, lookahead toggle.
---------------------------------------------------------------------- */
/* Predicted future rows fade so the eye reads "not yet real". */
.smart-timeline-row--projected {
opacity: 0.78;
}
.smart-timeline-row--projected .smart-timeline-status-icon,
.smart-timeline-row--predicted .smart-timeline-status-icon {
color: var(--color-text-muted, #777);
}
/* Court-set rows: dashed border on the left rail to read "the court
binds the date, not us". */
.smart-timeline-row--court_set {
border-left: 2px dashed var(--color-border-strong, #aaa);
padding-left: 0.5rem;
}
/* Predicted-overdue rows: amber-faded so the user notices the projection
should have happened by now. */
.smart-timeline-row--predicted_overdue {
opacity: 0.85;
border-left: 2px solid var(--color-status-amber, #d68a1a);
padding-left: 0.5rem;
}
.smart-timeline-row--predicted_overdue .smart-timeline-status-icon {
color: var(--color-status-amber, #d68a1a);
}
/* Status pill on projected rows — small, low-key, sits next to the kind
chip. */
.smart-timeline-status-pill {
display: inline-block;
padding: 0.1rem 0.5rem;
margin-left: 0.4rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 500;
background: var(--color-surface-2);
color: var(--color-text-muted, #555);
line-height: 1.3;
}
.smart-timeline-status-pill--court_set {
border: 1px dashed var(--color-border-strong, #999);
background: transparent;
}
.smart-timeline-status-pill--predicted_overdue {
background: var(--color-bg-amber-tint, #fff5e0);
color: var(--color-status-amber, #b56a00);
}
/* Depends-on footer — quiet line under the row title, "Folgt aus: …". */
.smart-timeline-depends-on {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.85rem;
color: var(--color-text-muted, #555);
}
.smart-timeline-depends-on-expand {
background: transparent;
border: none;
padding: 0;
color: var(--color-link, #1a6dc5);
cursor: pointer;
font-size: 0.8rem;
text-decoration: underline dotted;
}
.smart-timeline-depends-on-expand:hover {
text-decoration: underline;
}
.smart-timeline-depends-on-path {
flex-basis: 100%;
margin-top: 0.2rem;
padding-left: 0.75rem;
border-left: 2px solid var(--color-border, #ddd);
font-size: 0.8rem;
}
.smart-timeline-depends-on-hint {
font-style: italic;
color: var(--color-text-muted, #777);
}
/* Click-to-anchor — editor lives inline under the row body. The trigger
is a low-emphasis link button; the editor flips into a small flex row
with a date input + Speichern / Abbrechen. */
.smart-timeline-anchor {
margin-top: 0.4rem;
}
.smart-timeline-anchor-btn {
background: transparent;
border: 1px solid var(--color-border, #ddd);
color: var(--color-link, #1a6dc5);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.85rem;
cursor: pointer;
}
.smart-timeline-anchor-btn:hover {
background: var(--color-bg-lime-tint, #f4fdd1);
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-anchor-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.smart-timeline-anchor-date {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 4px;
font-size: 0.9rem;
}
.smart-timeline-anchor-submit {
background: var(--color-accent, #c6f41c);
border: 1px solid var(--color-accent, #c6f41c);
color: var(--color-text, #333);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
}
.smart-timeline-anchor-submit:disabled,
.smart-timeline-anchor-cancel:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.smart-timeline-anchor-cancel {
background: transparent;
border: 1px solid var(--color-border, #ddd);
color: var(--color-text-muted, #555);
padding: 0.25rem 0.75rem;
border-radius: 4px;
cursor: pointer;
}
.smart-timeline-anchor-msg {
flex-basis: 100%;
font-size: 0.85rem;
color: var(--color-text-muted, #555);
}
.smart-timeline-anchor-msg--error {
color: var(--status-red-fg, #b03030);
}
.smart-timeline-anchor-msg--predecessor {
background: var(--status-red-bg, #fde8e8);
border: 1px solid var(--status-red-border, #f0bcbc);
border-radius: 4px;
padding: 0.5rem 0.75rem;
}
.smart-timeline-anchor-msg--predecessor p {
margin: 0 0 0.4rem 0;
}
.smart-timeline-anchor-predecessor-link {
background: transparent;
border: none;
padding: 0;
color: var(--color-link, #1a6dc5);
cursor: pointer;
text-decoration: underline;
font-size: 0.85rem;
}
/* Lookahead toggle row — small, centred under the future section. */
.smart-timeline-lookahead {
display: flex;
justify-content: center;
gap: 0.75rem;
margin: 0.75rem 0;
}
.smart-timeline-lookahead-btn {
background: transparent;
border: 1px dashed var(--color-border, #ccc);
color: var(--color-text-muted, #555);
padding: 0.3rem 0.85rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.85rem;
}
.smart-timeline-lookahead-btn:hover {
border-style: solid;
border-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f4fdd1);
}
/* SmartTimeline Slice 3 — counterclaim parallel tracks (t-paliad-174).
.smart-timeline-tracks is the grid wrapper. Each .smart-timeline-track
is a self-contained column with its own past/today/future flow. CSS
Grid handles the side-by-side layout on desktop and collapses to a
single column on mobile (≤640px) per the existing Paliad breakpoint
convention. */
.smart-timeline-tracks {
display: grid;
grid-template-columns: repeat(var(--smart-timeline-track-count, 2), minmax(0, 1fr));
gap: 1.25rem;
align-items: start;
}
.smart-timeline-track {
border: 1px solid var(--color-border, #e0e0e0);
border-radius: 8px;
padding: 0.75rem 0.85rem 1rem;
background: var(--color-surface, #fff);
min-width: 0;
}
.smart-timeline-track--parent {
border-left: 3px solid var(--color-accent, #c6f41c);
}
.smart-timeline-track--counterclaim {
border-left: 3px solid var(--color-text-muted, #999);
background: var(--color-surface-2, #f7f7f7);
}
.smart-timeline-track--parent-context {
border-left: 3px dashed var(--color-text-muted, #999);
background: var(--color-surface-2, #f7f7f7);
opacity: 0.85;
}
.smart-timeline-track-header {
margin: 0 0 0.5rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-muted, #555);
border-bottom: 1px solid var(--color-border, #e0e0e0);
padding-bottom: 0.4rem;
}
.smart-timeline-track-empty {
color: var(--color-text-muted, #888);
font-size: 0.85rem;
padding: 0.75rem 0;
font-style: italic;
}
@media (max-width: 640px) {
.smart-timeline-tracks {
grid-template-columns: 1fr;
}
}
/* Track-selector chip — sits above the timeline. Style follows the
existing chip-row affordances. */
.smart-timeline-track-chip {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 0.75rem;
flex-wrap: wrap;
}
.smart-timeline-track-chip-label {
font-size: 0.85rem;
color: var(--color-text-muted, #555);
font-weight: 500;
}
.smart-timeline-track-chip-select {
border: 1px solid var(--color-border, #ccc);
background: var(--color-surface, #fff);
border-radius: 999px;
padding: 0.25rem 0.65rem;
font-size: 0.85rem;
cursor: pointer;
}
.smart-timeline-track-chip-select:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 1px;
}
/* SmartTimeline Slice 4 — parent-node lane aggregation (t-paliad-175).
.smart-timeline-lanes is the grid wrapper; .smart-timeline-lane is
each direct-child column. Layout mirrors .smart-timeline-tracks but
carries its own modifier so the visual treatment can diverge as the
product evolves (lane widths can become richer with sub-headers).
Mobile collapse to single-column at ≤640px. */
.smart-timeline-lanes-wrap {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.smart-timeline-lanes {
display: grid;
grid-template-columns: repeat(var(--smart-timeline-lane-count, 2), minmax(0, 1fr));
gap: 1rem;
align-items: start;
}
.smart-timeline-lane {
border: 1px solid var(--color-border, #e0e0e0);
border-radius: 8px;
padding: 0.75rem 0.85rem 1rem;
background: var(--color-surface, #fff);
min-width: 0;
transition: opacity 120ms ease-out;
}
.smart-timeline-lane--primary {
border-left: 3px solid var(--color-accent, #c6f41c);
}
.smart-timeline-lane--dimmed {
opacity: 0.35;
}
.smart-timeline-lane-header {
margin: 0 0 0.5rem;
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text-muted, #555);
border-bottom: 1px solid var(--color-border, #e0e0e0);
padding-bottom: 0.4rem;
}
.smart-timeline-lane-header-link {
color: inherit;
text-decoration: none;
}
.smart-timeline-lane-header-link:hover {
color: var(--color-link, #1a8aff);
text-decoration: underline;
}
.smart-timeline-lane-empty {
color: var(--color-text-muted, #888);
font-size: 0.85rem;
padding: 0.75rem 0;
font-style: italic;
}
@media (max-width: 640px) {
.smart-timeline-lanes {
grid-template-columns: 1fr;
}
}
/* Lane filter chip-row — multiselect chips above the strip. Mirrors the
FilterBar chip pattern; "Alle" pseudo-chip is highlighted when every
lane is selected. */
.smart-timeline-lane-filter {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
margin: 0.25rem 0 0.5rem;
}
.smart-timeline-lane-filter-label {
font-size: 0.85rem;
color: var(--color-text-muted, #555);
font-weight: 500;
}
.smart-timeline-lane-chip {
border: 1px solid var(--color-border, #ccc);
background: var(--color-surface, #fff);
color: var(--color-text, #222);
border-radius: 999px;
padding: 0.2rem 0.7rem;
font-size: 0.8rem;
cursor: pointer;
transition: background 120ms ease-out, border-color 120ms ease-out;
}
.smart-timeline-lane-chip:hover {
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-lane-chip.is-active {
background: var(--color-bg-lime-tint, #f4fdd1);
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-lane-chip--all {
font-weight: 500;
}
/* Client-level matter-list (Slice 4 default at type=client). Simple
list, slot for each direct child litigation. The Timeline-Ansicht
toggle in the Verlauf controls flips between this and the lane view. */
.smart-timeline-matter-list {
border: 1px solid var(--color-border, #e0e0e0);
border-radius: 8px;
padding: 1rem 1.25rem;
background: var(--color-surface, #fff);
}
.smart-timeline-matter-list-heading {
margin: 0 0 0.4rem;
font-size: 1rem;
font-weight: 600;
}
.smart-timeline-matter-list-items {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.smart-timeline-matter-list-item {
padding: 0.4rem 0.6rem;
border-radius: 6px;
background: var(--color-surface-2, #f7f7f7);
}
.smart-timeline-matter-list-item a {
color: inherit;
text-decoration: none;
display: block;
}
.smart-timeline-matter-list-item a:hover {
text-decoration: underline;
}
/* Counterclaim form layout follow-ups — inherits .entity-form, just
tightens the optional checkbox row + hint. */
.form-field--checkbox {
align-items: flex-start;
}
.form-field--checkbox .form-checkbox {
display: flex;
align-items: center;
gap: 0.45rem;
cursor: pointer;
}
.form-field--checkbox .form-field-hint {
color: var(--color-text-muted, #777);
font-size: 0.8rem;
margin-top: 0.35rem;
display: block;
line-height: 1.4;
}

View File

@@ -0,0 +1,6 @@
-- t-paliad-171 down — drop the SmartTimeline opt-in column.
DROP INDEX IF EXISTS paliad.project_events_timeline_kind_idx;
ALTER TABLE paliad.project_events
DROP COLUMN IF EXISTS timeline_kind;

View File

@@ -0,0 +1,32 @@
-- t-paliad-171 — SmartTimeline Slice 1.
-- Add the `timeline_kind` opt-in column to paliad.project_events so a
-- subset of audit rows can surface as timeline content. Existing rows
-- stay NULL (audit-only) and are filtered out of the SmartTimeline
-- read path; new write paths (custom milestone, counterclaim_created
-- in later slices) set the column on insert.
--
-- Value space (enforced in code, not via CHECK — see
-- internal/services/projection_service.go):
-- 'milestone' — structural event worth pinning to the timeline
-- (counterclaim_filed, third_party_intervened,
-- party_amendment, our_side_changed, scope_change)
-- 'custom_milestone' — free-text user-added event ("Eigener Meilenstein")
-- NULL — audit only (default, all existing rows)
--
-- Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
ALTER TABLE paliad.project_events
ADD COLUMN IF NOT EXISTS timeline_kind text NULL;
COMMENT ON COLUMN paliad.project_events.timeline_kind IS
'When non-NULL, this audit event also surfaces as a SmartTimeline '
'milestone. NULL keeps the row audit-only. See '
'internal/services/projection_service.go for the value space.';
-- Partial index — the SmartTimeline read path filters on
-- (project_id, timeline_kind IS NOT NULL); making the index partial
-- keeps it tiny (most rows stay audit-only) while still serving the
-- common lookup.
CREATE INDEX IF NOT EXISTS project_events_timeline_kind_idx
ON paliad.project_events (project_id, timeline_kind)
WHERE timeline_kind IS NOT NULL;

View File

@@ -0,0 +1,7 @@
-- t-paliad-173 down — reverses 076_smart_timeline_slice_2.up.sql.
ALTER TABLE paliad.deadlines DROP CONSTRAINT IF EXISTS deadlines_source_check;
DROP INDEX IF EXISTS paliad.appointments_deadline_rule_id_idx;
ALTER TABLE paliad.appointments DROP COLUMN IF EXISTS deadline_rule_id;

View File

@@ -0,0 +1,57 @@
-- t-paliad-173 — SmartTimeline Slice 2.
-- Two structural additions for click-to-anchor (§6 of
-- docs/design-smart-timeline-2026-05-08.md) + the layered SoC→SoD
-- sequence enforcement from m/paliad#31:
--
-- 1. paliad.appointments.deadline_rule_id — nullable FK to
-- paliad.deadline_rules. Court-set rules (Hauptverhandlung,
-- Decision, Order) anchor as appointments rather than deadlines
-- and need to remember which rule they came from so downstream
-- reflow has the parent_id chain.
--
-- 2. paliad.deadlines.source CHECK — adds 'anchor' alongside
-- the existing 'manual' / 'fristenrechner' values + the two
-- legacy values the design doc mentions ('rule', 'import') for
-- forward-compat. 'anchor' separates a click-to-anchor write from
-- a user-typed-it-in 'manual' write so analytics + a future
-- Outlook-import path can tell them apart.
--
-- paliad.project_events.event_type is intentionally NOT constrained —
-- the column is free-text in prod (every event_type today lives in
-- code, not in a CHECK). Slice 2 needs to write 'rule_skipped' rows
-- (§6.4); no schema change is required for that.
--
-- Idempotent: re-applying is a no-op. Tracker advances 75 → 76.
-- 1. paliad.appointments.deadline_rule_id ----------------------------------
ALTER TABLE paliad.appointments
ADD COLUMN IF NOT EXISTS deadline_rule_id uuid NULL
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.appointments.deadline_rule_id IS
'When non-NULL, this appointment is the actual occurrence of a '
'standard-course rule (Hauptverhandlung, Decision, Order). '
'Anchors downstream re-projection via FristenrechnerService '
'AnchorOverrides. See docs/design-smart-timeline-2026-05-08.md §6.';
CREATE INDEX IF NOT EXISTS appointments_deadline_rule_id_idx
ON paliad.appointments (deadline_rule_id)
WHERE deadline_rule_id IS NOT NULL;
-- 2. paliad.deadlines.source CHECK -----------------------------------------
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'deadlines_source_check'
AND conrelid = 'paliad.deadlines'::regclass
) THEN
ALTER TABLE paliad.deadlines DROP CONSTRAINT deadlines_source_check;
END IF;
END $$;
ALTER TABLE paliad.deadlines
ADD CONSTRAINT deadlines_source_check
CHECK (source IN ('manual', 'fristenrechner', 'rule', 'import', 'anchor'));

View File

@@ -0,0 +1,9 @@
-- t-paliad-174 — revert SmartTimeline Slice 3 schema.
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
DROP FUNCTION IF EXISTS paliad.projects_no_two_level_ccr();
DROP INDEX IF EXISTS paliad.projects_counterclaim_of_idx;
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS counterclaim_of;

View File

@@ -0,0 +1,89 @@
-- t-paliad-174 — SmartTimeline Slice 3.
-- Two structural additions for the counterclaim sub-project shape
-- (§4 of docs/design-smart-timeline-2026-05-08.md):
--
-- 1. paliad.projects.counterclaim_of — nullable FK referencing
-- paliad.projects(id) ON DELETE SET NULL. When non-NULL the row
-- represents the CCR (counterclaim) sub-project filed against the
-- target row. Standard parent_id keeps governing the project tree;
-- counterclaim_of is the *additional* relation describing the CCR
-- link. parent_id of the CCR child is set to the target's parent
-- (sibling-under-patent placement, §4.4) — that placement is owned
-- by ProjectService.CreateCounterclaim, not the schema.
--
-- 2. Two-level-CCR rejection trigger — UPC practice does NOT have
-- counterclaim-of-a-counterclaim chains. Reject the malformed shape
-- at the schema level so the application can never write it. CHECK
-- can't reference other rows; trigger function raises explicitly.
--
-- Idempotent: re-applying is a no-op. Tracker advances 76 → 77.
-- 1. paliad.projects.counterclaim_of ---------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN IF NOT EXISTS counterclaim_of uuid NULL
REFERENCES paliad.projects(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.projects.counterclaim_of IS
'When non-NULL this project is the CCR (counterclaim) filed against '
'the referenced parent project. parent_id continues to govern the '
'project tree (CCR is placed as a sibling under the same patent — '
'see ProjectService.CreateCounterclaim). ON DELETE SET NULL keeps '
'the CCR row alive when the parent is hard-deleted (rare; default '
'is archival) so the audit trail survives.';
CREATE INDEX IF NOT EXISTS projects_counterclaim_of_idx
ON paliad.projects (counterclaim_of)
WHERE counterclaim_of IS NOT NULL;
-- 2. Two-level-CCR rejection trigger ---------------------------------------
CREATE OR REPLACE FUNCTION paliad.projects_no_two_level_ccr() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
-- A project that is itself a CCR may NOT be the target of another CCR.
-- Two cases to reject:
--
-- (a) NEW row points at a parent that is itself a CCR:
-- NEW.counterclaim_of -> some row with counterclaim_of NOT NULL.
--
-- (b) NEW row claims to be a CCR (NEW.counterclaim_of IS NOT NULL)
-- but already has another CCR pointing AT it (NEW.id is the
-- target of some other row's counterclaim_of). The cleaner
-- phrasing: "no row may simultaneously have a CCR child AND
-- a CCR parent".
IF NEW.counterclaim_of IS NOT NULL THEN
IF EXISTS (
SELECT 1 FROM paliad.projects p
WHERE p.id = NEW.counterclaim_of
AND p.counterclaim_of IS NOT NULL
) THEN
RAISE EXCEPTION
'two-level counterclaim chains are not allowed: parent project % is itself a counterclaim',
NEW.counterclaim_of;
END IF;
IF EXISTS (
SELECT 1 FROM paliad.projects p
WHERE p.counterclaim_of = NEW.id
) THEN
RAISE EXCEPTION
'project % already has a counterclaim child and cannot itself be a counterclaim',
NEW.id;
END IF;
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION paliad.projects_no_two_level_ccr() IS
'Rejects two-level counterclaim chains. UPC practice does not have '
'CCR-of-a-CCR; reject the malformed shape at write time so the app '
'layer never has to defend against it. See migration 077.';
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
CREATE TRIGGER projects_no_two_level_ccr
BEFORE INSERT OR UPDATE OF counterclaim_of ON paliad.projects
FOR EACH ROW
EXECUTE FUNCTION paliad.projects_no_two_level_ccr();

View File

@@ -68,6 +68,7 @@ type Services struct {
Broadcast *services.BroadcastService
Pin *services.PinService
CardLayout *services.CardLayoutService
Projection *services.ProjectionService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
@@ -119,6 +120,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
broadcast: svc.Broadcast,
pin: svc.Pin,
cardLayout: svc.CardLayout,
projection: svc.Projection,
}
}
@@ -214,6 +216,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/projects/{id}", handleUpdateProject)
protected.HandleFunc("DELETE /api/projects/{id}", handleDeleteProject)
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
// t-paliad-171 / t-paliad-173 — SmartTimeline (Verlauf-tab redesign).
// /timeline returns the merged timeline (actuals + Slice 2 projections).
// /timeline/milestone is the "Eigener Meilenstein" write path.
// /timeline/anchor is the click-to-anchor write (Slice 2).
// /timeline/skip is the "ist nicht eingetreten" decision (§6.4).
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren)
protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree)
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)

View File

@@ -0,0 +1,393 @@
package handlers
// HTTP surface for the SmartTimeline (t-paliad-171, design doc
// docs/design-smart-timeline-2026-05-08.md). Two endpoints:
//
// GET /api/projects/{id}/timeline — read the merged timeline
// POST /api/projects/{id}/timeline/milestone — write a custom milestone
//
// Both go through ProjectionService, which delegates visibility + RLS
// to DeadlineService / AppointmentService and enforces the project_events
// gate inline. No new RLS surface here.
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/timeline
//
// Query parameters:
//
// ?include=audit_full — when present, project_events are returned
// without the timeline_kind filter (legacy
// Verlauf chronological view, behind the
// "Audit-Log anzeigen" toggle).
// ?direct_only=1|true — narrow to events whose project_id exactly
// matches; default is project + descendants.
func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
q := r.URL.Query()
opts := services.ProjectionOpts{
IncludeAuditFull: q.Get("include") == "audit_full",
DirectOnly: parseDirectOnly(q.Get("direct_only")),
LookaheadCap: parseLookahead(q.Get("lookahead")),
Lang: q.Get("lang"),
}
rows, meta, err := dbSvc.projection.For(r.Context(), uid, id, opts)
if err != nil {
writeServiceError(w, err)
return
}
// Always return [], never null — the frontend reads .length on the
// result and would crash on a JSON null.
if rows == nil {
rows = []services.TimelineEvent{}
}
lanes := meta.Lanes
if lanes == nil {
lanes = []services.LaneInfo{}
}
// Surface projection meta via headers — Slice 1-3 frontends still
// read X-Projection-Total / Lookahead / Tracks for the lookahead
// toggle and Track chip.
w.Header().Set("X-Projection-Has", boolStr(meta.HasProjection))
w.Header().Set("X-Projection-Total", itoa(meta.ProjectedTotal))
w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue))
w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead))
if len(meta.AvailableTracks) > 0 {
// Comma-separated list of track tags ("parent", "counterclaim:<id>",
// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
}
// Slice 4 changed the wire shape from []TimelineEvent to an envelope
// {events, lanes} so lane metadata can ride alongside the rows
// without exceeding header-size limits when a Client-level
// projection has many lanes. The frontend reads .events for the
// per-row contract and .lanes for parallel-column rendering.
writeJSON(w, http.StatusOK, services.ResponseEnvelope{
Events: rows,
Lanes: lanes,
})
}
// POST /api/projects/{id}/timeline/anchor
//
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
//
// 200 → AnchorResult JSON.
// 409 → predecessor_missing payload (m/paliad#31 layer 3 sequence guard).
// The frontend renders the message in the active language as an
// inline error and offers a "Stattdessen <predecessor> erfassen"
// link that pre-fills the editor for the parent rule.
func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
RuleCode string `json:"rule_code"`
ActualDate string `json:"actual_date"`
Kind string `json:"kind,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
d, err := time.Parse("2006-01-02", body.ActualDate)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid actual_date — expected YYYY-MM-DD",
})
return
}
res, err := dbSvc.projection.RecordAnchor(r.Context(), uid, id, services.AnchorInput{
RuleCode: body.RuleCode,
ActualDate: d,
Kind: body.Kind,
})
if err != nil {
if pme, ok := services.IsPredecessorMissing(err); ok {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "predecessor_missing",
"missing_rule_code": pme.MissingRuleCode,
"missing_rule_name_de": pme.MissingRuleNameDE,
"missing_rule_name_en": pme.MissingRuleNameEN,
"requested_rule_code": pme.RequestedRuleCode,
"requested_rule_name_de": pme.RequestedRuleNameDE,
"requested_rule_name_en": pme.RequestedRuleNameEN,
"message_de": "Bitte zuerst „" + pme.MissingRuleNameDE +
"“ (" + pme.MissingRuleCode + ") erfassen — daraus folgt die Frist „" +
pme.RequestedRuleNameDE + "“.",
"message_en": "Anchor „" + pme.MissingRuleNameEN +
"“ (" + pme.MissingRuleCode + ") first — „" +
pme.RequestedRuleNameEN + "“ flows from it.",
})
return
}
writeServiceError(w, err)
return
}
out := map[string]any{"updated": res.Updated}
if res.DeadlineID != nil {
out["deadline_id"] = res.DeadlineID.String()
out["kind"] = "deadline"
}
if res.AppointmentID != nil {
out["appointment_id"] = res.AppointmentID.String()
out["kind"] = "appointment"
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/projects/{id}/timeline/skip
//
// Body: {"rule_code":"inf.prelim","reason":"Beklagter hat keinen PO eingelegt"}
//
// Marks the rule as "ist nicht eingetreten / wurde verschoben" — the
// projected row drops out of future reads until the user clears the
// rule_skipped event (admin / audit-log path).
func handleProjectTimelineSkip(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
RuleCode string `json:"rule_code"`
Reason string `json:"reason,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
if err := dbSvc.projection.RecordRuleSkipped(r.Context(), uid, id, body.RuleCode, body.Reason); err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
// parseLookahead reads the ?lookahead=N query parameter; clamps to
// [1, MaxLookaheadCap] in the service. Returns 0 to mean "default" when
// the parameter is missing or malformed.
func parseLookahead(s string) int {
if s == "" {
return 0
}
n := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
if n > 1000 {
return 1000
}
}
return n
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// POST /api/projects/{id}/counterclaim
//
// Body: {
// "proceeding_type_id": 9, // optional, defaults to UPC_REV
// "flip_our_side": false, // optional, default-flip otherwise
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
// "case_number": "ACT_xxx_2026" // optional CCR case number
// }
//
// Creates the CCR sub-project, writes audit rows on parent + child,
// returns the new project's id + canonical URL.
func handleCreateProjectCounterclaim(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
parentID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
FlipOurSide *bool `json:"flip_our_side,omitempty"`
Title *string `json:"title,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
}
// Empty body is fine — full default behaviour.
if r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
}
opts := services.CounterclaimOpts{
ProceedingTypeID: body.ProceedingTypeID,
FlipOurSide: body.FlipOurSide,
Title: body.Title,
CaseNumber: body.CaseNumber,
}
child, err := dbSvc.projects.CreateCounterclaim(r.Context(), uid, parentID, opts)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"id": child.ID,
"url": "/projects/" + child.ID.String(),
"counterclaim_of": child.CounterclaimOf,
"parent_id": child.ParentID,
"title": child.Title,
"our_side": child.OurSide,
"proceeding_type": child.ProceedingTypeID,
"case_number": child.CaseNumber,
})
}
// POST /api/projects/{id}/timeline/milestone
//
// Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"}
//
// Writes a paliad.project_events row with event_type='custom_milestone'
// and timeline_kind='custom_milestone'. Returns the resulting
// TimelineEvent so the caller can append it without a re-fetch.
func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.projection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "projection service unavailable",
})
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
var body struct {
Title string `json:"title"`
Description *string `json:"description,omitempty"`
OccurredAt *string `json:"occurred_at,omitempty"`
BubbleUp bool `json:"bubble_up,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
var occurred *time.Time
if body.OccurredAt != nil && *body.OccurredAt != "" {
t, err := time.Parse("2006-01-02", *body.OccurredAt)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "invalid occurred_at — expected YYYY-MM-DD",
})
return
}
occurred = &t
}
ev, err := dbSvc.projection.RecordCustomMilestone(r.Context(), uid, id,
body.Title, body.Description, occurred, body.BubbleUp)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, ev)
}

View File

@@ -49,6 +49,7 @@ type dbServices struct {
broadcast *services.BroadcastService
pin *services.PinService
cardLayout *services.CardLayoutService
projection *services.ProjectionService
}
var dbSvc *dbServices

View File

@@ -163,6 +163,14 @@ type Project struct {
// claimant, defendant, court, both.
OurSide *string `db:"our_side" json:"our_side,omitempty"`
// CounterclaimOf is the parent project this row is a counterclaim
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
// regular projects; non-NULL rows are CCR sub-projects rendered as
// the parallel right-track on the parent's SmartTimeline. parent_id
// keeps governing the project tree — the CCR child is placed as a
// sibling under the same patent (§4.4 of the design doc).
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
Metadata json.RawMessage `db:"metadata" json:"metadata"`
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`

View File

@@ -114,14 +114,15 @@ type TimeSpec struct {
type TimeHorizon string
const (
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
HorizonNext7d TimeHorizon = "next_7d"
HorizonNext30d TimeHorizon = "next_30d"
HorizonNext90d TimeHorizon = "next_90d"
HorizonPast7d TimeHorizon = "past_7d"
HorizonPast30d TimeHorizon = "past_30d"
HorizonPast90d TimeHorizon = "past_90d"
HorizonAny TimeHorizon = "any"
HorizonAll TimeHorizon = "all"
HorizonCustom TimeHorizon = "custom"
)
type TimeField string
@@ -279,7 +280,7 @@ func (s *ScopeSpec) validate() error {
func (t *TimeSpec) validate(scope ScopeSpec) error {
switch t.Horizon {
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
HorizonPast30d, HorizonPast90d, HorizonAny:
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
// fine
case HorizonAll:
// Q26: reject "all" unless scope.projects is explicit. Performance

View File

@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number, matter_number,
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
proceeding_type_id, our_side, counterclaim_of, metadata, ai_summary, created_at, updated_at`
// CreateProjectInput is the payload for Create.
type CreateProjectInput struct {
@@ -122,6 +122,13 @@ type CreateProjectInput struct {
CaseNumber *string `json:"case_number,omitempty"`
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
OurSide *string `json:"our_side,omitempty"`
// CounterclaimOf marks this project as a CCR sub-project filed
// against the referenced parent project (t-paliad-174 Slice 3).
// Set by ProjectService.CreateCounterclaim — direct callers of
// Create rarely need it. The two-level-CCR rejection trigger
// (migration 077) will reject malformed shapes regardless.
CounterclaimOf *uuid.UUID `json:"counterclaim_of,omitempty"`
}
// UpdateProjectInput is the partial-update payload.
@@ -831,9 +838,10 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
(id, type, parent_id, path, title, reference, description, status,
created_by, industry, country, billing_reference, client_number,
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
$14, $15, $16, $17, $18, $19, $20, $21, $22, '{}'::jsonb, $23, $23)`,
id, input.Type, input.ParentID,
input.Title, input.Reference, input.Description, status,
userID,
@@ -842,6 +850,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
input.PatentNumber, input.FilingDate, input.GrantDate,
input.Court, input.CaseNumber, input.ProceedingTypeID,
nullableOurSide(input.OurSide),
input.CounterclaimOf,
now,
); err != nil {
return nil, fmt.Errorf("insert project: %w", err)
@@ -1096,6 +1105,268 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
return tx.Commit()
}
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
// patent reference is resolvable, else "<parent title> — Widerklage".
//
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
type CounterclaimOpts struct {
ProceedingTypeID *int
FlipOurSide *bool
Title *string
CaseNumber *string
}
// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
// against parentID that the caller can see. Each row is a normal
// paliad.projects row with counterclaim_of=parentID. Used by the
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.Project{}, nil
}
rows := []models.Project{}
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
WHERE p.counterclaim_of = $1
AND ` + visibilityPredicatePositional("p", 2) + `
ORDER BY p.created_at ASC, p.id ASC`
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
return nil, fmt.Errorf("load counterclaim children: %w", err)
}
return rows, nil
}
// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
// project + creator-as-lead team membership + audit rows on parent AND
// child are all written in a single transaction.
//
// Placement (§4.4): the CCR child is a sibling under the same patent —
// child.parent_id = parent.parent_id. When the parent has no parent_id
// (root case at the top of its tree) we fall back to parent.id as the
// CCR child's parent so the row remains in the same subtree.
//
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
// and "both" pass through unchanged. The opts.FlipOurSide override
// supports the rare R.49.2.b CCI shape where flipping is wrong.
//
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
// explicitly when they want it.
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
}
parent, err := s.GetByID(ctx, userID, parentID)
if err != nil {
return nil, err
}
if parent.CounterclaimOf != nil {
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
}
// Resolve proceeding_type_id default to UPC_REV when caller didn't
// override. The DB row is required because the projection layer
// dereferences it (paliad.proceeding_types.code).
procTypeID := 0
if opts.ProceedingTypeID != nil {
procTypeID = *opts.ProceedingTypeID
} else {
err := s.db.GetContext(ctx, &procTypeID,
`SELECT id FROM paliad.proceeding_types
WHERE code = 'UPC_REV' AND is_active = true`)
if err != nil {
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
}
}
childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
childParentID := parent.ParentID
if childParentID == nil {
// Parent has no parent_id (root case at the top of its tree).
// Fall back to parent.id so the CCR child stays in the same
// subtree rather than becoming a new root. The visibility
// predicate inherits cleanly either way.
fallback := parent.ID
childParentID = &fallback
}
// Resolve the best patent reference for the suggested title — when
// parent is a case, the patent_number lives on its patent ancestor.
patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
title := derivedCounterclaimTitle(parent, patentRef, opts.Title)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
court, case_number, proceeding_type_id, our_side, counterclaim_of,
metadata, created_at, updated_at)
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
id, childParentID, title, userID,
parent.Court, opts.CaseNumber, procTypeID,
nullableOurSide(&childOurSide), parentID, now,
); err != nil {
return nil, fmt.Errorf("insert counterclaim project: %w", err)
}
// Auto-add creator as team lead on the new CCR row so RLS lets the
// caller see the project they just made. Mirrors Create.
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
return nil, fmt.Errorf("insert creator team row: %w", err)
}
// Audit rows on both parent and child for symmetric trail. Both rows
// opt into the SmartTimeline via timeline_kind='milestone'. The
// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
// milestones surface on Patent / Litigation / Client SmartTimelines
// even though the level policy filters out other milestones.
if err := insertCounterclaimEvent(ctx, tx, id, userID,
"Widerklage (CCR) angelegt",
map[string]any{
"counterclaim_of": parentID.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
"Widerklage (CCR) angelegt",
map[string]any{
"counterclaim_id": id.String(),
"bubble_up": true,
},
); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit create counterclaim: %w", err)
}
return s.GetByID(ctx, userID, id)
}
// insertCounterclaimEvent writes a paliad.project_events row with
// event_type='counterclaim_created' AND timeline_kind='milestone' so
// the audit row surfaces on the SmartTimeline by default. Matches the
// pattern Slice 1 established for opt-in milestones (§2.2).
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title string, meta map[string]any) error {
now := time.Now().UTC()
metaJSON := json.RawMessage(`{}`)
if len(meta) > 0 {
b, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("marshal counterclaim_created metadata: %w", err)
}
metaJSON = b
}
_, err := tx.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
uuid.New(), projectID, title, now, userID, metaJSON)
if err != nil {
return fmt.Errorf("insert counterclaim_created event: %w", err)
}
return nil
}
// derivedCounterclaimOurSide computes the child's our_side from the
// parent's our_side and the opts.FlipOurSide override.
//
// Default (override nil OR override=true): claimant ↔ defendant, court
// and both pass through unchanged. NULL parent yields NULL child — the
// flip is meaningless without a known starting side.
//
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
// edge case where the CCR sub-project shares the parent's perspective.
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
if parentSide == nil {
return ""
}
side := strings.TrimSpace(*parentSide)
flip := true
if override != nil {
flip = *override
}
if !flip {
return side
}
switch side {
case "claimant":
return "defendant"
case "defendant":
return "claimant"
default:
return side
}
}
// resolvePatentReferenceForTitle returns the closest patent_number /
// reference to use as the CCR title prefix. Parent is usually a case
// row (no patent_number on it) — walks up ancestors to find the patent
// hub. Best-effort: returns empty when no patent ancestor is visible.
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
return strings.TrimSpace(*parent.PatentNumber)
}
ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
if err != nil || len(ancestors) == 0 {
return ""
}
for i := len(ancestors) - 1; i >= 0; i-- {
a := ancestors[i]
if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
return strings.TrimSpace(*a.PatentNumber)
}
}
return ""
}
// derivedCounterclaimTitle picks the auto-suggested title for the CCR
// child. Override wins when supplied; otherwise prefers the patent
// reference, then parent.reference, then parent.title — each yields
// "<ref> — Widerklage (CCR)".
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
if override != nil {
v := strings.TrimSpace(*override)
if v != "" {
return v
}
}
suffix := " — Widerklage (CCR)"
if patentRef != "" {
return patentRef + suffix
}
if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
return strings.TrimSpace(*parent.Reference) + suffix
}
return strings.TrimSpace(parent.Title) + suffix
}
// MaxEventsPageLimit caps ListEvents page size.
const MaxEventsPageLimit = 200

View File

@@ -0,0 +1,294 @@
package services
// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
// no DB required. Validates lookahead cap behaviour, anchor-kind
// dispatch, and extractMetadataString. The live integration test in
// projection_service_test.go covers SQL paths; this file covers the
// pure helpers a future refactor most likely to break.
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func TestApplyLookaheadDefault(t *testing.T) {
cases := []struct {
in, want int
}{
{0, DefaultLookaheadCap},
{-5, DefaultLookaheadCap},
{1, 1},
{7, 7},
{50, 50},
{51, MaxLookaheadCap},
{1000, MaxLookaheadCap},
}
for _, c := range cases {
if got := applyLookaheadDefault(c.in); got != c.want {
t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
}
}
}
func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// Two predicted_overdue (past) — must survive uncapped.
{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
// One court_set future — exempt from cap.
{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
// Three predicted future — cap=2 means the third drops.
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
}
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
if total != 3 {
t.Errorf("ProjectedTotal = %d, want 3", total)
}
if shown != 2 {
t.Errorf("ProjectedShown = %d, want 2", shown)
}
if overdue != 2 {
t.Errorf("PredictedOverdue = %d, want 2", overdue)
}
// kept must include both overdue + court_set + first 2 predicted = 5 rows.
if len(kept) != 5 {
t.Errorf("kept rows = %d, want 5", len(kept))
}
// Past + court_set must remain.
pastTitles := map[string]bool{}
for _, r := range kept {
pastTitles[r.Title] = true
}
for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
if !pastTitles[want] {
t.Errorf("expected kept row %q missing", want)
}
}
if pastTitles["Fut 3"] {
t.Errorf("Fut 3 should have been dropped (cap=2)")
}
}
func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
}
kept, total, shown, _ := applyLookaheadCap(rows, 7)
if total != 1 || shown != 1 {
t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
}
if len(kept) != 1 {
t.Errorf("kept = %d, want 1", len(kept))
}
}
func TestRuleAnchorKind(t *testing.T) {
hearing := "hearing"
decision := "decision"
order := "order"
filing := "filing"
cases := []struct {
name string
rule *models.DeadlineRule
want string
}{
{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
{"nil rule → deadline", nil, "deadline"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := ruleAnchorKind(c.rule); got != c.want {
t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
}
})
}
}
func TestExtractMetadataString(t *testing.T) {
cases := []struct {
name string
raw string
key string
want string
}{
{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
{"missing key", `{"foo":"bar"}`, "rule_code", ""},
{"empty json", `{}`, "rule_code", ""},
{"empty raw", ``, "rule_code", ""},
{"non-string value", `{"rule_code":123}`, "rule_code", ""},
{"malformed", `{`, "rule_code", ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractMetadataString(json.RawMessage(c.raw), c.key)
if got != c.want {
t.Errorf("extractMetadataString = %q, want %q", got, c.want)
}
})
}
}
func TestLang(t *testing.T) {
cases := []struct {
in, want string
}{
{"", "de"},
{"de", "de"},
{"DE", "de"},
{"en", "en"},
{"EN", "en"},
{" en ", "en"},
{"fr", "de"},
}
for _, c := range cases {
if got := lang(c.in); got != c.want {
t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestRuleNameInLang(t *testing.T) {
r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
t.Errorf("de = %q", got)
}
if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
t.Errorf("en = %q", got)
}
rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
t.Errorf("missing EN should fall back to DE, got %q", got)
}
}
func TestPredecessorMissingError(t *testing.T) {
pme := &PredecessorMissingError{
MissingRuleCode: "inf.soc",
MissingRuleNameDE: "Klageschrift",
MissingRuleNameEN: "Statement of Claim",
RequestedRuleCode: "inf.sod",
RequestedRuleNameDE: "Klageerwiderung",
RequestedRuleNameEN: "Statement of Defence",
}
got, ok := IsPredecessorMissing(pme)
if !ok {
t.Fatal("IsPredecessorMissing on direct error should return ok")
}
if got != pme {
t.Errorf("unwrapped pointer mismatch")
}
// Wrapping with errors.Errorf-style fmt should still unwrap.
wrapped := wrap(pme, "context")
got2, ok2 := IsPredecessorMissing(wrapped)
if !ok2 {
t.Fatal("IsPredecessorMissing on wrapped error should return ok")
}
if got2 != pme {
t.Errorf("unwrapped wrapped pointer mismatch")
}
// Random other error must not unwrap.
if _, ok := IsPredecessorMissing(errOther{}); ok {
t.Error("non-PME should not unwrap as PME")
}
}
// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
func wrap(err error, msg string) error {
return wrappedErr{msg: msg, inner: err}
}
type wrappedErr struct {
msg string
inner error
}
func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
func (w wrappedErr) Unwrap() error { return w.inner }
type errOther struct{}
func (errOther) Error() string { return "other" }
func TestAnnotateDependsOn(t *testing.T) {
socID := uuid.New()
sodID := uuid.New()
replyID := uuid.New()
socCode := "inf.soc"
sodCode := "inf.sod"
replyCode := "inf.reply"
rules := []models.DeadlineRule{
{ID: socID, Code: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
{ID: sodID, ParentID: &socID, Code: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
{ID: replyID, ParentID: &sodID, Code: &replyCode, Name: "Replik", NameEN: "Reply"},
}
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
// SoC actual.
{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
DeadlineRuleID: ptrUUID(socID)},
// SoD projected.
{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
DeadlineRuleID: ptrUUID(sodID)},
// Reply projected — depends on SoD.
{Kind: "projected", Status: "predicted", RuleCode: replyCode,
DeadlineRuleID: ptrUUID(replyID)},
}
svc := &ProjectionService{}
svc.annotateDependsOn(rows, rules, "de")
// SoC has no parent — depends_on stays empty.
if rows[0].DependsOnRuleCode != "" {
t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
}
// SoD's depends_on is SoC, dated.
if rows[1].DependsOnRuleCode != socCode {
t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
}
if rows[1].DependsOnRuleName != "Klageschrift" {
t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
}
if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
}
// Reply's depends_on is SoD, dated (from SoD's projected date).
if rows[2].DependsOnRuleCode != sodCode {
t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
}
if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
rows[2].DependsOnDate, sodDate)
}
// English mode flips the name.
svc.annotateDependsOn(rows, rules, "en")
if rows[1].DependsOnRuleName != "Statement of Claim" {
t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
}
}
func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }

View File

@@ -0,0 +1,302 @@
package services
// Live-DB integration test for the counterclaim sub-project shape
// (t-paliad-174 SmartTimeline Slice 3). Skipped without TEST_DATABASE_URL,
// matching the convention of the other live tests in this package.
//
// The test exercises the end-to-end shape:
// 1. CreateCounterclaim atomically creates child + flips our_side +
// writes audit rows on parent AND child + sets counterclaim_of.
// 2. parent_id of the child equals parent's parent_id (sibling-under-
// patent placement).
// 3. ProjectionService.For on the parent surfaces a parallel-track
// counterclaim event; AvailableTracks lists the new track.
// 4. ProjectionService.For on the child surfaces the parent's events
// with track="parent_context:<parent_id>".
// 5. Two-level CCR chains are rejected at the schema level.
import (
"context"
"errors"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestCreateCounterclaim_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
patentID := uuid.New() // sibling parent: the patent hub
caseID := uuid.New() // the parent case (UPC_INF)
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
// proceeding_types seed because they're NOT NULL on the test row.
var upcInf, upcRev int
if err := pool.GetContext(ctx, &upcInf,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
t.Fatalf("resolve UPC_INF: %v", err)
}
if err := pool.GetContext(ctx, &upcRev,
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
t.Fatalf("resolve UPC_REV: %v", err)
}
cleanup := func() {
// Delete CCR children first (FK to caseID via counterclaim_of is
// ON DELETE SET NULL but the child rows still hold parent_id =
// patentID — clear them via a parent_id sweep).
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, caseID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, patentID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'ccr-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'ccr-test@hlc.com', 'CCR Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Parent patent hub.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
VALUES ($1, 'patent', $1::text, 'EP3456789 — Test Patent', 'EP3456789', 'active', $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
// Child case (UPC_INF) under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
proceeding_type_id, our_side)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text,
'UPC-CFI München — Klage', 'active', $3, $4, 'claimant')`,
caseID, patentID, userID, upcInf); err != nil {
t.Fatalf("seed case: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
caseID, userID); err != nil {
t.Fatalf("seed case team: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fristen := NewFristenrechnerService(rules, holidays, courts)
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
t.Run("CreateCounterclaim flips our_side, places sibling, audits both", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// 1. counterclaim_of points at the parent.
if child.CounterclaimOf == nil || *child.CounterclaimOf != caseID {
t.Errorf("child.CounterclaimOf = %v, want %v", child.CounterclaimOf, caseID)
}
// 2. parent_id = parent's parent_id = patent hub (sibling-under-patent).
if child.ParentID == nil || *child.ParentID != patentID {
t.Errorf("child.ParentID = %v, want %v (sibling under patent)", child.ParentID, patentID)
}
// 3. our_side flipped: parent claimant → child defendant.
if child.OurSide == nil || *child.OurSide != "defendant" {
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
}
// 4. Default proceeding_type_id resolved to UPC_REV.
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
}
// 5. Auto-suggested title carries the patent reference + suffix.
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {
t.Errorf("child.Title = %q, want it to contain EP3456789 and Widerklage", child.Title)
}
// 6. Audit rows on BOTH parent and child with timeline_kind='milestone'.
var parentAudit, childAudit int
if err := pool.GetContext(ctx, &parentAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, caseID); err != nil {
t.Fatalf("count parent audit: %v", err)
}
if parentAudit != 1 {
t.Errorf("parent counterclaim_created rows = %d, want 1", parentAudit)
}
if err := pool.GetContext(ctx, &childAudit,
`SELECT count(*) FROM paliad.project_events
WHERE project_id = $1 AND event_type = 'counterclaim_created'
AND timeline_kind = 'milestone'`, child.ID); err != nil {
t.Fatalf("count child audit: %v", err)
}
if childAudit != 1 {
t.Errorf("child counterclaim_created rows = %d, want 1", childAudit)
}
})
t.Run("ProjectionService.For on parent surfaces counterclaim track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, caseID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For parent: %v", err)
}
// AvailableTracks contains parent + the new counterclaim track.
expectTrack := "counterclaim:" + child.ID.String()
var sawCounterclaimTrack bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawCounterclaimTrack = true
break
}
}
if !sawCounterclaimTrack {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
// At least one row carries the counterclaim track + the
// SubProjectID = child.ID.
var countCCR int
for _, r := range rows {
if r.Track == expectTrack {
countCCR++
if r.SubProjectID == nil || *r.SubProjectID != child.ID {
t.Errorf("ccr-track row missing SubProjectID = child.ID")
}
}
}
if countCCR == 0 {
t.Errorf("expected at least one row on counterclaim track, saw 0 (rows=%d)", len(rows))
}
})
t.Run("ProjectionService.For on child surfaces parent_context track", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
rows, meta, err := projection.For(ctx, userID, child.ID, ProjectionOpts{})
if err != nil {
t.Fatalf("projection.For child: %v", err)
}
expectTrack := "parent_context:" + caseID.String()
var sawParentContext bool
for _, t := range meta.AvailableTracks {
if t == expectTrack {
sawParentContext = true
break
}
}
if !sawParentContext {
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
}
var countParentCtx int
for _, r := range rows {
if r.Track == expectTrack {
countParentCtx++
if r.SubProjectID == nil || *r.SubProjectID != caseID {
t.Errorf("parent_context row missing SubProjectID = parent.ID")
}
}
}
if countParentCtx == 0 {
t.Errorf("expected at least one parent_context row, saw 0 (rows=%d)", len(rows))
}
})
t.Run("Two-level CCR chains are rejected at the schema level", func(t *testing.T) {
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
if err != nil {
t.Fatalf("CreateCounterclaim: %v", err)
}
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
// Trying to create a CCR against the CCR child = two-level chain.
// CreateCounterclaim guards with an early ErrInvalidInput before
// hitting the trigger; verify the early guard fires.
_, err = projects.CreateCounterclaim(ctx, userID, child.ID, CounterclaimOpts{})
if err == nil {
t.Fatal("expected error for two-level CCR chain, got nil")
}
if !errors.Is(err, ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
// Also pin the schema-level trigger guard: a direct INSERT
// pointing at a row that already has counterclaim_of NOT NULL
// must be rejected by paliad.projects_no_two_level_ccr.
grandchild := uuid.New()
_, err = pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by, counterclaim_of)
VALUES ($1, 'case', $2, $1::text, 'Grandchild CCR', 'active', $3, $4)`,
grandchild, patentID, userID, child.ID)
if err == nil {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, grandchild)
t.Fatal("expected schema trigger to reject grandchild CCR insert, got success")
}
if !strings.Contains(err.Error(), "two-level counterclaim") {
t.Errorf("trigger error message: %v (want two-level counterclaim)", err)
}
})
}

View File

@@ -0,0 +1,271 @@
package services
// Live-DB integration test for parent-node lane aggregation
// (t-paliad-175 SmartTimeline Slice 4 §5). Skipped without TEST_DATABASE_URL.
//
// Builds a 3-level fixture (Patent → Case-A + Case-B → CCR-A) and walks
// the level policy at each viewpoint:
//
// - Case-A view: full detail + CCR sub-project track (single project,
// own actuals + projection, "self" lane + "counterclaim:<id>" lane).
// - Patent view: lanes per child case; events from each case subtree;
// deadlines + milestones surface, statuses done/open/overdue.
// - Bubble-up: a counterclaim_created milestone (default-on bubble_up)
// surfaces at Patent level under Case-A's lane.
// - Custom milestone with bubble_up=true surfaces too; without, it's
// filtered out.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestProjectionService_LevelAggregation_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
patentID := uuid.New()
caseAID := uuid.New()
caseBID := uuid.New()
cleanup := func() {
// CCR children (counterclaim_of points at one of the cases)
// must go first so the FK doesn't block the case delete.
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of IN ($1, $2)`, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'level-agg-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'level-agg-test@hlc.com', 'Level Agg Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Patent hub.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
VALUES ($1, 'patent', $1::text, 'EP9999999 — Test Patent', 'EP9999999', 'active', $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
patentID, userID); err != nil {
t.Fatalf("seed patent team: %v", err)
}
// Case-A under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case A', 'active', $3)`,
caseAID, patentID, userID); err != nil {
t.Fatalf("seed case A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
caseAID, userID); err != nil {
t.Fatalf("seed case A team: %v", err)
}
// Case-B under the patent.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case B', 'active', $3)`,
caseBID, patentID, userID); err != nil {
t.Fatalf("seed case B: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
caseBID, userID); err != nil {
t.Fatalf("seed case B team: %v", err)
}
// Case-A: one open deadline + one done milestone (bubble_up=true via
// counterclaim_created event_type) + one custom_milestone (bubble_up=false).
now := time.Now().UTC()
deadlineA := uuid.New()
bubbledMilestoneA := uuid.New()
regularMilestoneA := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Case-A open deadline', $3::date, 'manual', 'pending', $4)`,
deadlineA, caseAID, now.AddDate(0, 0, 14).Format("2006-01-02"), userID); err != nil {
t.Fatalf("seed deadline A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'counterclaim_created', 'Widerklage angelegt', $3, $4,
'{"bubble_up":true}'::jsonb, $5, $5, 'milestone')`,
bubbledMilestoneA, caseAID, now.AddDate(0, 0, -7), userID, now); err != nil {
t.Fatalf("seed bubbled milestone A: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'Random Note (no bubble)', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
regularMilestoneA, caseAID, now.AddDate(0, 0, -3), userID, now); err != nil {
t.Fatalf("seed regular milestone A: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fristen := NewFristenrechnerService(rules, holidays, courts)
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
t.Run("Case-level: lanes mirror tracks (self + CCR)", func(t *testing.T) {
_, meta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
if err != nil {
t.Fatalf("For caseA: %v", err)
}
// At least the "self" lane is present.
var sawSelf bool
for _, l := range meta.Lanes {
if l.ID == "self" {
sawSelf = true
if l.Label != "Case A" {
t.Errorf("self lane label = %q, want Case A", l.Label)
}
}
}
if !sawSelf {
t.Errorf("Lanes = %v, want a 'self' entry", meta.Lanes)
}
})
t.Run("Patent-level: lanes per child case + milestones bubble", func(t *testing.T) {
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
if err != nil {
t.Fatalf("For patent: %v", err)
}
// Lanes: one per child case.
laneIDs := map[string]LaneInfo{}
for _, l := range meta.Lanes {
laneIDs[l.ID] = l
}
if _, ok := laneIDs[caseAID.String()]; !ok {
t.Errorf("Lanes missing Case-A entry: %v", meta.Lanes)
}
if _, ok := laneIDs[caseBID.String()]; !ok {
t.Errorf("Lanes missing Case-B entry: %v", meta.Lanes)
}
// Bubbled-up milestone (counterclaim_created) surfaces under
// Case-A's lane.
var sawBubbled, sawRegular, sawDeadline bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
sawBubbled = true
if r.LaneID != caseAID.String() {
t.Errorf("bubbled milestone LaneID = %q, want %s", r.LaneID, caseAID.String())
}
if !r.BubbleUp {
t.Errorf("bubbled milestone BubbleUp should be true")
}
}
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
sawRegular = true
}
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
sawDeadline = true
if r.LaneID != caseAID.String() {
t.Errorf("deadline LaneID = %q, want %s", r.LaneID, caseAID.String())
}
}
}
if !sawBubbled {
t.Errorf("bubbled milestone (counterclaim_created) should surface at Patent level")
}
// Patent policy = milestones + deadlines, statuses done/open/overdue.
// The pending deadline (status=open) survives; the regular custom
// milestone (off_script status, no bubble_up) is filtered out.
if !sawDeadline {
t.Errorf("Case-A's open deadline should surface at Patent level (kinds=deadline allowed)")
}
if sawRegular {
t.Errorf("regular custom_milestone (no bubble_up, off_script status) should be filtered at Patent level")
}
})
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
// Re-write the regular milestone with bubble_up=true and confirm
// it surfaces. Then revert.
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.project_events
SET metadata = '{"bubble_up":true}'::jsonb
WHERE id = $1`, regularMilestoneA); err != nil {
t.Fatalf("flip bubble_up: %v", err)
}
defer pool.ExecContext(ctx,
`UPDATE paliad.project_events SET metadata = '{}'::jsonb WHERE id = $1`,
regularMilestoneA)
rows, _, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
if err != nil {
t.Fatalf("For patent (after flip): %v", err)
}
var saw bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
saw = true
if !r.BubbleUp {
t.Errorf("flipped milestone BubbleUp should be true")
}
}
}
if !saw {
t.Errorf("custom_milestone with bubble_up=true should surface at Patent level")
}
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,257 @@
package services
// Live-DB integration test for ProjectionService — applies migrations,
// seeds one project + one deadline + one appointment + one
// timeline_kind-tagged project_event, and asserts the merge returns
// three rows in the right order. Skipped when TEST_DATABASE_URL is
// unset, mirroring the convention of the other live tests in this
// package.
import (
"context"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
userID := uuid.New()
projectID := uuid.New()
deadlineID := uuid.New()
apptID := uuid.New()
milestoneID := uuid.New()
auditOnlyID := uuid.New() // timeline_kind=NULL — must NOT surface in default read
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, apptID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id IN ($1, $2)`, milestoneID, auditOnlyID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'projection-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, 'projection-test@hlc.com', 'Projection Test', 'munich', 'global_admin', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
VALUES ($1, 'case', $1::text, 'Projection Test Project', '2026/9993', 'active', $2)`,
projectID, userID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
now := time.Now().UTC()
deadlineDate := now.AddDate(0, 0, 7) // a week from now
apptDate := now.AddDate(0, 0, 14) // two weeks from now
milestoneDate := now.AddDate(0, 0, -3) // three days ago
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Test Deadline', $3::date, 'manual', 'pending', $4)`,
deadlineID, projectID, deadlineDate.Format("2006-01-02"), userID); err != nil {
t.Fatalf("seed deadline: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.appointments
(id, project_id, title, start_at, appointment_type, created_by)
VALUES ($1, $2, 'Test Appointment', $3, 'meeting', $4)`,
apptID, projectID, apptDate, userID); err != nil {
t.Fatalf("seed appointment: %v", err)
}
// Two project_events: one with timeline_kind set (must surface), one
// without (must be filtered out unless include_audit_full).
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'Test Milestone', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
milestoneID, projectID, milestoneDate, userID, now); err != nil {
t.Fatalf("seed milestone project_event: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at)
VALUES ($1, $2, 'project_created', 'Audit-Only Event', $3, $4,
'{}'::jsonb, $5, $5)`,
auditOnlyID, projectID, milestoneDate.Add(-1*time.Hour), userID, now); err != nil {
t.Fatalf("seed audit-only project_event: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
eventTypes := NewEventTypeService(pool, users)
deadlines := NewDeadlineService(pool, projects, eventTypes)
appointments := NewAppointmentService(pool, projects)
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fristen := NewFristenrechnerService(rules, holidays, courts)
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
t.Run("default — only timeline_kind milestones surface", func(t *testing.T) {
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
if err != nil {
t.Fatalf("For: %v", err)
}
// Filter to seed rows so unrelated rows in the live DB don't
// confuse the assertions. We reference rows by provenance ID.
seen := map[string]TimelineEvent{}
for _, r := range rows {
switch {
case r.DeadlineID != nil && *r.DeadlineID == deadlineID:
seen["deadline"] = r
case r.AppointmentID != nil && *r.AppointmentID == apptID:
seen["appointment"] = r
case r.ProjectEventID != nil && *r.ProjectEventID == milestoneID:
seen["milestone"] = r
case r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID:
t.Errorf("audit-only project_event leaked into default read")
}
}
if len(seen) != 3 {
t.Fatalf("expected 3 seed rows, saw %d: %v", len(seen), seen)
}
// Sort order: milestone (3 days ago) → deadline (+7d) → appointment (+14d).
// Find the indices of our seeded rows in the result and check the
// relative ordering.
idx := func(id uuid.UUID) int {
for i, r := range rows {
switch {
case r.DeadlineID != nil && *r.DeadlineID == id:
return i
case r.AppointmentID != nil && *r.AppointmentID == id:
return i
case r.ProjectEventID != nil && *r.ProjectEventID == id:
return i
}
}
return -1
}
if !(idx(milestoneID) < idx(deadlineID) && idx(deadlineID) < idx(apptID)) {
t.Errorf("wrong sort: milestone=%d deadline=%d appt=%d (want asc)",
idx(milestoneID), idx(deadlineID), idx(apptID))
}
// Field shape — kind, status, deep-link IDs.
dl := seen["deadline"]
if dl.Kind != "deadline" {
t.Errorf("deadline.Kind = %q, want deadline", dl.Kind)
}
if dl.Status != "open" {
t.Errorf("deadline.Status = %q, want open (future date)", dl.Status)
}
if dl.Title != "Test Deadline" {
t.Errorf("deadline.Title = %q", dl.Title)
}
ap := seen["appointment"]
if ap.Kind != "appointment" || ap.Status != "open" {
t.Errorf("appointment kind/status = %q/%q", ap.Kind, ap.Status)
}
ms := seen["milestone"]
if ms.Kind != "milestone" || ms.Status != "off_script" {
t.Errorf("milestone kind/status = %q/%q (want milestone/off_script)",
ms.Kind, ms.Status)
}
})
t.Run("IncludeAuditFull — both project_events surface", func(t *testing.T) {
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{IncludeAuditFull: true})
if err != nil {
t.Fatalf("For audit_full: %v", err)
}
var sawAudit bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID {
sawAudit = true
break
}
}
if !sawAudit {
t.Errorf("audit-only project_event should surface with IncludeAuditFull=true")
}
})
t.Run("RecordCustomMilestone writes a row with timeline_kind set", func(t *testing.T) {
title := "Live-Test Custom Milestone"
desc := "from RecordCustomMilestone test"
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when, false)
if err != nil {
t.Fatalf("RecordCustomMilestone: %v", err)
}
if ev == nil || ev.ProjectEventID == nil {
t.Fatalf("RecordCustomMilestone returned nil id")
}
// Defer cleanup so the row doesn't leak into other tests.
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID)
// Verify the row landed with the expected discriminators.
var (
eventType string
timelineKind *string
)
if err := pool.QueryRowContext(ctx,
`SELECT event_type, timeline_kind FROM paliad.project_events WHERE id = $1`,
*ev.ProjectEventID).Scan(&eventType, &timelineKind); err != nil {
t.Fatalf("read back: %v", err)
}
if eventType != "custom_milestone" {
t.Errorf("event_type = %q, want custom_milestone", eventType)
}
if timelineKind == nil || *timelineKind != "custom_milestone" {
t.Errorf("timeline_kind = %v, want custom_milestone", timelineKind)
}
// And it must surface in the next read.
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
if err != nil {
t.Fatalf("For after milestone: %v", err)
}
var found bool
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == *ev.ProjectEventID {
found = true
break
}
}
if !found {
t.Errorf("newly recorded milestone did not surface in For()")
}
})
}

View File

@@ -0,0 +1,355 @@
package services
// Pure-function tests for ProjectionService — no DB required, runs by
// default. Validates the deterministic sort order and status-mapping
// behaviour; the live integration test in projection_service_test.go
// covers the SQL paths.
import (
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
)
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
d1 := uuid.New()
d2 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
rows := []TimelineEvent{
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
}
sortTimeline(rows)
// Date ASC (Mar1, Mar5, Mar10), undated last.
if rows[0].Title != "Mar1 deadline" {
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
}
if rows[1].Title != "Mar5 appointment" {
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
}
if rows[2].Title != "Mar10 deadline" {
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
}
if rows[3].Title != "Undated milestone" {
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
}
}
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
d1 := uuid.New()
a1 := uuid.New()
pe1 := uuid.New()
rows := []TimelineEvent{
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
}
sortTimeline(rows)
// Tiebreak: deadline > appointment > milestone (kindOrder).
if rows[0].Kind != "deadline" {
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
}
if rows[1].Kind != "appointment" {
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
}
if rows[2].Kind != "milestone" {
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
}
}
func TestDeadlineStatus(t *testing.T) {
today := time.Now().UTC()
yesterday := today.AddDate(0, 0, -1)
tomorrow := today.AddDate(0, 0, 1)
cases := []struct {
name string
status string
due time.Time
want string
}{
{"completed regardless of date", "completed", yesterday, "done"},
{"completed even if future", "completed", tomorrow, "done"},
{"pending past = overdue", "pending", yesterday, "overdue"},
{"pending today = open", "pending", today, "open"},
{"pending future = open", "pending", tomorrow, "open"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := deadlineStatus(c.status, c.due)
if got != c.want {
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
c.status, c.due, got, c.want)
}
})
}
}
func TestAppointmentStatus(t *testing.T) {
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
past := now.Add(-1 * time.Hour)
future := now.Add(1 * time.Hour)
if got := appointmentStatus(past, now); got != "done" {
t.Errorf("past appointment status = %q, want done", got)
}
if got := appointmentStatus(future, now); got != "open" {
t.Errorf("future appointment status = %q, want open", got)
}
}
func TestMilestoneStatus(t *testing.T) {
custom := "custom_milestone"
other := "counterclaim_filed"
cases := []struct {
name string
timelineKind *string
eventType *string
want string
}{
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
{"structural milestone = done", nil, &other, "done"},
{"both nil = done", nil, nil, "done"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := milestoneStatus(c.timelineKind, c.eventType)
if got != c.want {
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
}
})
}
}
func TestKindOrder(t *testing.T) {
// Lock the exact ordering — frontend assumes deadline before
// appointment before milestone before projected on same-date ties.
if kindOrder("deadline") >= kindOrder("appointment") {
t.Error("deadline should sort before appointment")
}
if kindOrder("appointment") >= kindOrder("milestone") {
t.Error("appointment should sort before milestone")
}
if kindOrder("milestone") >= kindOrder("projected") {
t.Error("milestone should sort before projected")
}
}
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
// These are user-visible policy decisions — locked here to catch
// accidental shifts during refactors.
func TestLevelPolicy(t *testing.T) {
cases := []struct {
projectType string
kinds []string
statuses []string
laneAxis string
}{
{"case", nil, nil, "self_plus_ccr"},
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
{"unknown", nil, nil, "self_plus_ccr"},
{
"patent",
[]string{"deadline", "milestone"},
[]string{"done", "open", "overdue"},
"child_case",
},
{
"litigation",
[]string{"milestone"},
[]string{"done"},
"child_patent",
},
{
"client",
[]string{"milestone"},
[]string{"done"},
"child_litigation",
},
}
for _, c := range cases {
t.Run(c.projectType, func(t *testing.T) {
got := levelPolicy(c.projectType)
if got.LaneAxis != c.laneAxis {
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
}
if !sliceEqual(got.Kinds, c.kinds) {
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
}
if !sliceEqual(got.Statuses, c.statuses) {
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
}
})
}
}
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
// a project_event milestone with bubble_up=true survives the level
// policy's kind/status filter at higher levels (design §5.3 + Q5).
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
allowKind := stringSet([]string{"deadline"}) // milestones excluded
allowStatus := stringSet([]string{"done"}) // off_script excluded
bubbledMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
BubbleUp: true,
}
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
t.Error("bubble_up=true row should survive both kind and status filters")
}
regularMilestone := TimelineEvent{
Kind: "milestone",
Status: "off_script",
}
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
t.Error("regular milestone should be filtered when kind/status both excluded")
}
// kind allowed, status excluded → drop.
allowedKindBadStatus := TimelineEvent{
Kind: "deadline",
Status: "open",
}
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
t.Error("excluded status should drop a row even when kind allowed")
}
// kind excluded, status allowed → drop.
badKindGoodStatus := TimelineEvent{
Kind: "appointment",
Status: "done",
}
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
t.Error("excluded kind should drop a row even when status allowed")
}
// Empty filters = pass-through.
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
t.Error("empty filters should pass everything")
}
}
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
// - counterclaim_created / third_party_intervention / scope_change
// default to true.
// - custom_milestone defaults to false.
// - Explicit metadata.bubble_up always wins.
func TestExtractBubbleUp(t *testing.T) {
str := func(s string) *string { return &s }
cases := []struct {
name string
raw string
eventType *string
timelineKind *string
want bool
}{
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
{"scope_change defaults true", "", str("scope_change"), nil, true},
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
{"unknown defaults false", "{}", str("note_created"), nil, false},
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
{"empty metadata + nil event_type = false", "", nil, nil, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
if got != c.want {
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
}
})
}
}
// TestChildTypeForAxis pins the axis → project type map.
func TestChildTypeForAxis(t *testing.T) {
cases := map[string]string{
"child_case": "case",
"child_patent": "patent",
"child_litigation": "litigation",
"self_plus_ccr": "",
"": "",
"bogus": "",
}
for axis, want := range cases {
if got := childTypeForAxis(axis); got != want {
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
}
}
}
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
// (t-paliad-174 §11 Q2):
// - Default (override nil): claimant ↔ defendant; court / both pass through.
// - Override true: same default-flip semantics.
// - Override false (R.49.2.b CCI edge case): keep parent's side.
// - NULL parent_side yields empty string (no flip without a starting side).
func TestDerivedCounterclaimOurSide(t *testing.T) {
tru := true
fal := false
str := func(s string) *string { return &s }
cases := []struct {
name string
parent *string
override *bool
want string
}{
{"nil parent → empty", nil, nil, ""},
{"nil parent + override → empty", nil, &tru, ""},
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
{"court passes through", str("court"), nil, "court"},
{"both passes through", str("both"), nil, "both"},
{"explicit flip=true", str("claimant"), &tru, "defendant"},
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := derivedCounterclaimOurSide(c.parent, c.override)
if got != c.want {
t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
c.parent, c.override, got, c.want)
}
})
}
}

View File

@@ -169,6 +169,10 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
from := day
to := day.AddDate(0, 0, 90)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast7d:
from := day.AddDate(0, 0, -7)
to := day.AddDate(0, 0, 1)
return viewSpecBounds{from: &from, to: &to}
case HorizonPast30d:
from := day.AddDate(0, 0, -30)
to := day.AddDate(0, 0, 1)