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:
gsxdsm
2026-06-02 23:47:32 -07:00
parent 6d3b1c9810
commit 0971eced30
9 changed files with 86 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@@ -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 */

View File

@@ -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[];
}

View File

@@ -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]);

View File

@@ -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" });
});

View File

@@ -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. */

View File

@@ -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);