feat(FN-971): add mission status summary with progress bar to mission list

- Add MissionSummary type and getMissionSummary method to MissionStore
- Include mission summary data in GET /missions API endpoint
- Update frontend types and API client for mission summary
- Display mission status summary with progress bar on MissionManager list cards
- Add CSS styles for progress bar and summary display
- Update MissionManager tests for summary display
This commit is contained in:
gsxdsm
2026-04-05 17:19:48 -07:00
parent f6bd237f46
commit 88df7387b5
8 changed files with 189 additions and 8 deletions

View File

@@ -2313,6 +2313,18 @@ export interface Mission {
updatedAt: string;
}
/** Status summary for a mission card, computed from hierarchy */
export interface MissionSummary {
totalMilestones: number;
completedMilestones: number;
totalFeatures: number;
completedFeatures: number;
progressPercent: number;
}
/** Mission with optional status summary (returned by list endpoint) */
export type MissionWithSummary = Mission & { summary?: MissionSummary };
/** Milestone entity */
export interface Milestone {
id: string;
@@ -2368,9 +2380,9 @@ export interface MissionWithHierarchy extends Mission {
milestones: MilestoneWithSlices[];
}
/** Fetch all missions */
export function fetchMissions(projectId?: string): Promise<Mission[]> {
return api<Mission[]>(withProjectId("/missions", projectId));
/** Fetch all missions with status summary */
export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]> {
return api<MissionWithSummary[]>(withProjectId("/missions", projectId));
}
/** Create a new mission */

View File

@@ -27,6 +27,7 @@ import { MissionInterviewModal } from "./MissionInterviewModal";
import type {
Mission,
MissionWithHierarchy,
MissionWithSummary,
Milestone,
Slice,
MissionFeature,
@@ -159,7 +160,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
};
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
const [missions, setMissions] = useState<Mission[]>([]);
const [missions, setMissions] = useState<MissionWithSummary[]>([]);
const [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
const [loading, setLoading] = useState(true);
const [detailLoading, setDetailLoading] = useState(false);
@@ -1367,10 +1368,12 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
{/* Mission items */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{missions.map((mission: any) => {
const m = mission as { id: string; title: string; description?: string; status: string };
const m = mission as { id: string; title: string; description?: string; status: string; summary?: { totalMilestones: number; completedMilestones: number; totalFeatures: number; completedFeatures: number; progressPercent: number } };
const selId = selectedMission as { id: string } | null;
const isSelected = selId && selId.id === m.id;
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
const summary = m.summary;
const hasContent = summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0);
return (
<div
key={m.id}
@@ -1394,6 +1397,22 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
{m.description && (
<p className="mission-list__item-description">{m.description}</p>
)}
{hasContent && (
<div className="mission-list__item-summary">
<span className="mission-list__item-stat">
{summary.completedMilestones}/{summary.totalMilestones} milestones
</span>
<span className="mission-list__item-stat">
{summary.completedFeatures}/{summary.totalFeatures} features
</span>
<div className="mission-list__item-progress">
<div
className="mission-list__item-progress-bar"
style={{ width: `${summary.progressPercent}%` }}
/>
</div>
</div>
)}
</div>
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
{m.status === "active" && (

View File

@@ -21,6 +21,13 @@ const mockMissions = [
status: "active",
autoAdvance: true,
milestones: [],
summary: {
totalMilestones: 2,
completedMilestones: 1,
totalFeatures: 5,
completedFeatures: 3,
progressPercent: 60,
},
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
},
@@ -168,6 +175,41 @@ describe("MissionManager", () => {
});
});
it("shows summary stats when mission has summary data", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
// M-002 has summary: { totalMilestones: 2, completedMilestones: 1, totalFeatures: 5, completedFeatures: 3 }
expect(screen.getByText("1/2 milestones")).toBeDefined();
expect(screen.getByText("3/5 features")).toBeDefined();
});
});
it("hides summary section for missions without summary data", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
// M-001 has no summary — no stats should appear for it
expect(screen.queryByText("0/0 milestones")).toBeNull();
});
// M-002 has summary so these should exist
expect(screen.getByText("1/2 milestones")).toBeDefined();
});
it("renders progress bar for missions with summary", async () => {
globalThis.fetch = createFetchMock();
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => {
// Progress bar element should exist for M-002 (has summary with progressPercent: 60)
const progressBar = document.querySelector(".mission-list__item-progress-bar") as HTMLElement;
expect(progressBar).toBeDefined();
expect(progressBar?.style.width).toBe("60%");
});
});
it("shows empty state when no missions exist", async () => {
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);

View File

@@ -59,6 +59,18 @@ export interface Milestone {
export type MilestoneWithSlices = Milestone;
/** Status summary for a mission card, computed from hierarchy */
export interface MissionSummary {
totalMilestones: number;
completedMilestones: number;
totalFeatures: number;
completedFeatures: number;
progressPercent: number;
}
/** Mission with optional status summary (returned by list endpoint) */
export type MissionWithSummary = Mission & { summary?: MissionSummary };
export interface MissionWithHierarchy extends Mission {
milestones: Milestone[];
}

View File

@@ -18125,6 +18125,35 @@ html .column.drag-over * {
flex-shrink: 0;
}
.mission-list__item-summary {
display: flex;
align-items: center;
gap: 12px;
margin-top: var(--space-xs);
}
.mission-list__item-stat {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
}
.mission-list__item-progress {
flex: 1;
height: 4px;
background: var(--bg-tertiary);
border-radius: 2px;
overflow: hidden;
min-width: 40px;
}
.mission-list__item-progress-bar {
height: 100%;
background: var(--accent-green, #22c55e);
border-radius: 2px;
transition: width 0.3s ease;
}
/* ================================================================
Mission Detail View
================================================================ */

View File

@@ -31,6 +31,7 @@ import type {
FeatureStatus,
InterviewState,
} from "@fusion/core";
import type { MissionSummary } from "@fusion/core";
import {
MISSION_STATUSES,
MILESTONE_STATUSES,
@@ -184,7 +185,7 @@ export function createMissionRouter(store: TaskStore): Router {
/**
* GET /api/missions
* List all missions ordered by createdAt desc
* List all missions ordered by createdAt desc, with status summary
*/
router.get(
"/",
@@ -192,7 +193,12 @@ export function createMissionRouter(store: TaskStore): Router {
const missions = missionStore.listMissions();
// Sort by createdAt desc
missions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json(missions);
// Attach status summary to each mission
const missionsWithSummary = missions.map((mission) => ({
...mission,
summary: missionStore.getMissionSummary(mission.id),
}));
res.json(missionsWithSummary);
})
);