FN-5920: prefer mission detail event count for activity tab
Use mission detail event counts as the authoritative pre-load source for mission activity. - add mission hierarchy eventCount support in core and dashboard mission types - return mission eventCount from mission detail APIs and document the field - prefer the detail eventCount over stale list summaries before activity events load - add coverage for store, API, and MissionManager pre-load count behavior Files changed: docs/missions.md | 2 +- packages/core/src/__tests__/mission-store.test.ts | 18 +++++++++ packages/core/src/mission-store.ts | 6 +++ packages/core/src/mission-types.ts | 2 + packages/dashboard/app/api/legacy.ts | 2 + packages/dashboard/app/components/MissionManager.tsx | 12 +++++- packages/dashboard/app/components/__tests__/MissionManager.test.tsx | 46 +++++++++++++++++++++- packages/dashboard/app/components/mission-types.ts | 1 + packages/dashboard/src/__tests__/mission-e2e.test.ts | 2 + 9 files changed, 86 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-5920 Fusion-Task-Lineage: a0269499-d34a-495d-89fc-0534bb62a94e
This commit is contained in:
@@ -117,7 +117,7 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission. |
|
||||
| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. |
|
||||
| `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. |
|
||||
| `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. |
|
||||
| `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. |
|
||||
|
||||
@@ -1920,6 +1920,24 @@ describe("MissionStore", () => {
|
||||
|
||||
expect(withHierarchy.linkedGoals).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports detail eventCount consistently with mission summaries", () => {
|
||||
const mission = store.createMission({ title: "Hierarchy event counts" });
|
||||
|
||||
const emptyHierarchy = store.getMissionWithHierarchy(mission.id)!;
|
||||
const emptySummary = store.getMissionSummary(mission.id);
|
||||
expect(emptyHierarchy.eventCount).toBe(0);
|
||||
expect(emptyHierarchy.eventCount).toBe(emptySummary.eventCount);
|
||||
|
||||
store.logMissionEvent(mission.id, "mission_started", "started");
|
||||
store.logMissionEvent(mission.id, "warning", "warning");
|
||||
store.logMissionEvent(mission.id, "error", "error");
|
||||
|
||||
const populatedHierarchy = store.getMissionWithHierarchy(mission.id)!;
|
||||
const populatedSummary = store.getMissionSummary(mission.id);
|
||||
expect(populatedHierarchy.eventCount).toBe(3);
|
||||
expect(populatedHierarchy.eventCount).toBe(populatedSummary.eventCount);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Transaction Tests ────────────────────────────────────────────────
|
||||
|
||||
@@ -723,9 +723,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
});
|
||||
|
||||
const eventCountRow = this.db
|
||||
.prepare("SELECT COUNT(*) AS count FROM mission_events WHERE missionId = ?")
|
||||
.get(id) as { count?: number | bigint } | undefined;
|
||||
const eventCount = Number(eventCountRow?.count ?? 0);
|
||||
|
||||
return {
|
||||
...mission,
|
||||
linkedGoals,
|
||||
eventCount,
|
||||
milestones: milestonesWithSlices,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,6 +461,8 @@ export interface SliceWithFeatures extends Slice {
|
||||
export interface MissionWithHierarchy extends Mission {
|
||||
/** Goals linked to this mission */
|
||||
linkedGoals?: Goal[];
|
||||
/** Unfiltered total of all mission lifecycle events, matching `MissionSummary.eventCount` and `getMissionEvents` `total` with no `eventType` filter */
|
||||
eventCount?: number;
|
||||
/** Milestones belonging to this mission, each with their slices */
|
||||
milestones: Array<MilestoneWithSlices & {
|
||||
/** Slices with their features loaded */
|
||||
|
||||
@@ -6980,6 +6980,8 @@ export interface SliceWithFeatures extends Slice {
|
||||
|
||||
/** Full mission hierarchy */
|
||||
export interface MissionWithHierarchy extends Mission {
|
||||
/** Unfiltered total of all mission lifecycle events, matching MissionSummary.eventCount and getMissionEvents total with no eventType filter */
|
||||
eventCount?: number;
|
||||
milestones: MilestoneWithSlices[];
|
||||
}
|
||||
|
||||
|
||||
@@ -922,8 +922,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
if (!selectedMission?.id) {
|
||||
return eventsTotal;
|
||||
}
|
||||
return missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount ?? eventsTotal;
|
||||
}, [eventsTotal, missions, selectedMission?.id]);
|
||||
|
||||
const baseCount = selectedMission.eventCount
|
||||
?? missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount;
|
||||
|
||||
if (baseCount == null) {
|
||||
return eventsTotal;
|
||||
}
|
||||
|
||||
return Math.max(baseCount, eventsTotal);
|
||||
}, [eventsTotal, missions, selectedMission?.eventCount, selectedMission?.id]);
|
||||
|
||||
const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]);
|
||||
|
||||
|
||||
@@ -132,6 +132,7 @@ const mockMissionDetail = {
|
||||
title: "Build Auth System",
|
||||
description: "Complete authentication flow",
|
||||
status: "planning",
|
||||
eventCount: 4,
|
||||
linkedGoals: [] as Array<{ id: string; title: string; status: "active" | "archived"; createdAt: string; updatedAt: string; description?: string }>,
|
||||
milestones: [
|
||||
{
|
||||
@@ -651,6 +652,8 @@ function createDetailFetchMockForMissionDetail(
|
||||
missionDetail: typeof mockMissionDetail,
|
||||
telemetryOverride: unknown = mockMilestoneValidationTelemetry,
|
||||
assertionsResponse: unknown[] = [],
|
||||
missionsResponse = mockMissions,
|
||||
eventsResponse = mockMissionEvents,
|
||||
) {
|
||||
return vi.fn().mockImplementation((url: string) => {
|
||||
if (url.includes("/missions/health")) {
|
||||
@@ -658,7 +661,7 @@ function createDetailFetchMockForMissionDetail(
|
||||
}
|
||||
|
||||
if (url.includes("/events")) {
|
||||
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, mockMissionEvents)));
|
||||
return Promise.resolve(mockApiResponse(parseMissionEventsResponse(url, eventsResponse)));
|
||||
}
|
||||
|
||||
if (url.includes("/health")) {
|
||||
@@ -686,7 +689,7 @@ function createDetailFetchMockForMissionDetail(
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(mockApiResponse(mockMissions));
|
||||
return Promise.resolve(mockApiResponse(missionsResponse));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1228,6 +1231,44 @@ describe("MissionManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers mission detail event count when the list summary is stale before activity events load", async () => {
|
||||
const staleSummaryMissions = mockMissions.map((mission) => mission.id === "M-001"
|
||||
? {
|
||||
...mission,
|
||||
summary: {
|
||||
...mission.summary,
|
||||
eventCount: 0,
|
||||
},
|
||||
}
|
||||
: mission);
|
||||
const missionDetailWithAuthoritativeCount = {
|
||||
...mockMissionDetail,
|
||||
eventCount: 7,
|
||||
};
|
||||
|
||||
globalThis.fetch = createDetailFetchMockForMissionDetail(
|
||||
missionDetailWithAuthoritativeCount,
|
||||
mockMilestoneValidationTelemetry,
|
||||
[],
|
||||
staleSummaryMissions,
|
||||
[],
|
||||
);
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Build Auth System")).toBeDefined();
|
||||
});
|
||||
fireEvent.click(screen.getByText("Build Auth System"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mission-tab-activity")).toHaveTextContent("Activity (7)");
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("mission-activity-events")).toBeNull();
|
||||
});
|
||||
|
||||
it("auto-scrolls to the latest mission activity on initial load", async () => {
|
||||
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||
@@ -1309,6 +1350,7 @@ describe("MissionManager", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Real-time warning event")).toBeDefined();
|
||||
expect(screen.getByTestId("mission-tab-activity")).toHaveTextContent("Activity (5)");
|
||||
expect(scrollIntoViewSpy).toHaveBeenLastCalledWith({ block: "end", behavior: "auto" });
|
||||
});
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ export type MissionWithSummary = Mission & { summary?: MissionSummary };
|
||||
export interface MissionWithHierarchy extends Mission {
|
||||
linkedGoals?: Goal[];
|
||||
milestones: Milestone[];
|
||||
eventCount?: number;
|
||||
}
|
||||
|
||||
/** Mission event categories emitted by mission observability APIs. */
|
||||
|
||||
@@ -102,6 +102,7 @@ function createMockMissionStore(options?: {
|
||||
return {
|
||||
...mission,
|
||||
linkedGoals: [],
|
||||
eventCount: (missionEvents.get(id) ?? []).length,
|
||||
milestones: missionMilestones.map((m) => ({
|
||||
...m,
|
||||
slices: Array.from(slices.values())
|
||||
@@ -1427,6 +1428,7 @@ describe("Mission API", () => {
|
||||
expect(res.body).toHaveProperty("linkedGoals");
|
||||
expect(Array.isArray(res.body.linkedGoals)).toBe(true);
|
||||
expect(res.body.linkedGoals).toEqual([]);
|
||||
expect(res.body.eventCount).toBe(0);
|
||||
expect(res.body.milestones).toHaveLength(1);
|
||||
expect(res.body.milestones[0]).toHaveProperty("slices");
|
||||
expect(Array.isArray(res.body.milestones[0].slices)).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user