FN-6490: add mission-goal linking controls

Add bidirectional mission-goal linking in the dashboard with route support and tests.

- Add Goals view controls to list, link, unlink, and navigate linked missions.
- Add Mission detail controls to link active goals and unlink existing goal chips while refreshing summaries.
- Expose goal-to-mission lookup through the goals API and cover route and UI behavior.
- Update mission and dashboard docs for cross-linking flows.

Files changed:
 docs/dashboard-guide.md                            |   4 +-
 docs/missions.md                                   |   8 +-
 packages/dashboard/app/App.tsx                     |   2 +-
 packages/dashboard/app/components/GoalsView.css    |  90 +++++++++-
 packages/dashboard/app/components/GoalsView.tsx    | 191 ++++++++++++++++++++-
 .../dashboard/app/components/MissionManager.css    |  38 +++-
 .../dashboard/app/components/MissionManager.tsx    | 125 +++++++++++++-
 .../app/components/__tests__/GoalsView.test.tsx    | 149 ++++++++++++++--
 .../__tests__/MissionManager.goal-links.test.tsx   | 140 +++++++++++++++
 .../dashboard/src/__tests__/goals-routes.test.ts   |  70 +++++++-
 packages/dashboard/src/goals-routes.ts             |  37 +++-
 11 files changed, 821 insertions(+), 33 deletions(-)

Fusion-Task-Id: FN-6490

Fusion-Task-Lineage: aa51e053-04c5-4124-90a0-bc421c8f979f
This commit is contained in:
gsxdsm
2026-06-15 15:05:36 -07:00
parent 222b5cedf3
commit 963362cd43
11 changed files with 821 additions and 33 deletions

View File

@@ -569,7 +569,8 @@ Goals view is a strategic-goals surface backed by the Goals REST API.
What it shows:
- Header with active-goal count (`N active goals`) and an **Add Goal** action
- Goal cards with title, optional description, and `Status: active|archived`
- Goal cards with title, optional description, `Status: active|archived`, and a **Linked Missions** section
- Linked-mission chips navigate to Mission Manager, each chip has an unlink control, and the card picker hides missions already linked to that goal
- Empty state when no goals exist: `No goals yet. Add one to begin tracking strategic outcomes.`
Data behavior:
@@ -578,6 +579,7 @@ Data behavior:
- Add-form drafting: **Draft with AI** sends the typed goal title to `POST /api/ai/draft-goal-description` and drops the returned `{ description }` into the description textarea for review/editing before save
- Edit: per-card inline form patches title/description via `PATCH /api/goals/:id`
- Archive/unarchive: `POST /api/goals/:id/archive` and `POST /api/goals/:id/unarchive`
- Linked missions: `GET /api/goals/:id/missions` for the reverse lookup, then `POST`/`DELETE /api/missions/:missionId/goals/:goalId` for link/unlink mutations
AI drafting behavior:
- The add-goal form enables **Draft with AI** once the title is non-empty

View File

@@ -46,11 +46,11 @@ Existing missions are intentionally **not** auto-linked to any goals. Fusion doe
### Manual linkage workflow
Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. Read surfaces can show current associations, and operator-facing write surfaces can add or remove links when a mission should explicitly support a goal. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data.
Mission ↔ goal links are created and removed deliberately as part of normal planning and operations work. The dashboard exposes the relationship from both directions: Mission detail has an active-goal picker plus linked-goal chips with unlink controls, and each Goals view card has a mission picker plus linked-mission chips with unlink controls. Archived goals are never offered for new links, duplicate link attempts are no-ops at the store/API layer, and removing the last link restores the empty-state copy rather than leaving an empty control shell. The workflow is intentionally manual so teams can choose the correct strategic relationship per mission instead of inheriting guessed links from older data.
### Unlinked mission indicator
Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. This is a read-only attention badge so operators can quickly find active missions that still need an explicit goal association.
Mission Manager shows an **Unlinked** indicator on active mission cards when `linkedGoalCount` is zero. Linking or unlinking from either dashboard surface refreshes this count so operators can quickly find active missions that still need an explicit goal association.
The engine also emits a workflow insight with advisory key `unlinked_missions_advisory` when it first observes one or more active missions with zero goal links. The insight is advisory only, includes only the affected mission ids plus a count, and is deduped to one stable row so it does not spam on every scheduler heartbeat.
@@ -142,6 +142,7 @@ Fusion surfaces the persisted mission↔goal linkage through REST, CLI, and pi-e
| `PATCH /api/missions/:missionId` | Update mission fields. Optional `goalIds: string[]` replaces the full linked-goal set; `[]` clears links and `undefined` leaves links unchanged. |
| `GET /api/missions/:missionId` | Return `MissionWithHierarchy`, including `linkedGoals` as an always-present array of `Goal` objects for the selected mission and optional `eventCount` as the authoritative unfiltered mission activity total. |
| `GET /api/missions/:missionId/goals` | List linked goals for a mission. Returns `{ goals }`. |
| `GET /api/goals/:goalId/missions` | List linked missions for a goal. Returns `{ missions: [{ id, title, status }] }` and skips stale links whose mission row no longer resolves. |
| `PUT /api/missions/:missionId/goals` | Replace the full linked-goal set with body `{ goalIds: string[] }`. Duplicate ids are deduplicated before reconciliation. |
| `POST /api/missions/:missionId/goals/:goalId` | Idempotently link one goal to a mission. |
| `DELETE /api/missions/:missionId/goals/:goalId` | Idempotently unlink one goal from a mission. |
@@ -154,7 +155,8 @@ The mission detail payload keeps `linkedGoals` separate from the milestone tree
- `fn mission goals <mission-id>` — list linked goals for a mission.
- `fn mission link-goal <mission-id> <goal-id>` — idempotently link a goal; archived goals reject with `GOAL_ARCHIVED`.
- `fn mission unlink-goal <mission-id> <goal-id>` — idempotently unlink a goal, including archived goals.
- Mission detail screens in the dashboard render linked-goal chips in the mission header; selecting a chip opens the Goals view and scrolls/highlights the anchored goal card.
- Dashboard Mission detail lets operators link active goals, unlink existing goal chips, and select a chip to open the Goals view at the anchored goal card.
- Dashboard Goals cards show linked missions, let operators link/unlink missions for that goal, and select a mission chip to open Mission Manager at that mission.
## Mission Planning Tools (pi extension)

View File

@@ -1809,7 +1809,7 @@ function AppInner() {
return (
<PageErrorBoundary>
<Suspense fallback={null}>
<GoalsView anchorGoalId={goalAnchorId} />
<GoalsView anchorGoalId={goalAnchorId} onNavigateToMission={handleOpenMission} />
</Suspense>
</PageErrorBoundary>
);

View File

@@ -95,7 +95,7 @@
.goals-card {
display: flex;
align-items: center;
align-items: stretch;
justify-content: space-between;
gap: var(--space-md);
scroll-margin-top: var(--space-xl);
@@ -164,6 +164,79 @@
gap: var(--space-sm);
}
.goals-linked-missions {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: var(--space-sm);
padding-left: var(--space-md);
border-left: calc(var(--space-xs) / 4) solid var(--border);
}
.goals-linked-missions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.goals-linked-missions-title {
margin: 0;
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
font-weight: 600;
}
.goals-linked-missions-count,
.goals-linked-mission-status,
.goals-linked-missions-empty {
color: var(--text-muted);
}
.goals-linked-missions-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.goals-linked-missions-picker {
flex: 1 1 calc(var(--space-xl) * 10);
min-width: 0;
}
.goals-linked-missions-link-button,
.goals-linked-mission-chip,
.goals-linked-mission-link {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.goals-linked-missions-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.goals-linked-mission-chip {
gap: calc(var(--space-xs) / 2);
padding: calc(var(--space-xs) / 2);
border: calc(var(--space-xs) / 4) solid var(--border);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
}
.goals-linked-mission-link {
border-radius: var(--radius-pill);
}
.goals-linked-missions-empty {
margin: 0;
}
.goals-activate-button {
min-width: calc(var(--space-2xl) * 2);
}
@@ -184,10 +257,23 @@
}
.goals-form-actions,
.goals-card-actions {
.goals-card-actions,
.goals-linked-missions-controls {
flex-direction: column;
}
.goals-linked-missions {
padding-left: 0;
padding-top: var(--space-md);
border-left: 0;
border-top: calc(var(--space-xs) / 4) solid var(--border);
}
.goals-linked-missions-link-button,
.goals-linked-missions-picker {
width: 100%;
}
.goals-card-description-collapsed {
-webkit-line-clamp: 3;
max-height: calc(var(--space-md) * 5);

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { Goal } from "@fusion/core";
import { Plus, Sparkles } from "lucide-react";
import { Link, Plus, Sparkles, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { draftGoalDescription, getRefineErrorMessage } from "../api";
@@ -10,8 +10,15 @@ import "./GoalsView.css";
export interface GoalsViewProps {
initialGoals?: Goal[];
anchorGoalId?: string;
onNavigateToMission?: (missionId: string) => void;
}
type LinkedMission = {
id: string;
title: string;
status: string;
};
const MAX_ACTIVE_GOALS = 5;
const WARNING_THRESHOLD = 3;
@@ -21,7 +28,7 @@ function isCapError(payload: unknown): boolean {
return Boolean(payload && typeof payload === "object" && "code" in payload && (payload as { code?: unknown }).code === "ACTIVE_GOAL_LIMIT_EXCEEDED");
}
export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
export function GoalsView({ initialGoals, anchorGoalId, onNavigateToMission }: GoalsViewProps) {
const { t } = useTranslation("app");
const [goals, setGoals] = useState<Goal[]>(() => initialGoals ?? []);
const [highlightedGoalId, setHighlightedGoalId] = useState<string | null>(null);
@@ -42,6 +49,11 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
const [editError, setEditError] = useState<string | null>(null);
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [expandedGoalDescriptions, setExpandedGoalDescriptions] = useState<Set<string>>(() => new Set());
const [missions, setMissions] = useState<LinkedMission[]>([]);
const [linkedMissionsByGoal, setLinkedMissionsByGoal] = useState<Record<string, LinkedMission[]>>({});
const [missionPickerByGoal, setMissionPickerByGoal] = useState<Record<string, string>>({});
const [linkingMissionGoalId, setLinkingMissionGoalId] = useState<string | null>(null);
const [unlinkingMissionKey, setUnlinkingMissionKey] = useState<string | null>(null);
useEffect(() => {
if (initialGoals !== undefined) {
@@ -82,6 +94,73 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
};
}, [initialGoals]);
useEffect(() => {
let active = true;
const loadMissions = async () => {
try {
const response = await fetch("/api/missions");
if (!response.ok) {
throw new Error(`Failed to load missions (${response.status})`);
}
const payload = (await response.json()) as { missions?: LinkedMission[] } | LinkedMission[];
const nextMissions = Array.isArray(payload)
? payload
: Array.isArray(payload.missions)
? payload.missions
: [];
if (active) {
setMissions(nextMissions.map((mission) => ({ id: mission.id, title: mission.title, status: mission.status })));
}
} catch {
if (active) {
setErrorMessage(t("goals.missionsLoadError", "Unable to load missions right now. Please try again."));
}
}
};
void loadMissions();
return () => {
active = false;
};
}, [t]);
const loadLinkedMissionsForGoal = async (goalId: string): Promise<LinkedMission[]> => {
const response = await fetch(`/api/goals/${encodeURIComponent(goalId)}/missions`);
if (!response.ok) {
throw new Error(`Failed to load linked missions (${response.status})`);
}
const payload = (await response.json()) as { missions?: LinkedMission[] };
return Array.isArray(payload.missions) ? payload.missions : [];
};
useEffect(() => {
let active = true;
const loadLinkedMissions = async () => {
if (goals.length === 0) {
setLinkedMissionsByGoal({});
return;
}
try {
const entries = await Promise.all(goals.map(async (goal) => [goal.id, await loadLinkedMissionsForGoal(goal.id)] as const));
if (active) {
setLinkedMissionsByGoal(Object.fromEntries(entries));
}
} catch {
if (active) {
setErrorMessage(t("goals.linkedMissionsLoadError", "Unable to load linked missions right now. Please try again."));
}
}
};
void loadLinkedMissions();
return () => {
active = false;
};
}, [goals, t]);
const activeCount = useMemo(() => goals.filter((goal) => goal.status === "active").length, [goals]);
const showWarning = activeCount >= WARNING_THRESHOLD && activeCount <= MAX_ACTIVE_GOALS;
@@ -265,6 +344,57 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
});
}
function getLinkableMissions(goalId: string): LinkedMission[] {
const linkedIds = new Set((linkedMissionsByGoal[goalId] ?? []).map((mission) => mission.id));
return missions.filter((mission) => !linkedIds.has(mission.id));
}
/**
* FNXC:Goals 2026-06-15-15:28:
* Goals cards now manage the reverse side of mission-goal links so users can link, unlink, and navigate to missions without switching to Mission detail first.
* Keep each card's linked list refreshed after mutations and hide already-linked missions to make duplicate INSERT OR IGNORE attempts unnecessary in normal UI flow.
*/
async function refreshLinkedMissions(goalId: string) {
const linkedMissions = await loadLinkedMissionsForGoal(goalId);
setLinkedMissionsByGoal((current) => ({ ...current, [goalId]: linkedMissions }));
setMissionPickerByGoal((current) => ({ ...current, [goalId]: "" }));
}
async function linkMissionToGoal(goalId: string) {
const missionId = missionPickerByGoal[goalId];
if (!missionId) return;
try {
setLinkingMissionGoalId(goalId);
setErrorMessage(null);
const response = await fetch(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, { method: "POST" });
if (!response.ok) {
throw new Error(`Failed to link mission (${response.status})`);
}
await refreshLinkedMissions(goalId);
} catch {
setErrorMessage(t("goals.linkMissionError", "Unable to link mission right now. Please try again."));
} finally {
setLinkingMissionGoalId(null);
}
}
async function unlinkMissionFromGoal(goalId: string, missionId: string) {
try {
setUnlinkingMissionKey(`${goalId}:${missionId}`);
setErrorMessage(null);
const response = await fetch(`/api/missions/${encodeURIComponent(missionId)}/goals/${encodeURIComponent(goalId)}`, { method: "DELETE" });
if (!response.ok) {
throw new Error(`Failed to unlink mission (${response.status})`);
}
await refreshLinkedMissions(goalId);
} catch {
setErrorMessage(t("goals.unlinkMissionError", "Unable to unlink mission right now. Please try again."));
} finally {
setUnlinkingMissionKey(null);
}
}
async function updateGoalArchiveStatus(goal: Goal) {
const endpoint = goal.status === "active" ? `/api/goals/${goal.id}/archive` : `/api/goals/${goal.id}/unarchive`;
@@ -501,6 +631,63 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) {
</div>
</>
)}
<section className="goals-linked-missions" aria-label={t("goals.linkedMissions", "Linked missions")}>
<div className="goals-linked-missions-header">
<h4 className="goals-linked-missions-title">{t("goals.linkedMissionsTitle", "Linked Missions")}</h4>
<span className="goals-linked-missions-count">
{t("goals.linkedMissionsCount", { count: linkedMissionsByGoal[goal.id]?.length ?? 0, defaultValue_one: "{{count}} linked", defaultValue_other: "{{count}} linked" })}
</span>
</div>
<div className="goals-linked-missions-controls">
<select
className="input goals-linked-missions-picker"
data-testid={`goal-mission-picker-${goal.id}`}
value={missionPickerByGoal[goal.id] ?? ""}
onChange={(event) => setMissionPickerByGoal((current) => ({ ...current, [goal.id]: event.target.value }))}
aria-label={t("goals.missionPicker", "Mission to link")}
disabled={linkingMissionGoalId === goal.id || getLinkableMissions(goal.id).length === 0}
>
<option value="">{t("goals.selectMission", "Select a mission")}</option>
{getLinkableMissions(goal.id).map((mission) => (
<option key={mission.id} value={mission.id}>{mission.title}</option>
))}
</select>
<button
type="button"
className="btn btn-primary goals-linked-missions-link-button"
data-testid={`goal-mission-link-button-${goal.id}`}
disabled={!missionPickerByGoal[goal.id] || linkingMissionGoalId === goal.id}
onClick={() => void linkMissionToGoal(goal.id)}
>
<Link size={16} aria-hidden="true" />
{linkingMissionGoalId === goal.id ? t("goals.linkingMission", "Linking…") : t("goals.linkMission", "Link mission")}
</button>
</div>
{(linkedMissionsByGoal[goal.id]?.length ?? 0) > 0 ? (
<div className="goals-linked-missions-list">
{(linkedMissionsByGoal[goal.id] ?? []).map((mission) => (
<div key={mission.id} className="goals-linked-mission-chip" data-testid={`goal-linked-mission-chip-${mission.id}`}>
<button type="button" className="btn goals-linked-mission-link" onClick={() => onNavigateToMission?.(mission.id)}>
{mission.title}
</button>
<span className="goals-linked-mission-status">{mission.status}</span>
<button
type="button"
className="btn-icon goals-linked-mission-unlink"
data-testid={`goal-linked-mission-unlink-${mission.id}`}
aria-label={t("goals.unlinkMission", "Unlink mission")}
disabled={unlinkingMissionKey === `${goal.id}:${mission.id}`}
onClick={() => void unlinkMissionFromGoal(goal.id, mission.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
) : (
<p className="goals-linked-missions-empty">{t("goals.noLinkedMissions", "No linked missions.")}</p>
)}
</section>
</article>
))}
</div>

View File

@@ -1096,6 +1096,24 @@
font-weight: 600;
}
.mission-detail__linked-goal-controls {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-wrap: wrap;
}
.mission-detail__linked-goal-picker {
flex: 1 1 calc(var(--space-xl) * 10);
min-width: 0;
}
.mission-detail__linked-goal-link-button {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.mission-detail__linked-goals-list {
display: flex;
flex-wrap: wrap;
@@ -1105,7 +1123,19 @@
.mission-detail__linked-goal-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
gap: calc(var(--space-xs) / 2);
padding: calc(var(--space-xs) / 2);
border: calc(var(--space-xs) / 4) solid var(--border);
border-radius: var(--radius-pill);
background: var(--surface-elevated);
}
.mission-detail__linked-goal-chip-link {
border-radius: var(--radius-pill);
}
.mission-detail__linked-goal-unlink {
flex: 0 0 auto;
}
.mission-detail__linked-goals-empty {
@@ -2560,10 +2590,16 @@
}
.mission-detail__linked-goals-header,
.mission-detail__linked-goal-controls,
.mission-detail__linked-goals-list {
align-items: stretch;
}
.mission-detail__linked-goal-controls,
.mission-detail__linked-goal-link-button {
width: 100%;
}
.mission-detail__run-help,
.mission-list__item-run-help {
max-width: 100%;

View File

@@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } fro
import { useTranslation } from "react-i18next";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { getErrorMessage } from "@fusion/core";
import { getErrorMessage, type Goal } from "@fusion/core";
import {
X,
Plus,
@@ -99,6 +99,7 @@ import {
fetchAiSession,
fetchMissionInterviewDrafts,
discardMissionInterviewDraft,
api,
type AiSessionSummary,
} from "../api";
import type { AutopilotState, MissionInterviewDraftSummary } from "./mission-types";
@@ -567,6 +568,12 @@ function getAutopilotActivitySummary(state: AutopilotState, lastActivityAt: stri
return t("missions.autopilotLastActivation", "Last activation {{time}}", { time: getRelativeTime(lastActivityAt, t) });
}
function buildMissionScopedPath(path: string, projectId?: string): string {
if (!projectId) return path;
const separator = path.includes("?") ? "&" : "?";
return `${path}${separator}${new URLSearchParams({ projectId }).toString()}`;
}
function normalizeMissionHierarchy(mission: MissionWithHierarchy): MissionWithHierarchy {
if (!Array.isArray(mission.milestones)) {
throw new Error("Malformed mission detail response: missing milestones");
@@ -706,6 +713,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
const [linkTaskFeatureId, setLinkTaskFeatureId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState("");
const [activeGoals, setActiveGoals] = useState<Goal[]>([]);
const [selectedGoalToLink, setSelectedGoalToLink] = useState("");
const [goalLinkBusy, setGoalLinkBusy] = useState(false);
const [unlinkingGoalId, setUnlinkingGoalId] = useState<string | null>(null);
// AI Interview modal
const [showInterviewModal, setShowInterviewModal] = useState(false);
const [interviewLaunchMode, setInterviewLaunchMode] = useState<"new" | "resume">("new");
@@ -1005,6 +1017,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionHealth, missionsCacheKey, projectId]);
const loadActiveGoals = useCallback(async () => {
try {
const result = await api<{ goals?: Goal[] }>(buildMissionScopedPath("/goals?status=active", projectId));
setActiveGoals(Array.isArray(result.goals) ? result.goals : []);
} catch (err) {
addToast(getErrorMessage(err) || t("missions.loadGoalsFailed", "Failed to load goals"), "error");
setActiveGoals([]);
}
}, [addToast, projectId, t]);
const loadMissionDetail = useCallback(async (missionId: string) => {
try {
setDetailLoading(true);
@@ -1237,6 +1259,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
useEffect(() => {
if (isActive) {
loadMissions();
loadActiveGoals();
setSelectedMission(null);
setSelectedMilestoneId(null);
setValidationTelemetry(null);
@@ -1246,7 +1269,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
setEventsFilter("all");
setExpandedEventMetadata(new Set());
}
}, [isActive, loadMissions]);
}, [isActive, loadActiveGoals, loadMissions]);
// Auto-load target mission when specified
const targetLoadedRef = useRef<string | null>(null);
@@ -2332,6 +2355,53 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
}
}, [addToast, loadMissionDetail, loadMissions, projectId]);
const linkableGoalsForSelectedMission = useMemo(() => {
const linkedIds = new Set((selectedMission?.linkedGoals ?? []).map((goal) => goal.id));
return activeGoals.filter((goal) => goal.status === "active" && !linkedIds.has(goal.id));
}, [activeGoals, selectedMission?.linkedGoals]);
useEffect(() => {
if (selectedGoalToLink && !linkableGoalsForSelectedMission.some((goal) => goal.id === selectedGoalToLink)) {
setSelectedGoalToLink("");
}
}, [linkableGoalsForSelectedMission, selectedGoalToLink]);
/**
* FNXC:Missions 2026-06-15-15:04:
* Mission detail is one side of the bidirectional goal-mission graph, so users must be able to link active goals and unlink existing chips without losing chip navigation.
* Refresh both detail and mission summaries after mutations because the sidebar unlinked indicator reads summary.linkedGoalCount.
*/
const handleLinkGoalToSelectedMission = useCallback(async () => {
if (!selectedMission || !selectedGoalToLink) return;
try {
setGoalLinkBusy(true);
await api(buildMissionScopedPath(`/missions/${encodeURIComponent(selectedMission.id)}/goals/${encodeURIComponent(selectedGoalToLink)}`, projectId), { method: "POST" });
await loadMissionDetail(selectedMission.id);
await loadMissions();
setSelectedGoalToLink("");
addToast(t("missions.goalLinked", "Goal linked to mission"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("missions.goalLinkFailed", "Failed to link goal"), "error");
} finally {
setGoalLinkBusy(false);
}
}, [addToast, loadMissionDetail, loadMissions, projectId, selectedGoalToLink, selectedMission, t]);
const handleUnlinkGoalFromSelectedMission = useCallback(async (goalId: string) => {
if (!selectedMission) return;
try {
setUnlinkingGoalId(goalId);
await api(buildMissionScopedPath(`/missions/${encodeURIComponent(selectedMission.id)}/goals/${encodeURIComponent(goalId)}`, projectId), { method: "DELETE" });
await loadMissionDetail(selectedMission.id);
await loadMissions();
addToast(t("missions.goalUnlinked", "Goal unlinked from mission"), "success");
} catch (err) {
addToast(getErrorMessage(err) || t("missions.goalUnlinkFailed", "Failed to unlink goal"), "error");
} finally {
setUnlinkingGoalId(null);
}
}, [addToast, loadMissionDetail, loadMissions, projectId, selectedMission, t]);
// ── Autopilot handlers ──
const handleToggleAutopilot = useCallback(async (missionId: string, enabled: boolean) => {
@@ -2521,18 +2591,57 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
{t("missions.linkedCount", { count: selectedMission.linkedGoals?.length ?? 0, defaultValue_one: "{{count}} linked", defaultValue_other: "{{count}} linked" })}
</span>
</div>
<div className="mission-detail__linked-goal-controls">
<select
className="input mission-detail__linked-goal-picker"
data-testid="mission-goal-picker"
value={selectedGoalToLink}
onChange={(event) => setSelectedGoalToLink(event.target.value)}
aria-label={t("missions.goalPicker", "Goal to link")}
disabled={goalLinkBusy || linkableGoalsForSelectedMission.length === 0}
>
<option value="">{t("missions.selectGoal", "Select an active goal")}</option>
{linkableGoalsForSelectedMission.map((goal) => (
<option key={goal.id} value={goal.id}>{goal.title}</option>
))}
</select>
<button
type="button"
className="btn btn-primary mission-detail__linked-goal-link-button"
data-testid="mission-goal-link-button"
disabled={!selectedGoalToLink || goalLinkBusy}
onClick={handleLinkGoalToSelectedMission}
>
<Link size={16} aria-hidden="true" />
{goalLinkBusy ? t("missions.linkingGoal", "Linking…") : t("missions.linkGoal", "Link goal")}
</button>
</div>
{(selectedMission.linkedGoals?.length ?? 0) > 0 ? (
<div className="mission-detail__linked-goals-list">
{(selectedMission.linkedGoals ?? []).map((goal) => (
<button
<div
key={goal.id}
type="button"
className="btn mission-detail__linked-goal-chip"
className="mission-detail__linked-goal-chip"
data-testid={`mission-linked-goal-chip-${goal.id}`}
onClick={() => onNavigateToGoal?.(goal.id)}
>
{goal.title}
</button>
<button
type="button"
className="btn mission-detail__linked-goal-chip-link"
onClick={() => onNavigateToGoal?.(goal.id)}
>
{goal.title}
</button>
<button
type="button"
className="btn-icon mission-detail__linked-goal-unlink"
data-testid={`mission-linked-goal-unlink-${goal.id}`}
aria-label={t("missions.unlinkGoal", "Unlink goal")}
disabled={unlinkingGoalId === goal.id}
onClick={() => handleUnlinkGoalFromSelectedMission(goal.id)}
>
<X size={16} aria-hidden="true" />
</button>
</div>
))}
</div>
) : (

View File

@@ -10,8 +10,10 @@ vi.mock("../../api", async () => ({
}));
vi.mock("lucide-react", () => ({
Link: () => <span data-testid="icon-link" />,
Plus: () => <span data-testid="icon-plus" />,
Sparkles: () => <span data-testid="icon-sparkles" />,
X: () => <span data-testid="icon-x" />,
}));
const mockDraftGoalDescription = vi.mocked(draftGoalDescription);
@@ -31,6 +33,19 @@ describe("GoalsView", () => {
beforeEach(() => {
vi.unstubAllGlobals();
mockDraftGoalDescription.mockReset();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
if (path.includes("/missions")) {
return { ok: true, json: async () => ({ missions: [] }) };
}
return { ok: true, json: async () => ({ goals: [] }) };
}),
);
});
afterEach(() => {
@@ -91,9 +106,12 @@ describe("GoalsView", () => {
it("renders inline load error when API request fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: false,
status: 500,
vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
return { ok: false, status: 500, json: async () => ({}) };
}),
);
@@ -117,6 +135,86 @@ describe("GoalsView", () => {
expect(screen.getByText(/approaching the 5-active goal cap/i)).toBeInTheDocument();
});
it("renders linked missions and navigates from the chip", async () => {
const onNavigateToMission = vi.fn();
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [{ id: "M-2", title: "Other Mission", status: "planning" }] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: [{ id: "M-1", title: "Linked Mission", status: "active" }] }) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} onNavigateToMission={onNavigateToMission} />);
const chip = await screen.findByTestId("goal-linked-mission-chip-M-1");
expect(chip).toHaveTextContent("Linked Mission");
fireEvent.click(screen.getByRole("button", { name: "Linked Mission" }));
expect(onNavigateToMission).toHaveBeenCalledWith("M-1");
});
it("links a mission and updates the linked mission list", async () => {
let linked = false;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === "/api/missions" && !init) {
return { ok: true, json: async () => ({ missions: [{ id: "M-1", title: "Mission One", status: "planning" }] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: linked ? [{ id: "M-1", title: "Mission One", status: "planning" }] : [] }) };
}
if (path === "/api/missions/M-1/goals/g1" && init?.method === "POST") {
linked = true;
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} />);
expect(await screen.findByText("No linked missions.")).toBeInTheDocument();
fireEvent.change(screen.getByTestId("goal-mission-picker-g1"), { target: { value: "M-1" } });
fireEvent.click(screen.getByTestId("goal-mission-link-button-g1"));
expect(await screen.findByTestId("goal-linked-mission-chip-M-1")).toHaveTextContent("Mission One");
expect(screen.getByTestId("goal-mission-picker-g1")).not.toHaveTextContent("Mission One");
expect(fetchMock).toHaveBeenCalledWith("/api/missions/M-1/goals/g1", { method: "POST" });
});
it("unlinks a mission and restores the empty linked missions state", async () => {
let linked = true;
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = String(input);
if (path === "/api/missions" && !init) {
return { ok: true, json: async () => ({ missions: [{ id: "M-1", title: "Mission One", status: "planning" }] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: linked ? [{ id: "M-1", title: "Mission One", status: "planning" }] : [] }) };
}
if (path === "/api/missions/M-1/goals/g1" && init?.method === "DELETE") {
linked = false;
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One" })]} />);
expect(await screen.findByTestId("goal-linked-mission-chip-M-1")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("goal-linked-mission-unlink-M-1"));
await waitFor(() => {
expect(screen.queryByTestId("goal-linked-mission-chip-M-1")).not.toBeInTheDocument();
});
expect(screen.getByText("No linked missions.")).toBeInTheDocument();
});
it("archives goal via API", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
@@ -148,10 +246,19 @@ describe("GoalsView", () => {
});
it("shows cap error for unarchive 409", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 409,
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
return {
ok: false,
status: 409,
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
};
});
vi.stubGlobal("fetch", fetchMock);
@@ -248,10 +355,19 @@ describe("GoalsView", () => {
});
it("shows cap error on 409 and keeps add form open", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
status: 409,
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
return {
ok: false,
status: 409,
json: async () => ({ code: "ACTIVE_GOAL_LIMIT_EXCEEDED", limit: 5, currentActive: 5 }),
};
});
vi.stubGlobal("fetch", fetchMock);
@@ -311,7 +427,16 @@ describe("GoalsView", () => {
});
it("shows edit error when PATCH fails", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 500 });
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const path = String(input);
if (path === "/api/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
if (path === "/api/goals/g1/missions") {
return { ok: true, json: async () => ({ missions: [] }) };
}
return { ok: false, status: 500, json: async () => ({}) };
});
vi.stubGlobal("fetch", fetchMock);
render(<GoalsView initialGoals={[makeGoal({ id: "g1", title: "One", description: "Desc" })]} />);

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { MissionManager } from "../MissionManager";
const mockApi = vi.fn();
const mockFetchMissions = vi.fn();
const mockFetchMission = vi.fn();
const mockFetchMissionsHealth = vi.fn();
const mockFetchAiSessions = vi.fn();
const mockFetchMissionInterviewDrafts = vi.fn();
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
return {
...actual,
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
};
});
vi.mock("../../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../api")>();
return {
...actual,
api: (...args: unknown[]) => mockApi(...args),
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
};
});
vi.mock("lucide-react", () => ({
X: () => <span>X</span>,
Plus: () => <span>+</span>,
Pencil: () => <span>Pencil</span>,
Trash2: () => <span>Trash</span>,
ChevronRight: () => <span>ChevronRight</span>,
ChevronDown: () => <span>ChevronDown</span>,
ChevronLeft: () => <span>ChevronLeft</span>,
Target: () => <span>Target</span>,
Layers: () => <span>Layers</span>,
Package: () => <span>Package</span>,
Box: () => <span>Box</span>,
Check: () => <span>Check</span>,
Loader2: () => <span>Loader</span>,
Link: () => <span>Link</span>,
Unlink: () => <span>Unlink</span>,
Play: () => <span>Play</span>,
Square: () => <span>Square</span>,
Sparkles: () => <span>Sparkles</span>,
Zap: () => <span>Zap</span>,
Activity: () => <span>Activity</span>,
FileText: () => <span>FileText</span>,
RefreshCw: () => <span>Refresh</span>,
}));
type LinkedGoal = { id: string; title: string; status: "active" | "archived"; createdAt: string; updatedAt: string };
const now = "2026-06-15T14:00:00.000Z";
const activeGoal: LinkedGoal = { id: "G-ACTIVE", title: "Active Goal", status: "active", createdAt: now, updatedAt: now };
const archivedGoal: LinkedGoal = { id: "G-ARCHIVED", title: "Archived Goal", status: "archived", createdAt: now, updatedAt: now };
let linkedGoals: LinkedGoal[];
function missionDetail() {
return {
id: "M-001",
title: "Mission One",
description: "",
status: "active",
linkedGoals,
milestones: [],
};
}
function setupApiMock() {
mockApi.mockImplementation(async (path: string, opts?: RequestInit) => {
if (path.startsWith("/goals?status=active")) {
return { goals: [activeGoal, archivedGoal] };
}
if (path === "/missions/M-001/goals/G-ACTIVE" && opts?.method === "POST") {
linkedGoals = [activeGoal];
return { goal: activeGoal, goals: linkedGoals };
}
if (path === "/missions/M-001/goals/G-ACTIVE" && opts?.method === "DELETE") {
linkedGoals = [];
return { removed: true, goals: [] };
}
return {};
});
}
describe("MissionManager goal links", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
linkedGoals = [];
setupApiMock();
mockFetchMissions.mockImplementation(async () => [
{ id: "M-001", title: "Mission One", description: "", status: "active", summary: { linkedGoalCount: linkedGoals.length }, milestones: [] },
]);
mockFetchMissionsHealth.mockResolvedValue({});
mockFetchAiSessions.mockResolvedValue([]);
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
mockFetchMission.mockImplementation(async () => missionDetail());
});
it("links an active goal, hides archived goals from the picker, unlinks back to empty, and keeps chip navigation", async () => {
const onNavigateToGoal = vi.fn();
render(<MissionManager isInline isOpen onClose={() => {}} addToast={() => {}} onNavigateToGoal={onNavigateToGoal} />);
fireEvent.click(await screen.findByText("Mission One"));
const picker = await screen.findByTestId("mission-goal-picker");
expect(within(picker).getByText("Active Goal")).toBeInTheDocument();
expect(within(picker).queryByText("Archived Goal")).not.toBeInTheDocument();
expect(screen.getByTestId("mission-unlinked-indicator-M-001")).toBeInTheDocument();
expect(screen.getByText("No linked goals.")).toBeInTheDocument();
fireEvent.change(picker, { target: { value: "G-ACTIVE" } });
fireEvent.click(screen.getByTestId("mission-goal-link-button"));
const chip = await screen.findByTestId("mission-linked-goal-chip-G-ACTIVE");
expect(chip).toHaveTextContent("Active Goal");
expect(within(screen.getByTestId("mission-goal-picker")).queryByText("Active Goal")).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId("mission-unlinked-indicator-M-001")).not.toBeInTheDocument();
});
fireEvent.click(within(chip).getByRole("button", { name: "Active Goal" }));
expect(onNavigateToGoal).toHaveBeenCalledWith("G-ACTIVE");
fireEvent.click(screen.getByTestId("mission-linked-goal-unlink-G-ACTIVE"));
await waitFor(() => {
expect(screen.queryByTestId("mission-linked-goal-chip-G-ACTIVE")).not.toBeInTheDocument();
});
expect(screen.getByText("No linked goals.")).toBeInTheDocument();
expect(screen.getByTestId("mission-unlinked-indicator-M-001")).toBeInTheDocument();
});
});

View File

@@ -2,7 +2,7 @@
import { beforeEach, describe, expect, it } from "vitest";
import express from "express";
import type { Goal, GoalStatus, TaskStore } from "@fusion/core";
import type { Goal, GoalStatus, Mission, TaskStore } from "@fusion/core";
import { createGoalsRouter } from "../goals-routes.js";
import { get, request } from "../test-request.js";
@@ -66,12 +66,42 @@ function createMockGoalStore() {
};
}
function createMockMissionStore() {
const missions = new Map<string, Mission>();
const goalLinks = new Map<string, string[]>();
const now = new Date().toISOString();
const addMission = (mission: Pick<Mission, "id" | "title" | "status">) => {
missions.set(mission.id, {
description: undefined,
interviewState: "idle",
createdAt: now,
updatedAt: now,
...mission,
} as Mission);
};
return {
addMission,
linkGoal: (missionId: string, goalId: string) => {
const existing = goalLinks.get(goalId) ?? [];
if (!existing.includes(missionId)) {
goalLinks.set(goalId, [...existing, missionId]);
}
},
listMissionIdsForGoal: (goalId: string) => goalLinks.get(goalId) ?? [],
getMission: (missionId: string) => missions.get(missionId) ?? null,
};
}
describe("goals-routes", () => {
let app: express.Express;
let missionStore: ReturnType<typeof createMockMissionStore>;
beforeEach(() => {
const goalStore = createMockGoalStore();
const store = { getGoalStore: () => goalStore } as unknown as TaskStore;
missionStore = createMockMissionStore();
const store = { getGoalStore: () => goalStore, getMissionStore: () => missionStore } as unknown as TaskStore;
app = express();
app.use(express.json());
app.use("/api/goals", createGoalsRouter(store));
@@ -113,6 +143,42 @@ describe("goals-routes", () => {
expect(invalid.status).toBe(400);
});
it("GET /:id/missions returns an empty linked mission list", async () => {
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Strategy" }), { "content-type": "application/json" });
const response = await get(app, `/api/goals/${(created.body as Goal).id}/missions`);
expect(response.status).toBe(200);
expect(response.body).toEqual({ missions: [] });
});
it("GET /:id/missions returns linked missions in store order and skips missing missions", async () => {
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Strategy" }), { "content-type": "application/json" });
const goalId = (created.body as Goal).id;
missionStore.addMission({ id: "M-ALPHA", title: "Alpha", status: "active" });
missionStore.addMission({ id: "M-BETA", title: "Beta", status: "complete" });
missionStore.linkGoal("M-BETA", goalId);
missionStore.linkGoal("M-MISSING", goalId);
missionStore.linkGoal("M-ALPHA", goalId);
const response = await get(app, `/api/goals/${goalId}/missions`);
expect(response.status).toBe(200);
expect(response.body).toEqual({
missions: [
{ id: "M-BETA", title: "Beta", status: "complete" },
{ id: "M-ALPHA", title: "Alpha", status: "active" },
],
});
});
it("GET /:id/missions validates the goal id and returns 404 for unknown goals", async () => {
const invalid = await get(app, "/api/goals/not-a-goal/missions");
expect(invalid.status).toBe(400);
const unknown = await get(app, "/api/goals/G-UNKNOWN/missions");
expect(unknown.status).toBe(404);
});
it("PATCH /:id updates and validates", async () => {
const created = await request(app, "POST", "/api/goals", JSON.stringify({ title: "Old" }), { "content-type": "application/json" });
const id = (created.body as Goal).id;

View File

@@ -14,7 +14,7 @@
import { Router, type Request, type Response } from "express";
import { AsyncLocalStorage } from "node:async_hooks";
import type { Goal, GoalStatus, GoalUpdateInput, TaskStore } from "@fusion/core";
import type { Goal, GoalStatus, GoalUpdateInput, Mission, TaskStore } from "@fusion/core";
import { ApiError, badRequest, catchHandler, conflict, internalError, notFound } from "./api-error.js";
import { getOrCreateProjectStore } from "./project-store-resolver.js";
@@ -27,6 +27,11 @@ type GoalStoreLike = {
unarchiveGoal(id: string): Goal;
};
type MissionStoreLike = {
listMissionIdsForGoal(goalId: string): string[];
getMission(missionId: string): Mission | null | undefined;
};
const GOAL_ID_RE = /^G-[A-Z0-9]+(?:-[A-Z0-9]+)*$/i;
const GOAL_STATUSES: GoalStatus[] = ["active", "archived"];
@@ -44,6 +49,10 @@ function getGoalStore(store: TaskStore): GoalStoreLike {
return store.getGoalStore();
}
function getMissionStore(store: TaskStore): MissionStoreLike {
return store.getMissionStore();
}
function validateGoalId(id: unknown): string {
if (typeof id !== "string" || !GOAL_ID_RE.test(id)) {
throw badRequest("Invalid goal id format");
@@ -132,6 +141,32 @@ export function createGoalsRouter(store: TaskStore): Router {
}),
);
/**
* FNXC:Goals 2026-06-15-14:45:
* Goals view needs the reverse side of mission-goal links so each goal card can show and edit its missions without loading the full mission hierarchy.
* Resolve the store's ordered link rows to current missions and skip missing mission records so stale links do not break the dashboard.
*/
router.get(
"/:id/missions",
catchHandler((req, res) => {
const id = validateGoalId(req.params.id);
const scopedStore = getScopedStore();
const goalStore = getGoalStore(scopedStore);
if (!goalStore.getGoal(id)) {
throw notFound(`Goal ${id} not found`);
}
const missionStore = getMissionStore(scopedStore);
const missions = missionStore
.listMissionIdsForGoal(id)
.map((missionId) => missionStore.getMission(missionId))
.filter((mission): mission is Mission => Boolean(mission))
.map((mission) => ({ id: mission.id, title: mission.title, status: mission.status }));
res.json({ missions });
}),
);
router.post(
"/",
catchHandler((req, res) => {