Phase 3 Slice 1 Step A (design §3.1). Additive only; no drops, no
data change. Adds nine columns to paliad.deadline_rules so the
calculator + rule editor can converge on a single rule shape over
the following slices:
trigger_event_id (bigint, FK trigger_events.id)
spawn_proceeding_type_id (int, FK proceeding_types.id)
combine_op (text, CHECK 'max'|'min')
condition_expr (jsonb)
priority (text, DEFAULT 'mandatory', 4-way CHECK)
is_court_set (bool, DEFAULT false)
lifecycle_state (text, DEFAULT 'published', 3-way CHECK)
draft_of (uuid, self-FK)
published_at (timestamptz)
FK types follow the actual referenced columns (bigint on
trigger_events, int4 serial on proceeding_types) — the design doc's
"int FK" shorthand is widened to the precise widths.
FKs are DEFERRABLE INITIALLY IMMEDIATE so Slice 3's data-move can
defer FK checks within a single transaction without disturbing
normal-statement semantics.
Indexes: partial WHERE NOT NULL on the two FK columns (sparse;
most rules have neither); plain btree on lifecycle_state so the
admin filter on 'published' is O(log n).
Slice 4 step 2 (faraday-Q7). Wires shape="timeline" into the /views
shape switcher and the dispatch in client/views.ts.
New file shape-timeline-cv.ts holds the adapter:
- ViewRow.kind="deadline" → TimelineEvent kind="deadline" + deadline_id
- ViewRow.kind="appointment" → kind="appointment" + appointment_id
- ViewRow.kind="project_event" → kind="milestone" + project_event_id
- ViewRow.kind="approval_request" → SKIPPED (no chart-meaningful date)
- Lane axis = project_id (design §10 cross-project chart use case);
first-seen order keeps lanes deterministic across re-renders.
- Rows without project_id collapse to a synthetic "self" lane.
- Status comes from row.detail.status for deadlines (done/overdue),
defaults to "open" everywhere else.
shape-timeline-chart.ts gets a new ChartMountOpts.staticData escape
hatch: when supplied, mount() skips the /api/projects/{id}/timeline
fetch and paints from the supplied events + lanes directly. This is
what lets the CV adapter feed pre-loaded ViewRows into the same
renderer that powers /projects/{id}/chart — Slice 1-3 features
(palette, density, range chips, lane filter, permalink) all carry
over for free.
views.ts switches the active shape host and disposes the chart handle
on shape flips so resize listeners don't leak between mounts.
Tests (13 new): pin the kind mapping, lane bucketing by project_id,
status extraction precedence, date passthrough, empty-input safety.
Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5.
Slice 4 step 1 (faraday-Q7). RenderShape gets a fourth member
ShapeTimeline, AllShapes extends, Validate accepts it. The
companion TimelineConfig struct stores the saved palette / density /
range-preset for a CV-timeline view so re-opening the view restores
the same visual settings — same vocabulary as the standalone
/projects/{id}/chart URL state, just persisted in user_views.render_spec
instead of the URL.
Validator mirrors the frontend's enum guards:
- known palettes (default | kind-coded | track-coded | high-contrast | print)
- known densities (compact | standard | spacious)
- known range presets (1y | 2y | all | custom)
- ISO-date strings length-bounded to 32 chars so a hostile editor
can't bloat the jsonb column.
Tests pin every accept/reject path in TestRenderSpec_TimelineConfigValidates.
Design ref: docs/design-project-chart-2026-05-09.md §11.5 + §14 Q7.
Phase 1 audit (AUDIT ONLY, no implementation). 799 lines, mai/pauli/fristen-logic-audit.
Headline findings:
- THREE parallel deadline-generation systems coexist with overlapping
intent:
- Pipeline A (proceeding-driven) — paliad.deadline_rules (172 rows),
FristenrechnerService.Calculate, drives /tools/fristenrechner +
SmartTimeline.
- Pipeline B (single-rule subset of A) — Pathway B cascade click.
- Pipeline C (event-driven, youpc legacy) — paliad.trigger_events
(110) + paliad.event_deadlines (77), EventDeadlineService.Calculate,
drives "Was kommt nach…" tab. Disjoint corpus from A.
- Rule corpus is RICHER than the brief implied: 32 columns, 172 rules
across 27 proceeding_types (132 fristenrechner + 40 litigation). The
dual-corpus is a latent footgun: paliad.projects.proceeding_type_id
accepts both categories with no CHECK constraint, so a project's
SmartTimeline depends on which code lands first.
- Data model already encodes most of m's mental model:
multi-deadline triggers via parent_id chains (deepest live: 3
levels in UPC_INF), conditional via condition_flag (AND-only),
flag-swap via alt_duration_value / alt_rule_code, court-set via
heuristic + 4-bucket classification, holiday adjustment via
HolidayService+CourtService.
- Real gaps (§6, 13 of them):
- Pipeline A/C redundancy (different capabilities, disjoint data).
- Litigation vs fristenrechner corpus drift (no contract).
- is_mandatory + is_optional overlap.
- deadline_concept_event_types is config layer, NOT trigger model.
- No real event-driven trigger endpoint.
- AND-only condition_flag (no OR/NOT/compound).
- Cross-proceeding spawn half-wired.
- 9 orphan concepts with rule_count=0 (incl wiedereinsetzung,
schriftsatznachreichung, weiterbehandlung).
- condition_rule_id dead column.
- Instance dimension (LG/OLG/BGH) not on paliad.projects.
- 1/26 deadlines linked to rule_id (anchor-from-actuals barely
used).
- Court-set is heuristic, not first-class column.
- Pipeline A lacks before / working_days / combine_op.
- The big m's-question: "all in the Rules so we should be able to
manage" is FALSE today. Rules edits = SQL migrations only. §8
proposes a 3-step ladder: status-quo / read-only admin / full
editor with audit log.
- §7 has concrete extension proposal for each §6 gap (migration size
costed).
- §9 has 15 open questions for m to call before Phase 2 starts.
- Live data sparse: 11/11 projects NULL proceeding_type_id, 1/26
deadlines with rule_id — demand-side mostly empty even though
supply-side (rules) is rich.
NOT cronus per memory directive 2026-05-06. NOT self-merged. Awaiting
m's go/no-go.
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.
Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.
Design ref: docs/design-project-chart-2026-05-09.md §8.1.
Slice 3 step 4 (head Slice-2 deferral). Implements head's option (a):
sidebar.ts walks the URL pathname on init and reveals a contextual
"Als Chart anzeigen" entry when it sits on a /projects/{uuid}/* page
that ISN'T already the chart itself.
Sidebar TSX gets a new hidden slot id="sidebar-project-chart-link"
right under the Übersicht group. The page never has to touch the
sidebar — initProjectContextChartLink owns the path-match and the
href population. Clean separation: pages don't know about the slot;
sidebar.ts doesn't know about pages.
UUID-shape regex prevents the chip from appearing on /projects (list)
or /projects/new. Rest-path check excludes /chart and /chart/ — the
chart page already has its own "Zurück zum Verlauf" path (Slice 1
link goes the other direction, a reciprocal can land in the next
commit).
i18n: 1 new key DE+EN under nav.context.project_chart.
Design ref: docs/design-project-chart-2026-05-09.md §8.1 +
Slice-2 head deferral resolution.
Slice 3 step 3 (faraday-Q10). The URL already aggregates every chip's
state via the individual writeParamToURL writers we built in Slice 2
and Slice 3 C1-C2 — palette + density + range + lanes. The copy
button just reads window.location.href and writes it to the clipboard.
Two-tier clipboard strategy:
1. navigator.clipboard.writeText in secure contexts (modern browsers,
localhost, paliad.de over TLS).
2. document.execCommand("copy") fallback for older / non-secure
contexts (file://, some iframes).
Visual feedback flashes green/amber on the button for 1.8s after the
click — no toast component needed, the button IS the affordance.
Permalink contract: reload an identical URL → visually identical
chart. Tested by hand on every chip combination; URL stays canonical
(default values omit their param) so shared links don't accumulate
defaults that drift if defaults change.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §14 Q10.
Slice 3 step 2. The chip group is rendered dynamically by the boot
client after refresh() reports lanes via the new onDataLoaded
callback — the lane labels and ids only exist after the server
responds, so static TSX can't render the chips. Hidden when the
projection has 0-1 lanes (filter has no value on a single-track
render).
setVisibleLanes(allowlist | null) on the chart handle filters BOTH
lanes and events in repaint() before passing to layout() — drops
unselected entirely (doesn't fall back to first-lane the way an
unknown stale id does). null = show all.
Stale lane ids are dropped from the URL-restored allowlist after
every refresh: deleted CCRs / child cases can't keep their lane id
alive across re-fetches.
URL state in ?lanes=id1,id2; absent / empty = show all. Hostile or
oversized ids are filtered (length cap 200) at parse time; the
allowlist intersection in repaint() defends again. Toggling every
chip back on collapses to null so the URL stays canonical.
Design ref: docs/design-project-chart-2026-05-09.md §3.2 + §8.2.
Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default:
1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded
events with a +30d right pad), and custom (date-pair inputs).
mount() grows currentRangePreset + customRangeFrom + customRangeTo so
the layout-time viewport is computed from the live preset, not the
constructor-time opts. resolveRange() handles the four cases; "all"
calls rangeFromEvents() over the last fetched timeline so completing
or adding a row reflows on next repaint.
URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds
?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair
shows / hides based on the preset.
i18n: 7 new keys DE+EN under projects.chart.range.*.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8.
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
The new abstract-browse surface. TSX shell hosts:
- header (h1 + subtitle)
- jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
- trigger date input
- court picker (visible only for proceedings with multiple
compatible courts — UPC_REV across CD + LD seats etc.)
- view toggle (Spalten / Zeitstrahl)
- result container
client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.
build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).
A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.
Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
Server-side endpoint GET /api/projects/{id}/timeline.ics returns a
VCALENDAR + one VEVENT per actual deadline (VALUE=DATE all-day) and
appointment (UTC timestamp). Projected / milestone / off_script rows
are deliberately skipped — faraday-Q6 / m's pick: a calendar feed
must never carry predicted dates the user never confirmed, otherwise
Outlook fills with rule_code-derived events that erode trust.
FormatTimelineICS reuses the existing caldav_ical.go escape helpers
and writes through the same canonical UIDs (paliad-deadline-<id> +
paliad-appointment-<id>) so a re-subscribe updates entries instead
of duplicating them. Stable across re-exports = lawyer-safe.
Visibility piggybacks on ProjectionService.For + ProjectService.GetByID
(same gates as the chart page handler). Content-Disposition filename
slugged for portable ASCII so Outlook + Apple Calendar agree.
4 tests pin the contract: only deadline/appointment kinds emit
VEVENTs; undated rows skip cleanly; RFC 5545 §3.3.11 escaping for
; , \ \\n; empty input still produces a valid VCALENDAR.
i18n: 1 new key DE+EN.
Design ref: docs/design-project-chart-2026-05-09.md §7.8.
Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).
- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
--chart-* tokens inlined so the standalone file paints the same way
when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
the print palette tokens, locks A4 landscape via @page. User picks
"Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
project_title, exported_at).
Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.
Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.
i18n: 7 new keys DE+EN under projects.chart.export.*.
Design ref: docs/design-project-chart-2026-05-09.md §7.
Density flips lane height (24/40/64) and mark radius (5/7/10) via the
existing LANE_HEIGHT / MARK_RADIUS tables in shape-timeline-chart.ts.
Unlike palette (pure CSS swap), density needs a repaint because it
changes layout() output — setDensity() on the handle re-runs the
layout pure function with the new viewport.density.
URL state in ?density=<compact|standard|spacious>, default omitted.
The writeParamToURL helper is now shared between palette + density to
keep the canonical URL short (omit when value equals the default).
i18n: 4 new keys DE+EN under projects.chart.density.*.
Design ref: docs/design-project-chart-2026-05-09.md §6.1.
Slice 2 ships all 5 palettes from design §5.1 (m's pick on faraday-Q5):
default / kind-coded / track-coded / high-contrast / print.
Each palette is a pure data-attribute swap of the --chart-* tokens on
.smart-timeline-chart[data-palette="..."]. The renderer never reads
palette state — it stamps classed SVG nodes and the tokens flow in
via CSS variable cascade. setPalette() on the chart handle is a
one-line attribute write; no repaint.
URL state lives in ?palette=<name>; default omits the param so the
canonical URL stays clean. Initial paint reads the URL, every change
writes via history.replaceState — bookmarkable per design §8.2.
Unknown values silently fall back to default (defence against stale /
hostile URLs).
i18n: 6 new keys DE+EN under projects.chart.palette.*.
Design ref: docs/design-project-chart-2026-05-09.md §5 + §8.2.
Slice 1 served dist/projects-chart.html unconditionally, leaking a 200
for any well-formed UUID guesser. Slice 2 resolves the project via
ProjectService.GetByID before serving — ErrNotVisible (and any other
visibility error) collapses to 404 + the standard notfound chrome,
matching the JSON-API contract that already lives in writeServiceError.
A genuine DB error logs through writeServiceError's existing path but
still renders 404 chrome to the user (httpDevNullJSON wrapper discards
the JSON body writeServiceError would otherwise emit, keeping the log
side-effect intact).
Test pins serveChartNotFound: 404 + non-empty body, degrading
gracefully when dist/notfound.html is absent (test env).
Closes Slice 1 edge case #2 flagged at m/paliad#35 issuecomment-7710.
Design ref: docs/design-project-chart-2026-05-09.md §8.2.
Wires the chart surface end-to-end:
- frontend/src/projects-chart.tsx — standalone page shell with title
row, inert control chips (Slice 3 wires them live), undated hint slot,
and the mount target for the SVG renderer.
- frontend/src/client/projects-chart.ts — boot client that parses the
project id from the URL, loads project metadata for the header,
mounts the renderer, and reveals the undated hint when the layout
reports clipped/undated rows.
- frontend/build.ts — registers the new bundle + HTML output.
- frontend/src/client/i18n.ts — 11 new DE+EN keys under projects.chart.*
+ projects.detail.smarttimeline.open_chart (the Verlauf link).
- frontend/src/projects-detail.tsx — "Als Chart anzeigen ↗" link in
the SmartTimeline controls, opens /chart in a new tab.
- frontend/src/client/projects-detail.ts — resolves the chart href in
renderHeader once project.id is known.
`bun run build` clean, `go build ./...` clean, 27/27 chart tests pass.
Design ref: docs/design-project-chart-2026-05-09.md §8.1 + §8.2 + §12.
Inventor pass for t-paliad-178. Two intents (deadline determination vs
abstract procedural shape browse) get two dedicated routes:
- /tools/fristenrechner — keeps deadline-determination, gains Step 0
("Abstrakt oder Akte?") above today's Step 1.
- /tools/verfahrensablauf — new dedicated abstract-browse surface with
variant chips (with_ccr / with_cci / with_amend), consolidated-vs-lane
view, and side-by-side compare.
§0 premise audit corrects three things the task brief got wrong:
1. projects.court is free-text, not FK — no silent court_id auto-pick.
2. projects.proceeding_type_id points at litigation-category rows, not
fristenrechner-category — a mapping helper (litigation × jurisdiction
→ fristenrechner code) is required.
3. condition_flag variants only exist on UPC_INF + UPC_REV; every other
proceeding renders a single canonical timeline. Variant chips honour
this — no dead chips on DE_INF / EPA_OPP / DPMA_*.
Sliced into 4 independent merges: Slice 1 (route + shell split) is the
structural foundation; Slices 2-4 layer Step 0 / variant chips / compare.
DESIGN ONLY — no implementation. Awaiting m's go/no-go before coder shift.
Extends shape-timeline-chart.ts with the DOM-mutation half of the
renderer:
- paint(layout, root, events): hand-rolled SVG using namespaced
document.createElementNS. Idempotent (clears prior children),
layers <defs> → grid+axis+lanes → today rule → marks. Each mark
wraps in <g> with data-* attrs for delegated event handling.
- mount(host, opts): fetches /api/projects/{id}/timeline (defensive
for both legacy []TimelineEvent and Slice-4 envelope shapes),
computes a today-1y..today+1y default range (design Q8), wires
resize debouncing + click delegation. Returns a handle with
refresh / dispose / getLayout.
CSS palette tokens swap purely via --chart-* custom properties on
.smart-timeline-chart, so future palette slices (Slice 3) toggle
attributes without touching the renderer. Deadlines colour-saturate
by status (open = ring, done = filled, overdue = red). Projected
rows use the hatched/dashed-dot variants from §6.2.
Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §5 + §6.
Slice 1 load-bearing math. Translates TimelineEvent[] + LaneInfo[] +
viewport into deterministic SVG-ready geometry: axis ticks (month /
quarter / year by total span), lane row y/height, mark x/y/shape per
kind+status, today rule. No DOM access — paint() will read this and
mutate the SVG separately.
Tests pin canvas geometry, pxPerDay math, today-rule clipping, lane
stacking, mark bucketing by lane_id, out-of-range clipping, undated
zone, mark-shape mapping, axis tick density. Date math is UTC
throughout so DST doesn't drift day-deltas.
Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
Slice 1 backend slice. Tiny static-file server for the new standalone
chart page; visibility piggybacks on the existing /api/projects/{id}/
timeline endpoint (gated through ProjectionService.For), so no new
auth surface.
Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
maxwell diagnosed and fixed two regressions m hit @ 18:32:
#32 — FilterBar timeline filters don't narrow Verlauf:
- The Verlauf bar mounted only 'time' + 'project_event_kind' axes; the
Slice 2/3 timeline_status + timeline_track chips never rendered.
- The customRunner drained predicates into the legacy loadEvents()
array, but the SmartTimeline render reads timelineRows. Filter pass
was landing on a dead branch.
#33 — Nur direkt always includes sub-projects:
- Frontend correctly sent ?direct_only=true; handler parsed it; the
loadProjectTrack SQL filter respected DirectOnly. But Slice 3's
CCR-children loading (forCaseLevel) and Slice 4's lane-per-child
loading (forAggregatedLevel) ran unconditionally regardless of the
flag.
Fix:
- Backend: ProjectionService.For() short-circuits to new
forDirectSelfOnly when opts.DirectOnly. Single 'self' lane, no
CCR/parent_context/child-case aggregation. Level-policy kind+status
filter still applies. Added ProjectEventType field to TimelineEvent
so frontend can filter by project_event_kind end-to-end.
- Frontend: mountFilterBar's customRunner gains a 'state' arg for
first-run hydration; BarHandle gains getState(). Verlauf bar now
mounts all three axes (time, project_event_kind, timeline_status,
timeline_track). customRunner drains state into verlaufFilters;
renderTimeline calls applyTimelineRowFilters before passing rows
to renderSmartTimeline.
- Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the DirectOnly contract at Patent and Case level (+89 LoC).
Verified: go build/vet/test clean, bun build clean. (Pre-existing
pq type-inference seed issue in unrelated projection_service_test
remains; verified independent of this change via stash.)
3-way merge with main preserves faraday's chart design doc unchanged.
Single commit c2f1c29 from mai/maxwell/bug-bundle-filterbar.
Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09:
m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind
chips changed URL params but the rendered list never narrowed. Two
causes: (1) the Verlauf bar mounted only "time" + "project_event_kind"
axes — the timeline_status / timeline_track chips never appeared. (2)
the customRunner drained predicates into `loadEvents` which writes the
legacy `events` array; the SmartTimeline render reads `timelineRows`,
so the filter pass was a dead branch.
Fix: mount all three axes on the bar; rewrite customRunner to drain
state into `verlaufFilters`; renderTimeline applies them client-side
via `applyTimelineRowFilters` before handing rows to renderSmartTimeline.
project_event_kind is forwarded through the substrate-shaped predicate
map (effective.filter.predicates.project_event.event_types);
timeline_status / timeline_track sit on raw BarState — the customRunner
signature now accepts the BarState snapshot as a second arg so the
bar's first run (before the handle is assigned) can read them.
Backend adds `ProjectEventType` to TimelineEvent + frontend
TimelineEvent — needed so the project_event_kind chip can match against
the underlying paliad.project_events.event_type for milestone rows.
m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the
timeline with ?direct_only=true, but ProjectionService.For honoured the
flag only at the deadline / appointment / project_events SQL level. CCR
sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded
unconditionally, so the "direct" view still showed everything.
Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is
set. Single "self" lane, no CCR / parent_context / child-case
aggregation. The level-policy kind/status filter still applies at
higher levels so a Patent-level direct view doesn't leak off_script
custom milestones the aggregated view filters out.
Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the contract — Patent direct_only collapses to a single 'self' lane
and excludes child-case events; Case-A direct_only excludes the CCR
child's milestones (with subtree default still surfacing them).
Build: go build/vet/test clean. bun run build clean (2171 keys).
schroedinger closes the 4-slice phasing of the SmartTimeline per
docs/design-smart-timeline-2026-05-08.md §5 + §10. Final design slice.
Backend (commit 7da8802):
- ProjectionService.levelPolicy(projectType) returns {Kinds, Statuses,
LaneGrouping} per design §5.1: Case (all/all/self+CCR), Patent
(deadline+milestone / done+open+overdue / one-per-child-case),
Litigation (milestone / done / one-per-child-patent), Client
(milestone / done / one-per-child-litigation, gated by toggle).
- bubble_up handling on paliad.project_events.metadata: events with
metadata->>'bubble_up'='true' survive the level kind+status filter
at higher levels. Defaults: counterclaim_created /
third_party_intervention / scope_change → bubble_up=true on insert
(bohr's Slice 3 counterclaim path retroactively gets the flag);
custom_milestone → bubble_up=false with form-checkbox override.
- Wire shape evolved from []TimelineEvent to {events, lanes} envelope
with each event carrying LaneID. Frontend has defensive fallback.
- Lane-grouping wire format: lanes []LaneInfo{id, label, project_id,
primary_track?}; one entry per direct child at parent levels.
- Tests: TestLevelPolicy (matrix per project type) +
projection_levels_test.go +271 LoC integration suite.
Frontend (commit 7e57507):
- shape-timeline.ts: lane-grouped CSS-grid render when lanes.length > 1;
per-lane sub-headers, time axis shared across lanes, lane filter chip
in header (multiselect, defaults all-selected).
- projects-detail.tsx + .ts: at Client-level project pages, Verlauf
defaults to existing matter-list (project tree) with new
'Timeline-Ansicht' toggle button. Toggle persists in localStorage
per project. Patent/Litigation already-default to lane view (no
toggle needed).
- '+ Eintrag → Eigener Meilenstein' form gains a 'Auf Eltern-Ebenen
sichtbar?' checkbox (default unchecked) for the bubble_up override.
Locked picks per design §11 (no deviations):
- Q5: bubble-up defaults locked
- Q12: Patent + Litigation default lane view; Client matter-list +
toggle
Verified: go build ./... clean, go vet clean, go test
./internal/services passing, bun build clean (2171 keys).
This closes the 4-slice phasing of t-paliad-169:
- Slice 1 (3e1bbd3): skeleton — actuals + audit toggle + render shape
- Slice 2 (196f3f7): projection + click-to-anchor + #31 layered
(lookahead + dependency + sequence enforcement)
- Slice 3 (91d3811): counterclaim sub-project + parallel-track
- Slice 4 (this): lane aggregation at Patent/Litigation/Client levels
The SmartTimeline design is fully shipped end-to-end. m can dogfood
the complete flow: anchor a date on a Case, see it bubble up the
hierarchy; create a counterclaim, see parallel tracks at Case level
and as a milestone bubbled up to Patent and beyond.
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
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
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.
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.
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.
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.
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.
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).
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.