import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import type { Goal } from "@fusion/core"; import { Link, Plus, Sparkles, Target, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { draftGoalDescription, getRefineErrorMessage } from "../api"; import { ViewHeader } from "./ViewHeader"; 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; const GOAL_DESCRIPTION_TOGGLE_LENGTH = 280; 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, onNavigateToMission }: GoalsViewProps) { const { t } = useTranslation("app"); const [goals, setGoals] = useState(() => initialGoals ?? []); const [highlightedGoalId, setHighlightedGoalId] = useState(null); const anchorTimeoutRef = useRef | null>(null); const [loading, setLoading] = useState(initialGoals === undefined); const [errorMessage, setErrorMessage] = useState(null); const [isAddFormOpen, setIsAddFormOpen] = useState(false); const [addTitle, setAddTitle] = useState(""); const [addDescription, setAddDescription] = useState(""); const [addError, setAddError] = useState(null); const [isCreating, setIsCreating] = useState(false); const [isDraftingDescription, setIsDraftingDescription] = useState(false); const [editGoalId, setEditGoalId] = useState(null); const [editTitle, setEditTitle] = useState(""); const [editDescription, setEditDescription] = useState(""); 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) { return; } let active = true; const loadGoals = async () => { try { setLoading(true); setErrorMessage(null); const response = await fetch("/api/goals"); if (!response.ok) { throw new Error(`Failed to load goals (${response.status})`); } const payload = (await response.json()) as { goals?: Goal[] }; if (!active) { return; } setGoals(Array.isArray(payload.goals) ? payload.goals : []); } catch { if (!active) { return; } setErrorMessage(t("goals.loadError", "Unable to load goals right now. Please try again.")); } finally { if (active) { setLoading(false); } } }; void loadGoals(); return () => { active = false; }; }, [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; useEffect(() => { if (!anchorGoalId) { setHighlightedGoalId(null); return; } const target = document.getElementById(`goal-card-${anchorGoalId}`); if (!target) { return; } setHighlightedGoalId(anchorGoalId); if (typeof target.scrollIntoView === "function") { target.scrollIntoView({ behavior: "smooth", block: "center" }); } if (anchorTimeoutRef.current) { clearTimeout(anchorTimeoutRef.current); } anchorTimeoutRef.current = setTimeout(() => { setHighlightedGoalId((current) => (current === anchorGoalId ? null : current)); anchorTimeoutRef.current = null; }, 1600); return () => { if (anchorTimeoutRef.current) { clearTimeout(anchorTimeoutRef.current); anchorTimeoutRef.current = null; } }; }, [anchorGoalId, goals]); function openAddForm() { setErrorMessage(null); setAddError(null); setIsAddFormOpen(true); } function openEdit(goal: Goal) { setEditGoalId(goal.id); setEditTitle(goal.title); setEditDescription(goal.description ?? ""); setEditError(null); } function cancelEdit() { setEditGoalId(null); setEditTitle(""); setEditDescription(""); setEditError(null); } function closeAddForm() { setIsAddFormOpen(false); setAddTitle(""); setAddDescription(""); setAddError(null); setIsDraftingDescription(false); } async function draftAddGoalDescription() { const title = addTitle.trim(); if (!title) { setAddError(t("goals.titleRequired", "Title is required.")); return; } try { setIsDraftingDescription(true); setAddError(null); const description = await draftGoalDescription(title); setAddDescription(description); } catch (error) { setAddError(getRefineErrorMessage(error)); } finally { setIsDraftingDescription(false); } } async function submitAddGoal() { const title = addTitle.trim(); if (!title) { setAddError(t("goals.titleRequired", "Title is required.")); return; } try { setIsCreating(true); setAddError(null); setErrorMessage(null); const response = await fetch("/api/goals", { method: "POST", headers: { "content-type": "application/json", }, body: JSON.stringify({ title, description: addDescription, }), }); if (response.ok) { const createdGoal = (await response.json()) as Goal; setGoals((current) => [...current, createdGoal]); closeAddForm(); return; } let payload: unknown = null; try { payload = await response.json(); } catch { payload = null; } if (response.status === 409 && isCapError(payload)) { setErrorMessage(t("goals.capError", "Cannot activate more than 5 goals. Resolve an active goal before activating another.")); return; } setAddError(t("goals.createError", "Unable to create goal right now. Please try again.")); } catch { setAddError(t("goals.createError", "Unable to create goal right now. Please try again.")); } finally { setIsCreating(false); } } async function saveEditGoal() { if (!editGoalId) { return; } const title = editTitle.trim(); if (!title) { setEditError(t("goals.titleRequired", "Title is required.")); return; } try { setIsSavingEdit(true); setEditError(null); const response = await fetch(`/api/goals/${editGoalId}`, { method: "PATCH", headers: { "content-type": "application/json", }, body: JSON.stringify({ title, description: editDescription }), }); if (!response.ok) { throw new Error(`Failed to update goal (${response.status})`); } const updatedGoal = (await response.json()) as Goal; setGoals((current) => current.map((goal) => (goal.id === updatedGoal.id ? updatedGoal : goal))); cancelEdit(); } catch { setEditError(t("goals.saveError", "Unable to save goal right now. Please try again.")); } finally { setIsSavingEdit(false); } } function isDescriptionToggleVisible(description: string): boolean { return description.length > GOAL_DESCRIPTION_TOGGLE_LENGTH || description.includes("\n"); } function toggleGoalDescription(goalId: string) { setExpandedGoalDescriptions((current) => { const next = new Set(current); if (next.has(goalId)) { next.delete(goalId); } else { next.add(goalId); } return next; }); } 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`; try { setErrorMessage(null); const response = await fetch(endpoint, { method: "POST", }); if (response.ok) { const updatedGoal = (await response.json()) as Goal; setGoals((current) => current.map((entry) => (entry.id === updatedGoal.id ? updatedGoal : entry))); return; } let payload: unknown = null; try { payload = await response.json(); } catch { payload = null; } if (response.status === 409 && isCapError(payload)) { setErrorMessage(t("goals.capError", "Cannot activate more than 5 goals. Resolve an active goal before activating another.")); return; } setErrorMessage(t("goals.updateError", "Unable to update goal status right now. Please try again.")); } catch { setErrorMessage(t("goals.updateError", "Unable to update goal status right now. Please try again.")); } } return (
{/* FNXC:Navigation 2026-06-22-01:10: Goals adopts the shared ViewHeader (CC-modeled) for a consistent main-content title row; the Add Goal action and the active-goal count both ride in the header actions cluster so existing behavior and the goals-active-count test hook are preserved. */}

{t("goals.activeCount", "{{count}} active goals", { count: activeCount })}

{/* FNXC:Goals 2026-06-22-16:30: Plus icon is sized 18 (was unsized → lucide 24px default) so the Add Goal button matches the height of the Compound Engineering stage-launcher button, which uses an 18px icon on the same .btn base. */} )} /> {/* FNXC:Navigation 2026-06-22-01:12: Inner content keeps its own horizontal padding via .goals-view__content so it aligns with the ViewHeader inset after the root drops its uniform padding. */}
{isAddFormOpen ? (
setAddTitle(event.target.value)} data-testid="goals-form-title" />