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

@@ -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
}