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:
@@ -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 */
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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()} />);
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
================================================================ */
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user