Compare commits
2 Commits
mai/schroe
...
mai/maxwel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2f1c29b10 | ||
|
|
7930ee0bdb |
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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