diff --git a/frontend/src/client/filter-bar/index.ts b/frontend/src/client/filter-bar/index.ts index f7f3464..95bdaa5 100644 --- a/frontend/src/client/filter-bar/index.ts +++ b/frontend/src/client/filter-bar/index.ts @@ -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(); diff --git a/frontend/src/client/filter-bar/types.ts b/frontend/src/client/filter-bar/types.ts index cdebc0f..ecf297d 100644 --- a/frontend/src/client/filter-bar/types.ts +++ b/frontend/src/client/filter-bar/types.ts @@ -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; + // 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) => Promise; // 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; } diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 28eb560..c8fa6e2 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -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; + // timeline_status chip — matches TimelineEvent.status verbatim + // (done | open | overdue | predicted | predicted_overdue | court_set | off_script). + timelineStatuses?: Set; + // timeline_track chip — chip values are "parent" / "counterclaim" / + // "off_script" but row.track may carry suffixed forms like + // "counterclaim:" or "parent_context:". Filtering normalises + // by matching the chip's prefix against the row's track tag. + timelineTracks?: Set; // 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:"; chip "parent" matches "parent" only (NOT +// "parent_context:", which is a CCR-child-viewing-parent overlay). +function timelineTrackChipMatches(rowTrack: string, chips: Set): 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(), diff --git a/frontend/src/client/views/shape-timeline.ts b/frontend/src/client/views/shape-timeline.ts index 5195eb9..91ae803 100644 --- a/frontend/src/client/views/shape-timeline.ts +++ b/frontend/src/client/views/shape-timeline.ts @@ -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 { diff --git a/internal/services/projection_levels_test.go b/internal/services/projection_levels_test.go index bd833f1..3423ea2 100644 --- a/internal/services/projection_levels_test.go +++ b/internal/services/projection_levels_test.go @@ -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:" 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:" 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. diff --git a/internal/services/projection_service.go b/internal/services/projection_service.go index 37cd389..2490365 100644 --- a/internal/services/projection_service.go +++ b/internal/services/projection_service.go @@ -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 }