feat(FN-1674): add roadmap export and handoff system

- Add RoadmapStore with read APIs for project-scoped roadmap data
- Add roadmap-handoff mapper to transform roadmap data for export
- Add project-scoped handoff API route (/api/projects/:id/roadmap/handoff)
- Add useRoadmaps hook for fetching and exposing roadmap data to components
- Update RoadmapsView with export/handoff UX path and roadmap detail view
- Add roadmap routes with project-scoped handoff endpoint
- Add comprehensive tests for handoff mapper and roadmap routes
- Update architecture.md and add dashboard-guide.md documentation
This commit is contained in:
Fusion
2026-04-15 17:06:36 -07:00
committed by gsxdsm
parent d52ae1cb99
commit b37dfc23f5
12 changed files with 1151 additions and 6 deletions

View File

@@ -4600,6 +4600,17 @@ export function getRoadmapFeatureHandoff(
);
}
/** Combined handoff response type for roadmap handoff endpoint */
export interface RoadmapHandoffResponse {
mission: RoadmapMissionPlanningHandoff;
features: RoadmapFeatureTaskPlanningHandoff[];
}
/** Get both mission and feature handoff payloads for a roadmap */
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
return api<RoadmapHandoffResponse>(withProjectId(`/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
}
/** Response from milestone suggestion generation */
export interface MilestoneSuggestionsResponse {
suggestions: Array<{

View File

@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles, Download, Copy, Loader } from "lucide-react";
import type { ToastType } from "../hooks/useToast";
import { useRoadmaps, type FeatureSuggestion, type MilestoneSuggestion, type SuggestionDraftPatch } from "../hooks/useRoadmaps";
import type {
@@ -12,6 +12,8 @@ import type {
RoadmapMilestoneUpdateInput,
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
export interface RoadmapsViewProps {
@@ -64,6 +66,113 @@ interface CreateFormState {
description: string;
}
// ── Handoff Modal Types ─────────────────────────────────────────────
interface HandoffModalProps {
isOpen: boolean;
onClose: () => void;
roadmapId: string;
roadmapTitle: string;
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
isLoading: boolean;
error: Error | null;
onFetchHandoff: () => void;
onCopyToClipboard: () => void;
}
// ── Handoff Modal Component ─────────────────────────────────────────
function HandoffModal({
isOpen,
onClose,
roadmapTitle,
handoffPayload,
isLoading,
error,
onFetchHandoff,
onCopyToClipboard,
}: HandoffModalProps) {
if (!isOpen) return null;
return (
<div className="modal-overlay open" onClick={onClose} role="presentation">
<div className="modal modal-lg" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="handoff-modal-title">
<div className="modal-header">
<h2 id="handoff-modal-title">Export Roadmap: {roadmapTitle}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close modal">
<X size={18} />
</button>
</div>
<div className="modal-body">
<p className="text-muted" style={{ marginBottom: "var(--space-lg)" }}>
Export roadmap data for use in mission and task planning flows.
This is a read-only export — no missions or tasks will be created.
</p>
{error && (
<div className="form-error" style={{ marginBottom: "var(--space-lg)" }}>
Error loading handoff data: {error.message}
</div>
)}
{!handoffPayload && !isLoading && (
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
<button className="btn btn-primary" onClick={onFetchHandoff}>
<Download size={16} style={{ marginRight: "var(--space-sm)" }} />
Load Handoff Data
</button>
</div>
)}
{isLoading && (
<div style={{ textAlign: "center", padding: "var(--space-xl)" }}>
<Loader size={24} className="spin" />
<p style={{ marginTop: "var(--space-md)" }}>Loading handoff data...</p>
</div>
)}
{handoffPayload && (
<>
<div style={{ marginBottom: "var(--space-lg)" }}>
<h3 style={{ marginBottom: "var(--space-sm)" }}>Mission Planning Handoff</h3>
<div className="card" style={{ padding: "var(--space-md)" }}>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "200px", overflow: "auto" }}>
{JSON.stringify(handoffPayload.mission, null, 2)}
</pre>
</div>
</div>
<div style={{ marginBottom: "var(--space-lg)" }}>
<h3 style={{ marginBottom: "var(--space-sm)" }}>
Feature Task Planning Handoffs ({handoffPayload.features.length})
</h3>
<div className="card" style={{ padding: "var(--space-md)" }}>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "12px", maxHeight: "300px", overflow: "auto" }}>
{JSON.stringify(handoffPayload.features, null, 2)}
</pre>
</div>
</div>
</>
)}
</div>
<div className="modal-actions">
<div className="modal-actions-left">
{handoffPayload && (
<button className="btn btn-sm" onClick={onCopyToClipboard}>
<Copy size={14} style={{ marginRight: "var(--space-xs)" }} />
Copy to Clipboard
</button>
)}
</div>
<div className="modal-actions-right">
<button className="btn" onClick={onClose}>Close</button>
</div>
</div>
</div>
</div>
);
}
// ── Roadmap Item ─────────────────────────────────────────────────────
function RoadmapItem({
@@ -72,12 +181,14 @@ function RoadmapItem({
onSelect,
onEdit,
onDelete,
onExport,
}: {
roadmap: Roadmap;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
onExport: () => void;
}) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
@@ -95,6 +206,11 @@ function RoadmapItem({
onDelete();
};
const handleExportClick = (e: React.MouseEvent) => {
e.stopPropagation();
onExport();
};
return (
<div
className={`roadmaps-view__sidebar-item${isSelected ? " roadmaps-view__sidebar-item--active" : ""}`}
@@ -112,6 +228,17 @@ function RoadmapItem({
)}
</div>
<div className="roadmaps-view__sidebar-item-actions" onClick={handleEditClick} role="presentation">
<span
className="roadmaps-view__icon-btn"
onClick={handleExportClick}
role="button"
title="Export roadmap"
aria-label="Export roadmap"
data-testid={`roadmap-export-${roadmap.id}`}
tabIndex={0}
>
<Download size={14} />
</span>
<span
className="roadmaps-view__icon-btn"
onClick={handleEditClick}
@@ -1127,8 +1254,18 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
handoffPayload,
isFetchingHandoff,
handoffError,
fetchHandoff,
clearHandoff,
} = useRoadmaps({ projectId });
// Handoff modal state
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
const [handoffRoadmapId, setHandoffRoadmapId] = useState<string | null>(null);
const [handoffRoadmapTitle, setHandoffRoadmapTitle] = useState<string>("");
// Goal prompt state for milestone suggestion generation
const [goalPrompt, setGoalPrompt] = useState("");
@@ -1460,6 +1597,41 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
[deleteRoadmap, addToast]
);
// Handoff handlers
const handleOpenHandoffModal = useCallback((roadmapId: string, roadmapTitle: string) => {
setHandoffRoadmapId(roadmapId);
setHandoffRoadmapTitle(roadmapTitle);
setHandoffModalOpen(true);
// Clear any previous handoff data
clearHandoff();
}, [clearHandoff]);
const handleCloseHandoffModal = useCallback(() => {
setHandoffModalOpen(false);
setHandoffRoadmapId(null);
setHandoffRoadmapTitle("");
clearHandoff();
}, [clearHandoff]);
const handleFetchHandoff = useCallback(() => {
if (handoffRoadmapId) {
fetchHandoff(handoffRoadmapId, {
onError: (err) => addToast(`Failed to load handoff: ${err.message}`, "error"),
});
}
}, [handoffRoadmapId, fetchHandoff, addToast]);
const handleCopyHandoffToClipboard = useCallback(() => {
if (handoffPayload) {
const data = JSON.stringify(handoffPayload, null, 2);
navigator.clipboard.writeText(data).then(() => {
addToast("Handoff data copied to clipboard", "success");
}).catch(() => {
addToast("Failed to copy to clipboard", "error");
});
}
}, [handoffPayload, addToast]);
const handleCreateRoadmap = useCallback(
async (input: RoadmapCreateInput) => {
try {
@@ -1758,6 +1930,7 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
onSelect={() => selectRoadmap(roadmap.id)}
onEdit={() => handleStartRoadmapEdit(roadmap)}
onDelete={() => handleDeleteRoadmap(roadmap.id)}
onExport={() => handleOpenHandoffModal(roadmap.id, roadmap.title)}
/>
))
)}
@@ -2015,6 +2188,19 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
/>
</div>
)}
{/* Handoff export modal */}
<HandoffModal
isOpen={handoffModalOpen}
onClose={handleCloseHandoffModal}
roadmapId={handoffRoadmapId || ""}
roadmapTitle={handoffRoadmapTitle}
handoffPayload={handoffPayload}
isLoading={isFetchingHandoff}
error={handoffError}
onFetchHandoff={handleFetchHandoff}
onCopyToClipboard={handleCopyHandoffToClipboard}
/>
</div>
);
}

View File

@@ -41,6 +41,9 @@ vi.mock("lucide-react", () => ({
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
Sparkles: (props: unknown) => <span data-testid="sparkles-icon" {...props}>Sparkles</span>,
Download: (props: unknown) => <span data-testid="download-icon" {...props}>Download</span>,
Copy: (props: unknown) => <span data-testid="copy-icon" {...props}>Copy</span>,
Loader: (props: unknown) => <span data-testid="loader-icon" {...props}>Loader</span>,
}));
const mockRoadmaps: Roadmap[] = [

View File

@@ -10,6 +10,8 @@ import type {
RoadmapFeatureCreateInput,
RoadmapFeatureUpdateInput,
RoadmapWithHierarchy,
RoadmapMissionPlanningHandoff,
RoadmapFeatureTaskPlanningHandoff,
} from "@fusion/core";
import * as api from "../api";
@@ -132,6 +134,18 @@ export interface UseRoadmapsResult {
/** Clear pending feature suggestions for a specific milestone */
clearFeatureSuggestions: (milestoneId: string) => void;
// Handoff / Export callbacks
/** Current handoff payload (mission + feature handoffs) */
handoffPayload: { mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null;
/** Whether handoff is currently being fetched */
isFetchingHandoff: boolean;
/** Error from the last handoff fetch attempt */
handoffError: Error | null;
/** Fetch handoff payload for a roadmap */
fetchHandoff: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
/** Clear the current handoff payload */
clearHandoff: () => void;
/** Refresh all roadmaps */
refresh: () => Promise<void>;
}
@@ -146,6 +160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Handoff state
const [handoffPayload, setHandoffPayload] = useState<{ mission: RoadmapMissionPlanningHandoff; features: RoadmapFeatureTaskPlanningHandoff[] } | null>(null);
const [isFetchingHandoff, setIsFetchingHandoff] = useState(false);
const [handoffError, setHandoffError] = useState<Error | null>(null);
// Ephemeral milestone suggestion state (in-memory only, not persisted)
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
@@ -167,18 +186,22 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
const previousProjectIdRef = useRef<string | undefined>(projectId);
// Project context version for stale-response protection
const projectContextVersionRef = useRef(0);
// Handoff fetch version for stale-response discard
const handoffFetchVersionRef = useRef(0);
// Refs to access latest state in callbacks
const roadmapsRef = useRef(roadmaps);
const selectedRoadmapIdRef = useRef(selectedRoadmapId);
const milestonesRef = useRef(milestones);
const featuresByMilestoneIdRef = useRef(featuresByMilestoneId);
const projectIdRef = useRef(projectId);
const handoffPayloadRef = useRef(handoffPayload);
roadmapsRef.current = roadmaps;
selectedRoadmapIdRef.current = selectedRoadmapId;
milestonesRef.current = milestones;
featuresByMilestoneIdRef.current = featuresByMilestoneId;
projectIdRef.current = projectId;
handoffPayloadRef.current = handoffPayload;
// Clear selection and suggestions when project changes
useEffect(() => {
@@ -189,6 +212,9 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
setSelectedRoadmap(null);
setMilestones([]);
setFeaturesByMilestoneId({});
// Clear handoff state
setHandoffPayload(null);
setHandoffError(null);
// Clear ephemeral suggestion state
setMilestoneSuggestions([]);
setIsGeneratingSuggestions(false);
@@ -1046,6 +1072,52 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
});
}, []);
// ── Handoff / Export Functions ────────────────────────────────────────
const fetchHandoff = useCallback(async (
roadmapId: string,
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
) => {
const requestVersion = ++handoffFetchVersionRef.current;
const requestProjectId = projectId; // Capture projectId at request time
setIsFetchingHandoff(true);
setHandoffError(null);
try {
const data = await api.fetchRoadmapHandoff(roadmapId, requestProjectId);
// Reject stale responses: check if project changed or version is stale
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return; // Stale response, discard
}
setHandoffPayload(data);
opts?.onSuccess?.();
} catch (err) {
// Reject stale errors: check if project changed or version is stale
if (handoffFetchVersionRef.current !== requestVersion || projectId !== requestProjectId) {
return; // Stale error, discard
}
const error = err instanceof Error ? err : new Error(String(err));
setHandoffError(error);
setHandoffPayload(null);
opts?.onError?.(error);
} finally {
// Only clear loading if this is still the current request
if (handoffFetchVersionRef.current === requestVersion) {
setIsFetchingHandoff(false);
}
}
}, [projectId]);
const clearHandoff = useCallback(() => {
setHandoffPayload(null);
setHandoffError(null);
setIsFetchingHandoff(false);
}, []);
const refresh = useCallback(async () => {
await fetchRoadmaps();
if (selectedRoadmapIdRef.current) {
@@ -1088,6 +1160,11 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
acceptFeatureSuggestion,
acceptAllFeatureSuggestions,
clearFeatureSuggestions,
handoffPayload,
isFetchingHandoff,
handoffError,
fetchHandoff,
clearHandoff,
refresh,
};
}