fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip

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).
This commit is contained in:
m
2026-05-09 18:52:01 +02:00
parent 7930ee0bdb
commit c2f1c29b10
6 changed files with 296 additions and 29 deletions

View File

@@ -71,7 +71,10 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
try {
let result: ViewRunResult;
if (opts.customRunner) {
result = await opts.customRunner(effective);
// Hand the runner a frozen snapshot of the bar state so it can
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
// timeline_status / timeline_track on the Verlauf surface).
result = await opts.customRunner(effective, Object.freeze({ ...state }));
} else {
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
@@ -202,6 +205,11 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
if (lastEffective) return lastEffective;
return computeEffective(opts.baseFilter, opts.baseRender, state);
},
getState() {
// Hand back a frozen snapshot so callers can't smuggle mutations
// back into the bar's owned state — the bar is the single writer.
return Object.freeze({ ...state });
},
destroy() {
destroyed = true;
toolbar.remove();

View File

@@ -112,12 +112,14 @@ export interface MountOpts {
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>;
// hands the effective spec + raw BarState to this function instead.
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
// The state argument is a frozen snapshot — same shape getState()
// returns on the handle, but available on the very first run before
// the handle has been assigned. Must be either this OR systemViewSlug
// — the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
// Per-surface override of the time-axis chip presets. Order is
// preserved. Default presets are forward-looking (next_*+past_30d+any)
@@ -150,4 +152,10 @@ export interface BarHandle {
// Read-only effective spec at this moment (post URL + localStorage
// overlay). Pages use this to construct deep-link URLs etc.
getEffective(): EffectiveSpec;
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
// round-trip (timeline_status / timeline_track on the SmartTimeline
// surface — the substrate FilterSpec has no per-source predicate for
// those) read state directly to drive client-side filtering. Returns
// a frozen snapshot; callers must not mutate.
getState(): Readonly<BarState>;
}

View File

@@ -255,16 +255,30 @@ let timelineSelectedLanes: string[] | null = null;
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 — Verlauf FilterBar state.
// t-paliad-170 / t-paliad-176 — 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.
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, ?tl_status=,
// ?tl_track=, …), and drives a client-side filter pass over `timelineRows`
// before render. The SmartTimeline endpoint has no built-in predicate for
// timeline_status / timeline_track / project_event_kind axes — they sit on
// BarState only — so we filter rendered rows in `applyTimelineRowFilters`
// rather than re-fetching on every chip click. The customRunner drains the
// bar's state into `verlaufFilters` and triggers a re-render via onResult.
let verlaufBar: BarHandle | null = null;
interface VerlaufFilters {
// project_event_kind chip — values from KnownProjectEventKinds (see
// internal/services/filter_spec.go). Only filters rows whose underlying
// project_events.event_type is non-empty (deadline / appointment /
// projected rows pass through unaffected — they have no event_type).
eventKinds?: Set<string>;
// timeline_status chip — matches TimelineEvent.status verbatim
// (done | open | overdue | predicted | predicted_overdue | court_set | off_script).
timelineStatuses?: Set<string>;
// timeline_track chip — chip values are "parent" / "counterclaim" /
// "off_script" but row.track may carry suffixed forms like
// "counterclaim:<id>" or "parent_context:<id>". Filtering normalises
// by matching the chip's prefix against the row's track tag.
timelineTracks?: 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.
@@ -273,6 +287,65 @@ interface VerlaufFilters {
}
let verlaufFilters: VerlaufFilters = {};
// applyTimelineRowFilters narrows the SmartTimeline rows to whatever
// the FilterBar's BarState declares. Empty filter → identity passthrough.
// Called from renderTimeline immediately before handing rows to
// renderSmartTimeline (single-column or lane-strip alike).
function applyTimelineRowFilters(rows: SmartTimelineEvent[]): SmartTimelineEvent[] {
const f = verlaufFilters;
if (
!f.eventKinds &&
!f.timelineStatuses &&
!f.timelineTracks &&
!f.fromDate &&
!f.toDate
) {
return rows;
}
return rows.filter((r) => {
// project_event_kind narrows project_events specifically: deadline /
// appointment / projected rows pass through unaffected (they carry no
// project_event_type). A milestone whose project_event_type isn't in
// the picked subset drops out.
if (f.eventKinds && r.project_event_type) {
if (!f.eventKinds.has(r.project_event_type)) return false;
}
if (f.timelineStatuses && !f.timelineStatuses.has(r.status)) return false;
if (f.timelineTracks && !timelineTrackChipMatches(r.track, f.timelineTracks)) return false;
if (f.fromDate || f.toDate) {
// Undated rows (court-set decisions, counterclaim-pending) escape
// the time horizon — same convention as the renderer's "Datum offen"
// bucket. Otherwise compare the row's date against the bounds.
if (r.date) {
const d = new Date(r.date);
if (f.fromDate && d < f.fromDate) return false;
if (f.toDate && d >= f.toDate) return false;
}
}
return true;
});
}
// timelineTrackChipMatches normalises the chip vocabulary against the
// row's track tag — chip "counterclaim" matches both "counterclaim" and
// "counterclaim:<id>"; chip "parent" matches "parent" only (NOT
// "parent_context:<id>", which is a CCR-child-viewing-parent overlay).
function timelineTrackChipMatches(rowTrack: string, chips: Set<string>): boolean {
const tag = rowTrack || "parent";
if (chips.has(tag)) return true;
for (const chip of chips) {
if (chip === "counterclaim" && tag.startsWith("counterclaim:")) return true;
}
return false;
}
// applyVerlaufFilters narrows the legacy /api/projects/{id}/events
// response to the bar's filter state. The render path no longer reads
// this `events` array (the SmartTimeline took over), but loadEvents +
// loadMoreEvents still call it so the cursor pagination state stays
// consistent for any future re-introduction. Keeps the project_event_kind
// + time-horizon filter intact; the SmartTimeline-only axes don't apply
// to the legacy ProjectEvent shape.
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
const f = verlaufFilters;
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
@@ -505,7 +578,13 @@ function renderTimeline() {
return;
}
renderSmartTimeline(host, timelineRows, {
// t-paliad-176 — apply FilterBar predicates client-side. The
// SmartTimeline endpoint returns the unfiltered superset; the bar's
// BarState (timeline_status / timeline_track / project_event_kind /
// time horizon) narrows what we render. Empty filter → identity.
const filteredRows = applyTimelineRowFilters(timelineRows);
renderSmartTimeline(host, filteredRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
lookahead: timelineLookahead,
@@ -1968,19 +2047,18 @@ async function main() {
}
// 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.
// Verlauf tab (t-paliad-170 → t-paliad-176). The bar owns URL params
// (?time=, ?pe_kind=, ?tl_status=, ?tl_track=) and the displayed filter
// chrome; on every state change it invokes the customRunner below, which
// drains the bar state into `verlaufFilters` and lets the bar's onResult
// callback trigger renderTimeline — narrowing happens client-side over
// `timelineRows` in `applyTimelineRowFilters`.
//
// 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.
// Why customRunner instead of the substrate POST: the SmartTimeline
// endpoint isn't a substrate-managed system view, and timeline_status /
// timeline_track / project_event_kind don't all map cleanly onto the
// substrate's per-source predicates. The customRunner stays as the bar's
// integration point with the SmartTimeline read pipeline.
function mountVerlaufFilterBar(id: string): void {
const host = document.getElementById("project-events-filter-bar");
if (!host) return;
@@ -2000,17 +2078,29 @@ function mountVerlaufFilterBar(id: string): void {
verlaufBar = mountFilterBar(host, {
baseFilter,
baseRender,
axes: ["time", "project_event_kind"],
// t-paliad-176 — exposing timeline_status + timeline_track on the
// Verlauf tab. They were declared in the bar's axis catalogue from
// Slice 2 onward but never mounted on this surface; chips were
// therefore invisible and the wire-up was a no-op.
axes: ["time", "timeline_status", "timeline_track", "project_event_kind"],
surfaceKey: "project-history",
showSaveAsView: false,
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
customRunner: async (effective) => {
customRunner: async (effective, state) => {
// project_event_kind rides through effective.filter.predicates
// (substrate-shaped); timeline_status / timeline_track live on raw
// BarState. The bar passes both to keep first-run hydration honest
// (the bar handle hasn't been assigned to verlaufBar yet on first
// run, so we can't reach getState() — the state argument fixes that).
const kinds = effective.filter.predicates?.project_event?.event_types;
const tlStatus = state.timeline_status;
const tlTrack = state.timeline_track;
verlaufFilters = {
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
timelineStatuses: tlStatus && tlStatus.length ? new Set(tlStatus) : undefined,
timelineTracks: tlTrack && tlTrack.length ? new Set(tlTrack) : undefined,
...horizonBounds(effective.filter.time?.horizon ?? "any"),
};
await loadEvents(id);
return { rows: [], inaccessible_project_ids: [] };
},
onResult: () => renderTimeline(),

View File

@@ -72,6 +72,12 @@ export interface TimelineEvent {
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
// t-paliad-176 — underlying paliad.project_events.event_type for
// milestone rows. Empty for deadline / appointment / projected rows.
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
// (matched against KnownProjectEventKinds in filter_spec.go).
project_event_type?: string;
}
export interface LaneInfo {

View File

@@ -238,6 +238,95 @@ func TestProjectionService_LevelAggregation_Live(t *testing.T) {
}
})
t.Run("Patent-level: direct_only collapses to single 'self' lane (m/paliad#33)", func(t *testing.T) {
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For patent direct_only: %v", err)
}
// Lanes should NOT include child cases — just one "self" entry
// pointing at the patent itself.
if len(meta.Lanes) != 1 || meta.Lanes[0].ID != "self" {
t.Errorf("DirectOnly Lanes = %v, want a single 'self' lane", meta.Lanes)
}
if len(meta.Lanes) > 0 && meta.Lanes[0].ProjectID != patentID.String() {
t.Errorf("self lane ProjectID = %q, want patent id", meta.Lanes[0].ProjectID)
}
// Case-A's deadline / milestones must NOT surface — they belong to
// the case subtree and direct_only excludes them.
for _, r := range rows {
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
t.Errorf("Case-A deadline should NOT surface at Patent level with direct_only=true (got %v)", r)
}
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
t.Errorf("Case-A bubbled milestone should NOT surface at Patent level with direct_only=true")
}
}
})
t.Run("Case-level: direct_only drops CCR sub-project lane", func(t *testing.T) {
// Seed a CCR child of Case-A so the default (subtree) path
// includes a "counterclaim:<id>" lane and direct_only excludes it.
ccrID := uuid.New()
ccrMilestoneID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, counterclaim_of, path, title, status, created_by)
VALUES ($1, 'case', $2, $2, $2::text || '.' || $1::text, 'Case A — CCR', 'active', $3)`,
ccrID, caseAID, userID); err != nil {
t.Fatalf("seed CCR: %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)`,
ccrID, userID); err != nil {
t.Fatalf("seed CCR team: %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', 'CCR-side note', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
ccrMilestoneID, ccrID, now.AddDate(0, 0, -1), userID, now); err != nil {
t.Fatalf("seed CCR milestone: %v", err)
}
defer func() {
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, ccrMilestoneID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, ccrID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, ccrID)
}()
// Default (subtree) path: Case-A timeline carries both "self" +
// "counterclaim:<ccrID>" lanes.
_, defaultMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
if err != nil {
t.Fatalf("For caseA default: %v", err)
}
var sawCCRLane bool
for _, l := range defaultMeta.Lanes {
if l.ID == "counterclaim:"+ccrID.String() {
sawCCRLane = true
}
}
if !sawCCRLane {
t.Fatalf("default Case-A meta.Lanes should include the CCR child: %v", defaultMeta.Lanes)
}
// Direct-only path: only the "self" lane survives, CCR milestones
// are excluded.
rows, directMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For caseA direct_only: %v", err)
}
if len(directMeta.Lanes) != 1 || directMeta.Lanes[0].ID != "self" {
t.Errorf("direct_only Lanes = %v, want only 'self'", directMeta.Lanes)
}
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == ccrMilestoneID {
t.Errorf("CCR milestone should NOT surface at Case-A with direct_only=true")
}
}
})
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.

View File

@@ -116,6 +116,13 @@ type TimelineEvent struct {
// one column per lane and groups rows by LaneID.
LaneID string `json:"lane_id,omitempty"`
// ProjectEventType carries the underlying paliad.project_events.event_type
// for milestone rows (t-paliad-176). Empty for deadline / appointment /
// projected rows. The FilterBar's project_event_kind chip narrows the
// rendered list against this field; KnownProjectEventKinds in
// internal/services/filter_spec.go is the canonical vocabulary.
ProjectEventType string `json:"project_event_type,omitempty"`
// BubbleUp signals that a project_event milestone is marked to
// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
// Read from metadata.bubble_up on the underlying paliad.project_events
@@ -298,6 +305,17 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
policy := levelPolicy(proj.Type)
// DirectOnly collapses every level to a single-lane "self" view —
// no CCR sub-project lanes (Case level), no parent_context lane (CCR
// child viewpoint), no child-case / child-patent / child-litigation
// lanes (Patent / Litigation / Client levels). The level-policy
// kind/status filter still applies at higher levels so that, e.g., a
// Patent-level direct view doesn't suddenly leak off_script custom
// milestones that the aggregated view filters out (t-paliad-176).
if opts.DirectOnly {
return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
}
// Patent / Litigation / Client levels — lane-aggregated rendering.
if policy.LaneAxis != "self_plus_ccr" {
return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
@@ -309,6 +327,51 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
return s.forCaseLevel(ctx, userID, proj, opts, meta)
}
// forDirectSelfOnly handles every level when DirectOnly is requested
// (m/paliad#33). Renders this project's own actuals + (at Case level)
// projection only — no CCR / parent_context / child-case lanes. The
// policy's kind/status filter still applies at higher levels so the
// "Nur direkt" Patent view honours the same milestone-only contract as
// the aggregated default. Produces a single "self" lane.
func (s *ProjectionService) forDirectSelfOnly(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
includeProjection := policy.LaneAxis == "self_plus_ccr"
rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
allowKind := stringSet(policy.Kinds)
allowStatus := stringSet(policy.Statuses)
out := make([]TimelineEvent, 0, len(rows))
for i := range rows {
row := rows[i]
row.LaneID = "self"
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
continue
}
out = append(out, row)
}
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: "self",
Label: proj.Title,
ProjectID: proj.ID.String(),
})
sortTimeline(out)
return out, meta, nil
}
// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
// CCR sub-projects (when this project is the parent) or parent_context
// (when this project is a CCR child). Lanes mirror tracks one-for-one
@@ -1100,6 +1163,9 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
ProjectEventID: &r.ID,
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
}
if r.EventType != nil {
ev.ProjectEventType = *r.EventType
}
if r.Description != nil {
ev.Description = *r.Description
}