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:
@@ -110,7 +110,7 @@ export type {
|
|||||||
FeatureLinkedPayload,
|
FeatureLinkedPayload,
|
||||||
} from "./mission-types.js";
|
} from "./mission-types.js";
|
||||||
export { MissionStore } from "./mission-store.js";
|
export { MissionStore } from "./mission-store.js";
|
||||||
export type { MissionStoreEvents } from "./mission-store.js";
|
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";
|
||||||
|
|
||||||
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────
|
// ── Central Infrastructure (Multi-Project Support) ───────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,22 @@ import type {
|
|||||||
InterviewState,
|
InterviewState,
|
||||||
} from "./mission-types.js";
|
} from "./mission-types.js";
|
||||||
|
|
||||||
|
// ── Mission Summary Type ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Status summary for a mission, computed from its hierarchy. */
|
||||||
|
export interface MissionSummary {
|
||||||
|
/** Total number of milestones in the mission */
|
||||||
|
totalMilestones: number;
|
||||||
|
/** Number of milestones with status "complete" */
|
||||||
|
completedMilestones: number;
|
||||||
|
/** Total number of features across all slices */
|
||||||
|
totalFeatures: number;
|
||||||
|
/** Number of features with status "done" */
|
||||||
|
completedFeatures: number;
|
||||||
|
/** Computed progress percentage (0–100), based on features or milestones */
|
||||||
|
progressPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event Types ─────────────────────────────────────────────────────
|
// ── Event Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface MissionStoreEvents {
|
export interface MissionStoreEvents {
|
||||||
@@ -247,6 +263,51 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
return (rows as any[]).map((row) => this.rowToMission(row));
|
return (rows as any[]).map((row) => this.rowToMission(row));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a status summary for a mission, computing milestone and feature counts
|
||||||
|
* and progress percentage from the hierarchy.
|
||||||
|
*
|
||||||
|
* Progress is calculated as:
|
||||||
|
* - (completedFeatures / totalFeatures) * 100 if there are features
|
||||||
|
* - (completedMilestones / totalMilestones) * 100 if there are milestones but no features
|
||||||
|
* - 0 otherwise
|
||||||
|
*
|
||||||
|
* @param missionId - Mission ID
|
||||||
|
* @returns MissionSummary with counts and progress
|
||||||
|
*/
|
||||||
|
getMissionSummary(missionId: string): MissionSummary {
|
||||||
|
const milestones = this.listMilestones(missionId);
|
||||||
|
const totalMilestones = milestones.length;
|
||||||
|
const completedMilestones = milestones.filter((m) => m.status === "complete").length;
|
||||||
|
|
||||||
|
let totalFeatures = 0;
|
||||||
|
let completedFeatures = 0;
|
||||||
|
|
||||||
|
for (const milestone of milestones) {
|
||||||
|
const slices = this.listSlices(milestone.id);
|
||||||
|
for (const slice of slices) {
|
||||||
|
const features = this.listFeatures(slice.id);
|
||||||
|
totalFeatures += features.length;
|
||||||
|
completedFeatures += features.filter((f) => f.status === "done").length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let progressPercent = 0;
|
||||||
|
if (totalFeatures > 0) {
|
||||||
|
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
|
||||||
|
} else if (totalMilestones > 0) {
|
||||||
|
progressPercent = Math.round((completedMilestones / totalMilestones) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMilestones,
|
||||||
|
completedMilestones,
|
||||||
|
totalFeatures,
|
||||||
|
completedFeatures,
|
||||||
|
progressPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a mission.
|
* Update a mission.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2313,6 +2313,18 @@ export interface Mission {
|
|||||||
updatedAt: string;
|
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 */
|
/** Milestone entity */
|
||||||
export interface Milestone {
|
export interface Milestone {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2368,9 +2380,9 @@ export interface MissionWithHierarchy extends Mission {
|
|||||||
milestones: MilestoneWithSlices[];
|
milestones: MilestoneWithSlices[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch all missions */
|
/** Fetch all missions with status summary */
|
||||||
export function fetchMissions(projectId?: string): Promise<Mission[]> {
|
export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]> {
|
||||||
return api<Mission[]>(withProjectId("/missions", projectId));
|
return api<MissionWithSummary[]>(withProjectId("/missions", projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new mission */
|
/** Create a new mission */
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { MissionInterviewModal } from "./MissionInterviewModal";
|
|||||||
import type {
|
import type {
|
||||||
Mission,
|
Mission,
|
||||||
MissionWithHierarchy,
|
MissionWithHierarchy,
|
||||||
|
MissionWithSummary,
|
||||||
Milestone,
|
Milestone,
|
||||||
Slice,
|
Slice,
|
||||||
MissionFeature,
|
MissionFeature,
|
||||||
@@ -159,7 +160,7 @@ const EMPTY_FEATURE_FORM: FeatureFormData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectTask, availableTasks = [], resumeSessionId, targetMissionId }: MissionManagerProps) {
|
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 [selectedMission, setSelectedMission] = useState<MissionWithHierarchy | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [detailLoading, setDetailLoading] = useState(false);
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
@@ -1367,10 +1368,12 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
|||||||
{/* Mission items */}
|
{/* Mission items */}
|
||||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||||
{missions.map((mission: 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 selId = selectedMission as { id: string } | null;
|
||||||
const isSelected = selId && selId.id === m.id;
|
const isSelected = selId && selId.id === m.id;
|
||||||
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
|
const statusColors = missionStatusColors[m.status as MissionStatus] || { bg: "", text: "" };
|
||||||
|
const summary = m.summary;
|
||||||
|
const hasContent = summary && (summary.totalMilestones > 0 || summary.totalFeatures > 0);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -1394,6 +1397,22 @@ export function MissionManager({ isOpen, onClose, addToast, projectId, onSelectT
|
|||||||
{m.description && (
|
{m.description && (
|
||||||
<p className="mission-list__item-description">{m.description}</p>
|
<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>
|
||||||
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
|
<div className="mission-list__item-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
{m.status === "active" && (
|
{m.status === "active" && (
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ const mockMissions = [
|
|||||||
status: "active",
|
status: "active",
|
||||||
autoAdvance: true,
|
autoAdvance: true,
|
||||||
milestones: [],
|
milestones: [],
|
||||||
|
summary: {
|
||||||
|
totalMilestones: 2,
|
||||||
|
completedMilestones: 1,
|
||||||
|
totalFeatures: 5,
|
||||||
|
completedFeatures: 3,
|
||||||
|
progressPercent: 60,
|
||||||
|
},
|
||||||
createdAt: "2026-01-02T00:00:00.000Z",
|
createdAt: "2026-01-02T00:00:00.000Z",
|
||||||
updatedAt: "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 () => {
|
it("shows empty state when no missions exist", async () => {
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
|
globalThis.fetch = vi.fn().mockResolvedValue(mockApiResponse([]));
|
||||||
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
render(<MissionManager isOpen={true} onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|||||||
@@ -59,6 +59,18 @@ export interface Milestone {
|
|||||||
|
|
||||||
export type MilestoneWithSlices = 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 {
|
export interface MissionWithHierarchy extends Mission {
|
||||||
milestones: Milestone[];
|
milestones: Milestone[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18125,6 +18125,35 @@ html .column.drag-over * {
|
|||||||
flex-shrink: 0;
|
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
|
Mission Detail View
|
||||||
================================================================ */
|
================================================================ */
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
FeatureStatus,
|
FeatureStatus,
|
||||||
InterviewState,
|
InterviewState,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
|
import type { MissionSummary } from "@fusion/core";
|
||||||
import {
|
import {
|
||||||
MISSION_STATUSES,
|
MISSION_STATUSES,
|
||||||
MILESTONE_STATUSES,
|
MILESTONE_STATUSES,
|
||||||
@@ -184,7 +185,7 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/missions
|
* GET /api/missions
|
||||||
* List all missions ordered by createdAt desc
|
* List all missions ordered by createdAt desc, with status summary
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
@@ -192,7 +193,12 @@ export function createMissionRouter(store: TaskStore): Router {
|
|||||||
const missions = missionStore.listMissions();
|
const missions = missionStore.listMissions();
|
||||||
// Sort by createdAt desc
|
// Sort by createdAt desc
|
||||||
missions.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
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