FN-5898: add unlinked mission goal indicator

Document the manual no-backfill mission-goal policy and flag active missions that still need explicit goal links.

- document the mission→goal linkage model, explicit no-backfill decision, and manual linkage workflow in missions docs
- include linkedGoalCount in mission summaries and cover single-summary/batched-summary behavior in MissionStore tests
- show an Unlinked badge for active non-interview missions with zero linked goals and add MissionManager coverage

Files changed:
 .changeset/fn-5898-mission-goal-unlinked-indicator.md     |  5 ++
 docs/missions.md                                   | 18 ++++--
 packages/core/src/__tests__/mission-store.test.ts  | 32 +++++++++-
 packages/core/src/mission-store.ts                 | 25 +++++++-
 packages/dashboard/app/components/MissionManager.css    |  6 ++
 packages/dashboard/app/components/MissionManager.tsx    | 14 ++++
 packages/dashboard/app/components/__tests__/MissionManager.test.tsx   | 74 ++++++++++++++++++++++
 packages/dashboard/app/components/mission-types.ts |  1 +
 8 files changed, 166 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-5898

Fusion-Task-Lineage: e31e9544-4e03-4b52-afbb-e372314b997e
This commit is contained in:
gsxdsm
2026-06-02 18:15:14 -07:00
parent cc18206bc5
commit 577ce12a18
8 changed files with 166 additions and 9 deletions

View File

@@ -26,6 +26,13 @@ function createTaskInDb(
).run(taskId, description, options?.column ?? "triage", status ?? null, now, now, options?.deletedAt ?? null);
}
function createGoalInDb(database: Database, goalId: string, title = "Test goal"): void {
const now = new Date().toISOString();
database.prepare(
"INSERT INTO goals (id, title, description, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)"
).run(goalId, title, null, "active", now, now);
}
describe("MissionStore", () => {
let tmpDir: string;
let fusionDir: string;
@@ -238,6 +245,7 @@ describe("MissionStore", () => {
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
linkedGoalCount: 0,
progressPercent: 0,
});
});
@@ -303,6 +311,19 @@ describe("MissionStore", () => {
expect(summary.progressPercent).toBe(33);
});
it("getMissionSummary reports linked goal counts", () => {
const mission = store.createMission({ title: "Goal-linked mission" });
createGoalInDb(db, "G-001", "North Star");
createGoalInDb(db, "G-002", "Reliability");
expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(0);
store.linkGoal(mission.id, "G-001");
store.linkGoal(mission.id, "G-002");
expect(store.getMissionSummary(mission.id).linkedGoalCount).toBe(2);
});
it("findNextPendingSlice skips completed slices in earlier milestones", () => {
const mission = store.createMission({ title: "Next pending" });
const m1 = store.addMilestone(mission.id, { title: "M1" });
@@ -376,6 +397,7 @@ describe("MissionStore", () => {
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
linkedGoalCount: 0,
progressPercent: 0,
});
@@ -386,6 +408,7 @@ describe("MissionStore", () => {
completedMilestones: 0,
totalFeatures: 0,
completedFeatures: 0,
linkedGoalCount: 0,
progressPercent: 0,
});
@@ -396,6 +419,7 @@ describe("MissionStore", () => {
completedMilestones: 1,
totalFeatures: 2,
completedFeatures: 1,
linkedGoalCount: 0,
progressPercent: 50,
});
});
@@ -408,8 +432,11 @@ describe("MissionStore", () => {
store.updateFeature(f1.id, { status: "done" });
const f2 = store.addFeature(slice.id, { title: "F2" });
store.updateFeature(f2.id, { status: "done" });
const f3 = store.addFeature(slice.id, { title: "F3" });
// f3 not done
store.addFeature(slice.id, { title: "F3" });
createGoalInDb(db, "G-003", "North Star");
createGoalInDb(db, "G-004", "Reliability");
store.linkGoal(mission.id, "G-003");
store.linkGoal(mission.id, "G-004");
const singleSummary = store.getMissionSummary(mission.id);
const batchedResult = store.listMissionsWithSummaries().find((m) => m.id === mission.id)!;
@@ -418,6 +445,7 @@ describe("MissionStore", () => {
expect(batchedResult.summary.completedMilestones).toBe(singleSummary.completedMilestones);
expect(batchedResult.summary.totalFeatures).toBe(singleSummary.totalFeatures);
expect(batchedResult.summary.completedFeatures).toBe(singleSummary.completedFeatures);
expect(batchedResult.summary.linkedGoalCount).toBe(singleSummary.linkedGoalCount);
expect(batchedResult.summary.progressPercent).toBe(singleSummary.progressPercent);
});

View File

@@ -121,6 +121,8 @@ export interface MissionSummary {
totalFeatures: number;
/** Number of features with status "done" */
completedFeatures: number;
/** Number of goals linked to the mission */
linkedGoalCount: number;
/** Computed progress percentage (0100), based on features or milestones */
progressPercent: number;
}
@@ -765,6 +767,11 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
}
const linkedGoalRow = this.db
.prepare("SELECT COUNT(*) AS count FROM mission_goals WHERE missionId = ?")
.get(missionId) as { count?: number | bigint } | undefined;
const linkedGoalCount = Number(linkedGoalRow?.count ?? 0);
let progressPercent = 0;
if (totalFeatures > 0) {
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
@@ -777,6 +784,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
completedMilestones,
totalFeatures,
completedFeatures,
linkedGoalCount,
progressPercent,
};
}
@@ -813,7 +821,15 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
).all() as unknown as FeatureRow[];
const allFeatures = featureRows.map((row) => this.rowToFeature(row));
// 5. Group in-memory: slices by milestoneId, features by sliceId
// 5. Batch query linked goal counts
const linkedGoalRows = this.db.prepare(
"SELECT missionId, COUNT(*) AS count FROM mission_goals GROUP BY missionId"
).all() as Array<{ missionId: string; count?: number | bigint }>;
const linkedGoalCountByMissionId = new Map(
linkedGoalRows.map((row) => [row.missionId, Number(row.count ?? 0)]),
);
// 6. 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) || [];
@@ -828,7 +844,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
featuresBySliceId.set(feature.sliceId, list);
}
// 6. Group milestones by missionId
// 7. Group milestones by missionId
const milestonesByMissionId = new Map<string, Milestone[]>();
for (const milestone of allMilestones) {
const list = milestonesByMissionId.get(milestone.missionId) || [];
@@ -836,7 +852,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
milestonesByMissionId.set(milestone.missionId, list);
}
// 7. Compute summary for each mission using grouped data
// 8. Compute summary for each mission using grouped data
return missions.map((mission) => {
const milestones = milestonesByMissionId.get(mission.id) || [];
const totalMilestones = milestones.length;
@@ -854,6 +870,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
}
}
const linkedGoalCount = linkedGoalCountByMissionId.get(mission.id) ?? 0;
let progressPercent = 0;
if (totalFeatures > 0) {
progressPercent = Math.round((completedFeatures / totalFeatures) * 100);
@@ -868,6 +886,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
completedMilestones,
totalFeatures,
completedFeatures,
linkedGoalCount,
progressPercent,
},
};