diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index e7bd91427e..402f989a10 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/docs/missions.md b/docs/missions.md index dc998519db..c8c2d0937a 100644 --- a/docs/missions.md +++ b/docs/missions.md @@ -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 ` — list linked goals for a mission. - `fn mission link-goal ` — idempotently link a goal; archived goals reject with `GOAL_ARCHIVED`. - `fn mission unlink-goal ` — 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) diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index ce408595b2..de4c0cc669 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -1809,7 +1809,7 @@ function AppInner() { return ( - + ); diff --git a/packages/dashboard/app/components/GoalsView.css b/packages/dashboard/app/components/GoalsView.css index e2708b7183..bdc96c26b5 100644 --- a/packages/dashboard/app/components/GoalsView.css +++ b/packages/dashboard/app/components/GoalsView.css @@ -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); diff --git a/packages/dashboard/app/components/GoalsView.tsx b/packages/dashboard/app/components/GoalsView.tsx index 9248c462fe..c837d74758 100644 --- a/packages/dashboard/app/components/GoalsView.tsx +++ b/packages/dashboard/app/components/GoalsView.tsx @@ -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(() => initialGoals ?? []); const [highlightedGoalId, setHighlightedGoalId] = useState(null); @@ -42,6 +49,11 @@ export function GoalsView({ initialGoals, anchorGoalId }: GoalsViewProps) { const [editError, setEditError] = useState(null); const [isSavingEdit, setIsSavingEdit] = useState(false); const [expandedGoalDescriptions, setExpandedGoalDescriptions] = useState>(() => new Set()); + const [missions, setMissions] = useState([]); + const [linkedMissionsByGoal, setLinkedMissionsByGoal] = useState>({}); + const [missionPickerByGoal, setMissionPickerByGoal] = useState>({}); + const [linkingMissionGoalId, setLinkingMissionGoalId] = useState(null); + const [unlinkingMissionKey, setUnlinkingMissionKey] = useState(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 => { + 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) { )} +
+
+

{t("goals.linkedMissionsTitle", "Linked Missions")}

+ + {t("goals.linkedMissionsCount", { count: linkedMissionsByGoal[goal.id]?.length ?? 0, defaultValue_one: "{{count}} linked", defaultValue_other: "{{count}} linked" })} + +
+
+ + +
+ {(linkedMissionsByGoal[goal.id]?.length ?? 0) > 0 ? ( +
+ {(linkedMissionsByGoal[goal.id] ?? []).map((mission) => ( +
+ + {mission.status} + +
+ ))} +
+ ) : ( +

{t("goals.noLinkedMissions", "No linked missions.")}

+ )} +
))} diff --git a/packages/dashboard/app/components/MissionManager.css b/packages/dashboard/app/components/MissionManager.css index d85879b1f8..a32ffe67e1 100644 --- a/packages/dashboard/app/components/MissionManager.css +++ b/packages/dashboard/app/components/MissionManager.css @@ -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%; diff --git a/packages/dashboard/app/components/MissionManager.tsx b/packages/dashboard/app/components/MissionManager.tsx index 470fbcd48c..32ba3b1eac 100644 --- a/packages/dashboard/app/components/MissionManager.tsx +++ b/packages/dashboard/app/components/MissionManager.tsx @@ -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(null); const [selectedTaskId, setSelectedTaskId] = useState(""); + const [activeGoals, setActiveGoals] = useState([]); + const [selectedGoalToLink, setSelectedGoalToLink] = useState(""); + const [goalLinkBusy, setGoalLinkBusy] = useState(false); + const [unlinkingGoalId, setUnlinkingGoalId] = useState(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(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" })} +
+ + +
{(selectedMission.linkedGoals?.length ?? 0) > 0 ? (
{(selectedMission.linkedGoals ?? []).map((goal) => ( - + + +
))} ) : ( diff --git a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx index 97a105012a..5239e2c5bf 100644 --- a/packages/dashboard/app/components/__tests__/GoalsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/GoalsView.test.tsx @@ -10,8 +10,10 @@ vi.mock("../../api", async () => ({ })); vi.mock("lucide-react", () => ({ + Link: () => , Plus: () => , Sparkles: () => , + 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(); + + 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(); + + 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(); + + 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(); diff --git a/packages/dashboard/app/components/__tests__/MissionManager.goal-links.test.tsx b/packages/dashboard/app/components/__tests__/MissionManager.goal-links.test.tsx new file mode 100644 index 0000000000..30b953bf0d --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MissionManager.goal-links.test.tsx @@ -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(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +vi.mock("../../api", async (importOriginal) => { + const actual = await importOriginal(); + 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: () => X, + Plus: () => +, + Pencil: () => Pencil, + Trash2: () => Trash, + ChevronRight: () => ChevronRight, + ChevronDown: () => ChevronDown, + ChevronLeft: () => ChevronLeft, + Target: () => Target, + Layers: () => Layers, + Package: () => Package, + Box: () => Box, + Check: () => Check, + Loader2: () => Loader, + Link: () => Link, + Unlink: () => Unlink, + Play: () => Play, + Square: () => Square, + Sparkles: () => Sparkles, + Zap: () => Zap, + Activity: () => Activity, + FileText: () => FileText, + RefreshCw: () => Refresh, +})); + +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( {}} 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(); + }); +}); diff --git a/packages/dashboard/src/__tests__/goals-routes.test.ts b/packages/dashboard/src/__tests__/goals-routes.test.ts index b408521294..103af53c4a 100644 --- a/packages/dashboard/src/__tests__/goals-routes.test.ts +++ b/packages/dashboard/src/__tests__/goals-routes.test.ts @@ -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(); + const goalLinks = new Map(); + const now = new Date().toISOString(); + + const addMission = (mission: Pick) => { + 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; 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; diff --git a/packages/dashboard/src/goals-routes.ts b/packages/dashboard/src/goals-routes.ts index 82e6bc2460..eb8007c3f6 100644 --- a/packages/dashboard/src/goals-routes.ts +++ b/packages/dashboard/src/goals-routes.ts @@ -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) => {