FN-5914: fix mission activity counts and chronology
Keep mission activity counts accurate before event data loads and show events in chronological order. - add persisted mission event counts to core mission summaries and dashboard mission summary types - use the summary event count for the Activity tab preload badge instead of the currently loaded page total - render mission activity oldest-to-newest, keep load-more prepends stable, and scroll to the latest event on initial/live updates - extend mission store, dashboard, and MissionManager tests for event count and ordering coverage Files changed: packages/core/src/__tests__/mission-store.test.ts | 19 ++++ packages/core/src/mission-store.ts | 24 ++++- packages/dashboard/app/api/legacy.ts | 2 + .../dashboard/app/components/MissionManager.tsx | 19 ++-- .../components/__tests__/MissionManager.test.tsx | 107 ++++++++++++++++----- packages/dashboard/app/components/mission-types.ts | 1 + .../dashboard/src/__tests__/mission-e2e.test.ts | 7 +- 7 files changed, 143 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-5914 Fusion-Task-Lineage: b3119e94-66bd-437d-bcde-d5e4e43cb29e
This commit is contained in:
@@ -246,6 +246,7 @@ describe("MissionStore", () => {
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
});
|
||||
@@ -324,6 +325,18 @@ describe("MissionStore", () => {
|
||||
expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(2);
|
||||
});
|
||||
|
||||
it("getMissionSummary reports unfiltered event counts", () => {
|
||||
const mission = store.createMission({ title: "Eventful mission" });
|
||||
|
||||
expect(store.getMissionSummary(mission.id).eventCount).toBe(0);
|
||||
|
||||
store.logMissionEvent(mission.id, "mission_started", "started");
|
||||
store.logMissionEvent(mission.id, "warning", "warning");
|
||||
store.logMissionEvent(mission.id, "error", "error");
|
||||
|
||||
expect(store.getMissionSummary(mission.id).eventCount).toBe(3);
|
||||
});
|
||||
|
||||
it("findNextPendingSlice skips completed slices in earlier milestones", () => {
|
||||
const mission = store.createMission({ title: "Next pending" });
|
||||
const m1 = store.addMilestone(mission.id, { title: "M1" });
|
||||
@@ -398,6 +411,7 @@ describe("MissionStore", () => {
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
|
||||
@@ -409,6 +423,7 @@ describe("MissionStore", () => {
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 0,
|
||||
});
|
||||
|
||||
@@ -420,6 +435,7 @@ describe("MissionStore", () => {
|
||||
totalFeatures: 2,
|
||||
completedFeatures: 1,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 50,
|
||||
});
|
||||
});
|
||||
@@ -437,6 +453,8 @@ describe("MissionStore", () => {
|
||||
createGoalInDb(db, "G-004", "Reliability");
|
||||
store.linkGoal(mission.id, "G-003");
|
||||
store.linkGoal(mission.id, "G-004");
|
||||
store.logMissionEvent(mission.id, "mission_started", "started");
|
||||
store.logMissionEvent(mission.id, "warning", "warning");
|
||||
|
||||
const singleSummary = store.getMissionSummary(mission.id);
|
||||
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
|
||||
@@ -446,6 +464,7 @@ describe("MissionStore", () => {
|
||||
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
|
||||
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
|
||||
expect(batchedResult.summary.linkedGoalCount).toBe(singleSummary.linkedGoalCount);
|
||||
expect(batchedResult.summary.eventCount).toBe(singleSummary.eventCount);
|
||||
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
|
||||
});
|
||||
|
||||
|
||||
@@ -123,6 +123,8 @@ export interface MissionSummary {
|
||||
completedFeatures: number;
|
||||
/** Number of goals linked to the mission */
|
||||
linkedGoalCount: number;
|
||||
/** Unfiltered total number of persisted mission lifecycle events */
|
||||
eventCount: number;
|
||||
/** Computed progress percentage (0–100), based on features or milestones */
|
||||
progressPercent: number;
|
||||
}
|
||||
@@ -772,6 +774,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
.get(missionId) as { count?: number | bigint } | undefined;
|
||||
const linkedGoalCount = Number(linkedGoalRow?.count ?? 0);
|
||||
|
||||
const eventCountRow = this.db
|
||||
.prepare("SELECT COUNT(*) AS count FROM mission_events WHERE missionId = ?")
|
||||
.get(missionId) as { count?: number | bigint } | undefined;
|
||||
const eventCount = Number(eventCountRow?.count ?? 0);
|
||||
|
||||
let progressPercent = 0;
|
||||
if (totalFeatures > 0) {
|
||||
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||
@@ -785,6 +792,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
totalFeatures,
|
||||
completedFeatures,
|
||||
linkedGoalCount,
|
||||
eventCount,
|
||||
progressPercent,
|
||||
};
|
||||
}
|
||||
@@ -829,7 +837,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
linkedGoalRows.map((row) => [row.missionId, Number(row.count ?? 0)]),
|
||||
);
|
||||
|
||||
// 6. Group in-memory: slices by milestoneId, features by sliceId
|
||||
// 6. Batch query mission event counts
|
||||
const eventCountRows = this.db.prepare(
|
||||
"SELECT missionId, COUNT(*) AS count FROM mission_events GROUP BY missionId"
|
||||
).all() as Array<{ missionId: string; count?: number | bigint }>;
|
||||
const eventCountByMissionId = new Map(
|
||||
eventCountRows.map((row) => [row.missionId, Number(row.count ?? 0)]),
|
||||
);
|
||||
|
||||
// 7. Group in-memory: slices by milestoneId, features by sliceId
|
||||
const slicesByMilestoneId = new Map<string, Slice[]>();
|
||||
for (const slice of allSlices) {
|
||||
const list = slicesByMilestoneId.get(slice.milestoneId) || [];
|
||||
@@ -844,7 +860,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
featuresBySliceId.set(feature.sliceId, list);
|
||||
}
|
||||
|
||||
// 7. Group milestones by missionId
|
||||
// 8. Group milestones by missionId
|
||||
const milestonesByMissionId = new Map<string, Milestone[]>();
|
||||
for (const milestone of allMilestones) {
|
||||
const list = milestonesByMissionId.get(milestone.missionId) || [];
|
||||
@@ -852,7 +868,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
milestonesByMissionId.set(milestone.missionId, list);
|
||||
}
|
||||
|
||||
// 8. Compute summary for each mission using grouped data
|
||||
// 9. Compute summary for each mission using grouped data
|
||||
return missions.map((mission) => {
|
||||
const milestones = milestonesByMissionId.get(mission.id) || [];
|
||||
const totalMilestones = milestones.length;
|
||||
@@ -871,6 +887,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
}
|
||||
|
||||
const linkedGoalCount = linkedGoalCountByMissionId.get(mission.id) ?? 0;
|
||||
const eventCount = eventCountByMissionId.get(mission.id) ?? 0;
|
||||
|
||||
let progressPercent = 0;
|
||||
if (totalFeatures > 0) {
|
||||
@@ -887,6 +904,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
totalFeatures,
|
||||
completedFeatures,
|
||||
linkedGoalCount,
|
||||
eventCount,
|
||||
progressPercent,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6919,6 +6919,8 @@ export interface MissionSummary {
|
||||
completedMilestones: number;
|
||||
totalFeatures: number;
|
||||
completedFeatures: number;
|
||||
linkedGoalCount: number;
|
||||
eventCount: number;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -918,6 +918,15 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
|
||||
const activityEventsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const activityTabEventCount = useMemo(() => {
|
||||
if (!selectedMission?.id) {
|
||||
return eventsTotal;
|
||||
}
|
||||
return missions.find((mission) => mission.id === selectedMission.id)?.summary?.eventCount ?? eventsTotal;
|
||||
}, [eventsTotal, missions, selectedMission?.id]);
|
||||
|
||||
const displayedMissionEvents = useMemo(() => [...missionEvents].reverse(), [missionEvents]);
|
||||
|
||||
// Keep latest state available to long-lived SSE handlers without reconnect churn.
|
||||
missionsRef.current = missions;
|
||||
selectedMissionRef.current = selectedMission;
|
||||
@@ -1538,10 +1547,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
|
||||
if (shouldAutoScroll) {
|
||||
requestAnimationFrame(() => {
|
||||
const container = activityEventsContainerRef.current;
|
||||
if (container) {
|
||||
container.scrollTop = 0;
|
||||
}
|
||||
scrollActivityToLatest();
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -1584,6 +1590,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
projectId,
|
||||
refreshMissionSidebar,
|
||||
refreshValidationTelemetry,
|
||||
scrollActivityToLatest,
|
||||
]);
|
||||
|
||||
// Mission handlers
|
||||
@@ -2752,7 +2759,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
aria-selected={activeTab === "activity"}
|
||||
data-testid="mission-tab-activity"
|
||||
>
|
||||
Activity ({eventsTotal})
|
||||
Activity ({activityTabEventCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3994,7 +4001,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
className="mission-events"
|
||||
data-testid="mission-activity-events"
|
||||
>
|
||||
{missionEvents.map((event) => {
|
||||
{displayedMissionEvents.map((event) => {
|
||||
const hasMetadata = Boolean(event.metadata && Object.keys(event.metadata).length > 0);
|
||||
const metadataExpanded = expandedEventMetadata.has(event.id);
|
||||
|
||||
|
||||
@@ -92,6 +92,15 @@ const mockMissions = [
|
||||
status: "planning",
|
||||
interviewState: "not_started",
|
||||
milestones: [],
|
||||
summary: {
|
||||
totalMilestones: 1,
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 2,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 4,
|
||||
progressPercent: 0,
|
||||
},
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
@@ -110,6 +119,7 @@ const mockMissions = [
|
||||
totalFeatures: 5,
|
||||
completedFeatures: 3,
|
||||
linkedGoalCount: 1,
|
||||
eventCount: 2,
|
||||
progressPercent: 60,
|
||||
},
|
||||
createdAt: "2026-01-02T00:00:00.000Z",
|
||||
@@ -302,20 +312,12 @@ const mockMilestoneValidationTelemetry = {
|
||||
|
||||
const mockMissionEvents = [
|
||||
{
|
||||
id: "E-001",
|
||||
id: "E-004",
|
||||
missionId: "M-001",
|
||||
eventType: "mission_started",
|
||||
description: "Mission started",
|
||||
metadata: null,
|
||||
timestamp: "2026-01-03T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "E-002",
|
||||
missionId: "M-001",
|
||||
eventType: "warning",
|
||||
description: "Task queue is delayed",
|
||||
metadata: { queueDepth: 4 },
|
||||
timestamp: "2026-01-03T10:10:00.000Z",
|
||||
eventType: "autopilot_state_changed",
|
||||
description: "Autopilot moved to watching",
|
||||
metadata: { previous: "inactive", next: "watching" },
|
||||
timestamp: "2026-01-03T10:30:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "E-003",
|
||||
@@ -326,12 +328,20 @@ const mockMissionEvents = [
|
||||
timestamp: "2026-01-03T10:20:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "E-004",
|
||||
id: "E-002",
|
||||
missionId: "M-001",
|
||||
eventType: "autopilot_state_changed",
|
||||
description: "Autopilot moved to watching",
|
||||
metadata: { previous: "inactive", next: "watching" },
|
||||
timestamp: "2026-01-03T10:30:00.000Z",
|
||||
eventType: "warning",
|
||||
description: "Task queue is delayed",
|
||||
metadata: { queueDepth: 4 },
|
||||
timestamp: "2026-01-03T10:10:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "E-001",
|
||||
missionId: "M-001",
|
||||
eventType: "mission_started",
|
||||
description: "Mission started",
|
||||
metadata: null,
|
||||
timestamp: "2026-01-03T10:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -342,7 +352,7 @@ const mockMissionEventsPaged = Array.from({ length: 65 }, (_, index) => ({
|
||||
description: `Mission event ${index + 1}`,
|
||||
metadata: { index: index + 1 },
|
||||
timestamp: new Date(Date.UTC(2026, 0, 3, 10, index)).toISOString(),
|
||||
}));
|
||||
})).reverse();
|
||||
|
||||
/** Create a mock Response that matches the real api() function's expectations (text + content-type headers) */
|
||||
function mockApiResponse(data: unknown) {
|
||||
@@ -1158,7 +1168,7 @@ describe("MissionManager", () => {
|
||||
expect(screen.queryByText(/"queueDepth": 4/)).toBeNull();
|
||||
});
|
||||
|
||||
it("loads more mission activity events", async () => {
|
||||
it("loads more older mission activity events at the top", async () => {
|
||||
globalThis.fetch = createDetailFetchMock(mockMissionEventsPaged as unknown as typeof mockMissionEvents);
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
@@ -1173,6 +1183,8 @@ describe("MissionManager", () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId("mission-tab-activity"));
|
||||
|
||||
const eventsContainer = await screen.findByTestId("mission-activity-events");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Mission event 50")).toBeDefined();
|
||||
expect(
|
||||
@@ -1181,6 +1193,10 @@ describe("MissionManager", () => {
|
||||
}),
|
||||
).toBeDefined();
|
||||
expect(screen.getByTestId("mission-activity-load-more")).toBeDefined();
|
||||
|
||||
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
|
||||
expect(eventDescriptions[0]?.textContent).toBe("Mission event 16");
|
||||
expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId("mission-activity-load-more"));
|
||||
@@ -1189,10 +1205,30 @@ describe("MissionManager", () => {
|
||||
const activityCount = document.querySelector(".mission-detail__activity-count");
|
||||
expect(activityCount?.textContent?.trim()).toBe("65 of 65");
|
||||
expect(screen.queryByTestId("mission-activity-load-more")).toBeNull();
|
||||
|
||||
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
|
||||
expect(eventDescriptions[0]?.textContent).toBe("Mission event 1");
|
||||
expect(eventDescriptions[eventDescriptions.length - 1]?.textContent).toBe("Mission event 65");
|
||||
}, { timeout: 5000 });
|
||||
}, 15000);
|
||||
|
||||
it("auto-scrolls to latest mission activity on initial load", async () => {
|
||||
it("shows the summary event count before activity events load", async () => {
|
||||
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
|
||||
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 (4)");
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -1217,14 +1253,29 @@ describe("MissionManager", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Mission started")).toBeDefined();
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalled();
|
||||
expect(scrollIntoViewSpy).toHaveBeenCalledWith({ block: "end", behavior: "auto" });
|
||||
});
|
||||
|
||||
const eventsContainer = await screen.findByTestId("mission-activity-events");
|
||||
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
|
||||
expect(eventDescriptions.map((node) => node.textContent)).toEqual([
|
||||
"Mission started",
|
||||
"Task queue is delayed",
|
||||
"Feature F-001 completed",
|
||||
"Autopilot moved to watching",
|
||||
]);
|
||||
});
|
||||
|
||||
it("prepends real-time mission events and scrolls to top when near bottom", async () => {
|
||||
it("appends real-time mission events at the bottom and scrolls to latest when near bottom", async () => {
|
||||
globalThis.fetch = createDetailFetchMock(mockMissionEvents);
|
||||
globalThis.EventSource = MockEventSource as unknown as typeof globalThis.EventSource;
|
||||
|
||||
const scrollIntoViewSpy = vi.fn();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
|
||||
configurable: true,
|
||||
value: scrollIntoViewSpy,
|
||||
});
|
||||
|
||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1258,11 +1309,17 @@ describe("MissionManager", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Real-time warning event")).toBeDefined();
|
||||
expect(eventsContainer.scrollTop).toBe(0);
|
||||
expect(scrollIntoViewSpy).toHaveBeenLastCalledWith({ block: "end", behavior: "auto" });
|
||||
});
|
||||
|
||||
const eventDescriptions = Array.from(eventsContainer.querySelectorAll(".mission-event__description"));
|
||||
expect(eventDescriptions[0]?.textContent).toBe("Real-time warning event");
|
||||
expect(eventDescriptions.map((node) => node.textContent)).toEqual([
|
||||
"Mission started",
|
||||
"Task queue is delayed",
|
||||
"Feature F-001 completed",
|
||||
"Autopilot moved to watching",
|
||||
"Real-time warning event",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores real-time mission events for non-selected missions", async () => {
|
||||
|
||||
@@ -260,6 +260,7 @@ export interface MissionSummary {
|
||||
totalFeatures: number;
|
||||
completedFeatures: number;
|
||||
linkedGoalCount?: number;
|
||||
eventCount?: number;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,8 @@ function createMockMissionStore(options?: {
|
||||
completedMilestones: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 0,
|
||||
},
|
||||
}))
|
||||
@@ -141,10 +143,11 @@ function createMockMissionStore(options?: {
|
||||
getMissionSummary: vi.fn((_missionId: string) => ({
|
||||
totalMilestones: 0,
|
||||
completedMilestones: 0,
|
||||
totalSlices: 0,
|
||||
completedSlices: 0,
|
||||
totalFeatures: 0,
|
||||
completedFeatures: 0,
|
||||
linkedGoalCount: 0,
|
||||
eventCount: 0,
|
||||
progressPercent: 0,
|
||||
})),
|
||||
|
||||
getMissionEvents: vi.fn((missionId: string, options?: { limit?: number; offset?: number; eventType?: string }) => {
|
||||
|
||||
Reference in New Issue
Block a user