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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user