1189 lines
44 KiB
TypeScript
1189 lines
44 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import type {
|
|
Roadmap,
|
|
RoadmapMilestone,
|
|
RoadmapFeature,
|
|
RoadmapCreateInput,
|
|
RoadmapUpdateInput,
|
|
RoadmapMilestoneCreateInput,
|
|
RoadmapMilestoneUpdateInput,
|
|
RoadmapFeatureCreateInput,
|
|
RoadmapFeatureUpdateInput,
|
|
RoadmapWithHierarchy,
|
|
RoadmapMissionPlanningHandoff,
|
|
RoadmapFeatureTaskPlanningHandoff,
|
|
} from "@fusion/core";
|
|
import * as api from "../api";
|
|
|
|
/**
|
|
* A suggested milestone from AI generation with a stable local draft ID.
|
|
* Draft IDs enable stable identity when drafts are reordered or edited.
|
|
* Drafts are ephemeral and NOT persisted until explicit acceptance.
|
|
*/
|
|
export interface MilestoneSuggestion {
|
|
/** Stable local draft ID for UI binding and identity */
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
}
|
|
|
|
/**
|
|
* A suggested feature from AI generation with a stable local draft ID.
|
|
* Draft IDs enable stable identity when drafts are reordered or edited.
|
|
* Drafts are ephemeral and NOT persisted until explicit acceptance.
|
|
*/
|
|
export interface FeatureSuggestion {
|
|
/** Stable local draft ID for UI binding and identity */
|
|
id: string;
|
|
title: string;
|
|
description?: string;
|
|
}
|
|
|
|
/** Patch type for updating a suggestion draft */
|
|
export type SuggestionDraftPatch = {
|
|
title?: string;
|
|
description?: string;
|
|
};
|
|
|
|
export interface UseRoadmapsOptions {
|
|
/** When provided, fetches roadmaps for this project */
|
|
projectId?: string;
|
|
}
|
|
|
|
export interface UseRoadmapsResult {
|
|
/** All roadmaps for the current project */
|
|
roadmaps: Roadmap[];
|
|
/** Currently selected roadmap ID */
|
|
selectedRoadmapId: string | null;
|
|
/** Selected roadmap with full hierarchy (milestones and features) */
|
|
selectedRoadmap: RoadmapWithHierarchy | null;
|
|
/** Milestones for the selected roadmap */
|
|
milestones: RoadmapMilestone[];
|
|
/** Features by milestone ID */
|
|
featuresByMilestoneId: Record<string, RoadmapFeature[]>;
|
|
/** Loading state */
|
|
loading: boolean;
|
|
/** Error state */
|
|
error: Error | null;
|
|
|
|
// Roadmap CRUD callbacks
|
|
/** Create a new roadmap */
|
|
createRoadmap: (input: RoadmapCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Update a roadmap */
|
|
updateRoadmap: (roadmapId: string, updates: RoadmapUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Delete a roadmap */
|
|
deleteRoadmap: (roadmapId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Select a roadmap to view its details */
|
|
selectRoadmap: (roadmapId: string | null) => void;
|
|
|
|
// Milestone CRUD callbacks
|
|
/** Create a milestone in the selected roadmap */
|
|
createMilestone: (input: RoadmapMilestoneCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Update a milestone */
|
|
updateMilestone: (milestoneId: string, updates: RoadmapMilestoneUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Delete a milestone */
|
|
deleteMilestone: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
|
|
// Milestone ordering callbacks
|
|
/** Reorder milestones within a roadmap */
|
|
reorderMilestones: (roadmapId: string, orderedMilestoneIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
|
|
// Feature CRUD callbacks
|
|
/** Create a feature in a milestone */
|
|
createFeature: (milestoneId: string, input: RoadmapFeatureCreateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Update a feature */
|
|
updateFeature: (featureId: string, updates: RoadmapFeatureUpdateInput, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Delete a feature */
|
|
deleteFeature: (featureId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
|
|
// Feature ordering callbacks
|
|
/** Reorder features within a milestone */
|
|
reorderFeatures: (milestoneId: string, orderedFeatureIds: string[], opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Move a feature to a different milestone or position */
|
|
moveFeature: (featureId: string, targetMilestoneId: string, targetIndex: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
|
|
// Milestone suggestion callbacks
|
|
/** Current pending milestone suggestions (ephemeral, in-memory only) */
|
|
milestoneSuggestions: MilestoneSuggestion[];
|
|
/** Whether suggestions are currently being generated */
|
|
isGeneratingSuggestions: boolean;
|
|
/** Generate milestone suggestions from a goal prompt */
|
|
generateMilestoneSuggestions: (goalPrompt: string, count?: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Update a milestone suggestion draft before acceptance */
|
|
updateMilestoneSuggestionDraft: (draftId: string, patch: SuggestionDraftPatch) => void;
|
|
/** Accept a single milestone suggestion and create it as a milestone */
|
|
acceptMilestoneSuggestion: (draftId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Accept all milestone suggestions and create them as milestones (sequentially, in draft order) */
|
|
acceptAllMilestoneSuggestions: (opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Clear all pending milestone suggestions */
|
|
clearMilestoneSuggestions: () => void;
|
|
|
|
// Feature suggestion callbacks (ephemeral, scoped by milestone)
|
|
/** Pending feature suggestions by milestone ID (ephemeral, in-memory only) */
|
|
featureSuggestionsByMilestoneId: Record<string, FeatureSuggestion[]>;
|
|
/** Whether feature suggestions are being generated for a specific milestone */
|
|
isGeneratingFeatureSuggestions: (milestoneId: string) => boolean;
|
|
/** Generate feature suggestions for a specific milestone */
|
|
generateFeatureSuggestions: (milestoneId: string, input?: { prompt?: string; count?: number }, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Update a feature suggestion draft before acceptance */
|
|
updateFeatureSuggestionDraft: (milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => void;
|
|
/** Accept a single feature suggestion and create it as a feature */
|
|
acceptFeatureSuggestion: (milestoneId: string, draftId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** Accept all feature suggestions for a milestone (sequentially, in draft order) */
|
|
acceptAllFeatureSuggestions: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
|
/** 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>;
|
|
}
|
|
|
|
export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
|
const projectId = options?.projectId;
|
|
const [roadmaps, setRoadmaps] = useState<Roadmap[]>([]);
|
|
const [selectedRoadmapId, setSelectedRoadmapId] = useState<string | null>(null);
|
|
const [selectedRoadmap, setSelectedRoadmap] = useState<RoadmapWithHierarchy | null>(null);
|
|
const [milestones, setMilestones] = useState<RoadmapMilestone[]>([]);
|
|
const [featuresByMilestoneId, setFeaturesByMilestoneId] = useState<Record<string, RoadmapFeature[]>>({});
|
|
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);
|
|
|
|
// Ephemeral feature suggestion state keyed by milestone ID (in-memory only, not persisted)
|
|
const [featureSuggestionsByMilestoneId, setFeatureSuggestionsByMilestoneId] = useState<Record<string, FeatureSuggestion[]>>({});
|
|
const [generatingFeatureSuggestions, setGeneratingFeatureSuggestions] = useState<Record<string, boolean>>({});
|
|
|
|
// Refs for feature suggestion state
|
|
const featureSuggestionsByMilestoneIdRef = useRef(featureSuggestionsByMilestoneId);
|
|
const generatingFeatureSuggestionsRef = useRef(generatingFeatureSuggestions);
|
|
const milestoneSuggestionsRef = useRef(milestoneSuggestions);
|
|
|
|
featureSuggestionsByMilestoneIdRef.current = featureSuggestionsByMilestoneId;
|
|
generatingFeatureSuggestionsRef.current = generatingFeatureSuggestions;
|
|
milestoneSuggestionsRef.current = milestoneSuggestions;
|
|
|
|
// Track previous projectId to detect changes
|
|
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(() => {
|
|
if (previousProjectIdRef.current !== projectId) {
|
|
previousProjectIdRef.current = projectId;
|
|
projectContextVersionRef.current++;
|
|
setSelectedRoadmapId(null);
|
|
setSelectedRoadmap(null);
|
|
setMilestones([]);
|
|
setFeaturesByMilestoneId({});
|
|
// Clear handoff state
|
|
setHandoffPayload(null);
|
|
setHandoffError(null);
|
|
// Clear ephemeral suggestion state
|
|
setMilestoneSuggestions([]);
|
|
setIsGeneratingSuggestions(false);
|
|
setFeatureSuggestionsByMilestoneId({});
|
|
setGeneratingFeatureSuggestions({});
|
|
}
|
|
}, [projectId]);
|
|
|
|
// Fetch roadmaps on mount and when projectId changes
|
|
const fetchRoadmaps = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const fetchedRoadmaps = await api.fetchRoadmaps(projectId);
|
|
setRoadmaps(fetchedRoadmaps);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error("Failed to fetch roadmaps"));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [projectId]);
|
|
|
|
// Fetch selected roadmap with full hierarchy
|
|
const fetchSelectedRoadmap = useCallback(async (roadmapId: string) => {
|
|
try {
|
|
const roadmap = await api.fetchRoadmap(roadmapId, projectId);
|
|
setSelectedRoadmap(roadmap);
|
|
setMilestones(roadmap.milestones || []);
|
|
|
|
// Build features by milestone ID
|
|
const featuresMap: Record<string, RoadmapFeature[]> = {};
|
|
for (const milestone of roadmap.milestones || []) {
|
|
featuresMap[milestone.id] = milestone.features || [];
|
|
}
|
|
setFeaturesByMilestoneId(featuresMap);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err : new Error("Failed to fetch roadmap"));
|
|
}
|
|
}, [projectId]);
|
|
|
|
// Initial fetch
|
|
useEffect(() => {
|
|
void fetchRoadmaps();
|
|
}, [fetchRoadmaps]);
|
|
|
|
// Fetch selected roadmap when selection changes
|
|
useEffect(() => {
|
|
if (selectedRoadmapId) {
|
|
void fetchSelectedRoadmap(selectedRoadmapId);
|
|
} else {
|
|
setSelectedRoadmap(null);
|
|
setMilestones([]);
|
|
setFeaturesByMilestoneId({});
|
|
}
|
|
}, [selectedRoadmapId, fetchSelectedRoadmap]);
|
|
|
|
// Roadmap CRUD
|
|
const createRoadmap = useCallback(async (
|
|
input: RoadmapCreateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
const newRoadmap = await api.createRoadmap(input, projectIdRef.current);
|
|
setRoadmaps((prev) => [...prev, newRoadmap]);
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to create roadmap");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, []); // No dependencies needed - uses refs
|
|
|
|
const updateRoadmap = useCallback(async (
|
|
roadmapId: string,
|
|
updates: RoadmapUpdateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
const updated = await api.updateRoadmap(roadmapId, updates, projectIdRef.current);
|
|
setRoadmaps((prev) => prev.map((r) => (r.id === roadmapId ? updated : r)));
|
|
if (selectedRoadmapIdRef.current === roadmapId) {
|
|
setSelectedRoadmap((prev) => prev ? { ...prev, ...updated } : null);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to update roadmap");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, []); // No dependencies needed - uses refs
|
|
|
|
const deleteRoadmap = useCallback(async (
|
|
roadmapId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
await api.deleteRoadmap(roadmapId, projectIdRef.current);
|
|
setRoadmaps((prev) => prev.filter((r) => r.id !== roadmapId));
|
|
if (selectedRoadmapIdRef.current === roadmapId) {
|
|
setSelectedRoadmapId(null);
|
|
setSelectedRoadmap(null);
|
|
setMilestones([]);
|
|
setFeaturesByMilestoneId({});
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to delete roadmap");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, []); // No dependencies needed - uses refs
|
|
|
|
const selectRoadmap = useCallback((roadmapId: string | null) => {
|
|
setSelectedRoadmapId(roadmapId);
|
|
}, []);
|
|
|
|
// Milestone CRUD
|
|
const createMilestone = useCallback(async (
|
|
input: RoadmapMilestoneCreateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
const currentRoadmapId = selectedRoadmapIdRef.current;
|
|
if (!currentRoadmapId) {
|
|
const error = new Error("No roadmap selected");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
try {
|
|
const newMilestone = await api.createRoadmapMilestone(currentRoadmapId, input, projectIdRef.current);
|
|
setMilestones((prev) => [...prev, newMilestone]);
|
|
setFeaturesByMilestoneId((prev) => ({ ...prev, [newMilestone.id]: [] }));
|
|
// Refresh the full roadmap to get updated hierarchy
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to create milestone");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]);
|
|
|
|
const updateMilestone = useCallback(async (
|
|
milestoneId: string,
|
|
updates: RoadmapMilestoneUpdateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
const updated = await api.updateRoadmapMilestone(milestoneId, updates, projectIdRef.current);
|
|
setMilestones((prev) => prev.map((m) => (m.id === milestoneId ? updated : m)));
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to update milestone");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
const deleteMilestone = useCallback(async (
|
|
milestoneId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
await api.deleteRoadmapMilestone(milestoneId, projectIdRef.current);
|
|
setMilestones((prev) => prev.filter((m) => m.id !== milestoneId));
|
|
setFeaturesByMilestoneId((prev) => {
|
|
const updated = { ...prev };
|
|
delete updated[milestoneId];
|
|
return updated;
|
|
});
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to delete milestone");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
// Feature CRUD
|
|
const createFeature = useCallback(async (
|
|
milestoneId: string,
|
|
input: RoadmapFeatureCreateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
const newFeature = await api.createRoadmapFeature(milestoneId, input, projectIdRef.current);
|
|
setFeaturesByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: [...(prev[milestoneId] || []), newFeature],
|
|
}));
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to create feature");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
const updateFeature = useCallback(async (
|
|
featureId: string,
|
|
updates: RoadmapFeatureUpdateInput,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
const updated = await api.updateRoadmapFeature(featureId, updates, projectIdRef.current);
|
|
setFeaturesByMilestoneId((prev) => {
|
|
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
|
for (const [milestoneId, features] of Object.entries(prev)) {
|
|
updatedMap[milestoneId] = features.map((f) => (f.id === featureId ? updated : f));
|
|
}
|
|
return updatedMap;
|
|
});
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to update feature");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
const deleteFeature = useCallback(async (
|
|
featureId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
try {
|
|
await api.deleteRoadmapFeature(featureId, projectIdRef.current);
|
|
setFeaturesByMilestoneId((prev) => {
|
|
const updatedMap: Record<string, RoadmapFeature[]> = {};
|
|
for (const [milestoneId, features] of Object.entries(prev)) {
|
|
updatedMap[milestoneId] = features.filter((f) => f.id !== featureId);
|
|
}
|
|
return updatedMap;
|
|
});
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error("Failed to delete feature");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
// Milestone ordering
|
|
const reorderMilestones = useCallback(async (
|
|
roadmapId: string,
|
|
orderedMilestoneIds: string[],
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// Save snapshot for rollback
|
|
const snapshot = milestonesRef.current;
|
|
|
|
// Optimistic update
|
|
const reordered = orderedMilestoneIds
|
|
.map((id) => snapshot.find((m) => m.id === id))
|
|
.filter((m): m is RoadmapMilestone => m !== undefined)
|
|
.map((m, index) => ({ ...m, orderIndex: index }));
|
|
|
|
setMilestones(reordered);
|
|
|
|
try {
|
|
await api.reorderRoadmapMilestones(roadmapId, orderedMilestoneIds, projectIdRef.current);
|
|
// Refresh to get server state
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Rollback to snapshot
|
|
setMilestones(snapshot);
|
|
const error = err instanceof Error ? err : new Error("Failed to reorder milestones");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
// Feature ordering
|
|
const reorderFeatures = useCallback(async (
|
|
milestoneId: string,
|
|
orderedFeatureIds: string[],
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// No-op suppression: if IDs are already in the same order, skip API call
|
|
const currentFeatures = featuresByMilestoneIdRef.current[milestoneId] || [];
|
|
const currentIds = currentFeatures.map((f) => f.id);
|
|
if (JSON.stringify(currentIds) === JSON.stringify(orderedFeatureIds)) {
|
|
opts?.onSuccess?.();
|
|
return;
|
|
}
|
|
|
|
// Save snapshot for rollback
|
|
const snapshot = featuresByMilestoneIdRef.current;
|
|
|
|
// Optimistic update
|
|
const reordered = orderedFeatureIds
|
|
.map((id) => currentFeatures.find((f) => f.id === id))
|
|
.filter((f): f is RoadmapFeature => f !== undefined)
|
|
.map((f, index) => ({ ...f, orderIndex: index }));
|
|
|
|
setFeaturesByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: reordered,
|
|
}));
|
|
|
|
try {
|
|
await api.reorderRoadmapFeatures(milestoneId, orderedFeatureIds, projectIdRef.current);
|
|
// Refresh to get server state
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Rollback to snapshot
|
|
setFeaturesByMilestoneId(snapshot);
|
|
const error = err instanceof Error ? err : new Error("Failed to reorder features");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]); // Uses refs internally
|
|
|
|
const moveFeature = useCallback(async (
|
|
featureId: string,
|
|
targetMilestoneId: string,
|
|
targetIndex: number,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// Save snapshot for rollback
|
|
const snapshot = featuresByMilestoneIdRef.current;
|
|
|
|
// Find which milestone the feature is currently in
|
|
let sourceMilestoneId: string | null = null;
|
|
for (const [milestoneId, features] of Object.entries(snapshot)) {
|
|
if (features.some((f) => f.id === featureId)) {
|
|
sourceMilestoneId = milestoneId;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!sourceMilestoneId) {
|
|
const error = new Error("Feature not found");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// No-op suppression: if already at target position in same milestone, skip
|
|
if (sourceMilestoneId === targetMilestoneId) {
|
|
const currentFeatures = snapshot[sourceMilestoneId] || [];
|
|
const clampedIndex = Math.max(0, Math.min(targetIndex, currentFeatures.length - 1));
|
|
const currentIndex = currentFeatures.findIndex((f) => f.id === featureId);
|
|
if (currentIndex === clampedIndex) {
|
|
opts?.onSuccess?.();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Optimistic update
|
|
const sourceFeatures = snapshot[sourceMilestoneId] || [];
|
|
const targetFeatures = snapshot[targetMilestoneId] || [];
|
|
const feature = sourceFeatures.find((f) => f.id === featureId);
|
|
|
|
if (!feature) {
|
|
const error = new Error("Feature not found");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Remove from source
|
|
const newSourceFeatures = sourceFeatures
|
|
.filter((f) => f.id !== featureId)
|
|
.map((f, index) => ({ ...f, orderIndex: index }));
|
|
|
|
// Add to target at correct position
|
|
const updatedFeature = { ...feature, milestoneId: targetMilestoneId, orderIndex: targetIndex };
|
|
const newTargetFeatures = [...targetFeatures];
|
|
newTargetFeatures.splice(targetIndex, 0, updatedFeature);
|
|
// Renormalize target
|
|
const normalizedTargetFeatures = newTargetFeatures.map((f, index) => ({ ...f, orderIndex: index }));
|
|
|
|
// If moving within same milestone, update source with the new order
|
|
if (sourceMilestoneId === targetMilestoneId) {
|
|
setFeaturesByMilestoneId((prev) => ({
|
|
...prev,
|
|
[sourceMilestoneId]: normalizedTargetFeatures,
|
|
}));
|
|
} else {
|
|
// Renormalize source after removal
|
|
setFeaturesByMilestoneId((prev) => ({
|
|
...prev,
|
|
[sourceMilestoneId]: newSourceFeatures,
|
|
[targetMilestoneId]: normalizedTargetFeatures,
|
|
}));
|
|
}
|
|
|
|
try {
|
|
await api.moveRoadmapFeature(featureId, targetMilestoneId, targetIndex, projectId);
|
|
// Refresh to get server state
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Rollback to snapshot
|
|
setFeaturesByMilestoneId(snapshot);
|
|
const error = err instanceof Error ? err : new Error("Failed to move feature");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap, projectId]);
|
|
|
|
// ── Milestone Suggestion Actions (Ephemeral) ───────────────────────────────────
|
|
|
|
/**
|
|
* Generate a stable draft ID for suggestions.
|
|
* Uses crypto.randomUUID() for browser environments with a counter fallback.
|
|
*/
|
|
function generateDraftId(): string {
|
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
return crypto.randomUUID();
|
|
}
|
|
// Fallback for environments without crypto.randomUUID
|
|
return `draft-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
|
}
|
|
|
|
const generateMilestoneSuggestions = useCallback(async (
|
|
goalPrompt: string,
|
|
count: number = 5,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
const currentRoadmapId = selectedRoadmapIdRef.current;
|
|
if (!currentRoadmapId) {
|
|
const error = new Error("No roadmap selected");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Capture project context version for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const requestProjectId = projectIdRef.current;
|
|
|
|
setIsGeneratingSuggestions(true);
|
|
|
|
try {
|
|
const response = await api.generateMilestoneSuggestions(
|
|
currentRoadmapId,
|
|
goalPrompt,
|
|
count,
|
|
requestProjectId
|
|
);
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed during fetch - discard response
|
|
return;
|
|
}
|
|
|
|
// Assign stable draft IDs to suggestions for UI binding and identity
|
|
const suggestionsWithIds: MilestoneSuggestion[] = response.suggestions.map((s) => ({
|
|
id: generateDraftId(),
|
|
title: s.title,
|
|
description: s.description,
|
|
}));
|
|
setMilestoneSuggestions(suggestionsWithIds);
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed during fetch - discard error
|
|
return;
|
|
}
|
|
|
|
const error = err instanceof Error ? err : new Error("Failed to generate suggestions");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
} finally {
|
|
// Only clear loading state if context hasn't changed
|
|
if (projectContextVersionRef.current === contextVersionAtStart) {
|
|
setIsGeneratingSuggestions(false);
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const updateMilestoneSuggestionDraft = useCallback((draftId: string, patch: SuggestionDraftPatch) => {
|
|
// Update ref first for immediate visibility to acceptAll
|
|
const currentSuggestions = milestoneSuggestionsRef.current;
|
|
const updatedSuggestions = currentSuggestions.map((s) => (s.id === draftId ? { ...s, ...patch } : s));
|
|
milestoneSuggestionsRef.current = updatedSuggestions;
|
|
// Then update state for re-render
|
|
setMilestoneSuggestions((prev) =>
|
|
prev.map((s) => (s.id === draftId ? { ...s, ...patch } : s))
|
|
);
|
|
}, []);
|
|
|
|
const acceptMilestoneSuggestion = useCallback(async (
|
|
draftId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
const currentRoadmapId = selectedRoadmapIdRef.current;
|
|
if (!currentRoadmapId) {
|
|
const error = new Error("No roadmap selected");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Capture state for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const currentSuggestions = milestoneSuggestionsRef.current;
|
|
|
|
// Find the suggestion by draft ID
|
|
const index = currentSuggestions.findIndex((s) => s.id === draftId);
|
|
if (index === -1) {
|
|
const error = new Error("Suggestion draft not found");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
const suggestion = currentSuggestions[index];
|
|
|
|
// Validate: title must not be empty/whitespace-only
|
|
if (!suggestion.title.trim()) {
|
|
const error = new Error("Title cannot be empty");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Optimistic update: remove from suggestions immediately
|
|
setMilestoneSuggestions((prev) => prev.filter((s) => s.id !== draftId));
|
|
|
|
try {
|
|
await api.createRoadmapMilestone(
|
|
currentRoadmapId,
|
|
{ title: suggestion.title, description: suggestion.description },
|
|
projectIdRef.current
|
|
);
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed - re-add to suggestions (optimistic rollback)
|
|
setMilestoneSuggestions((prev) => {
|
|
const updated = [...prev];
|
|
updated.splice(index, 0, suggestion);
|
|
return updated;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Refresh the roadmap to get the new milestone
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Rollback: re-add to suggestions
|
|
setMilestoneSuggestions((prev) => {
|
|
const updated = [...prev];
|
|
updated.splice(index, 0, suggestion);
|
|
return updated;
|
|
});
|
|
|
|
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]);
|
|
|
|
const acceptAllMilestoneSuggestions = useCallback(async (
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
const currentRoadmapId = selectedRoadmapIdRef.current;
|
|
if (!currentRoadmapId) {
|
|
const error = new Error("No roadmap selected");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Read from ref - it's updated synchronously by updateMilestoneSuggestionDraft
|
|
const suggestionsToAccept = [...milestoneSuggestionsRef.current];
|
|
if (suggestionsToAccept.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Validate all titles before accepting any
|
|
const emptyTitleIndex = suggestionsToAccept.findIndex((s) => !s.title.trim());
|
|
if (emptyTitleIndex !== -1) {
|
|
const error = new Error(`Title cannot be empty at position ${emptyTitleIndex + 1}`);
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Clear suggestions immediately (optimistic)
|
|
setMilestoneSuggestions([]);
|
|
|
|
// Capture state for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
|
|
// Accept sequentially to preserve order
|
|
for (let i = 0; i < suggestionsToAccept.length; i++) {
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed - stop accepting
|
|
break;
|
|
}
|
|
|
|
const suggestion = suggestionsToAccept[i];
|
|
|
|
try {
|
|
await api.createRoadmapMilestone(
|
|
currentRoadmapId,
|
|
{ title: suggestion.title, description: suggestion.description },
|
|
projectIdRef.current
|
|
);
|
|
} catch (err) {
|
|
// On error, stop accepting and report
|
|
const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
// Refresh the roadmap to get all new milestones
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
|
|
opts?.onSuccess?.();
|
|
}, [fetchSelectedRoadmap]);
|
|
|
|
// ── Feature Suggestion Actions (Ephemeral, Milestone-Scoped) ───────────────────────────────────
|
|
|
|
const isGeneratingFeatureSuggestions = useCallback((milestoneId: string): boolean => {
|
|
return generatingFeatureSuggestionsRef.current[milestoneId] ?? false;
|
|
}, []);
|
|
|
|
const generateFeatureSuggestions = useCallback(async (
|
|
milestoneId: string,
|
|
input?: { prompt?: string; count?: number },
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// Capture project context version for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const requestProjectId = projectIdRef.current;
|
|
|
|
// Set loading state for this milestone
|
|
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: true }));
|
|
|
|
try {
|
|
const response = await api.generateFeatureSuggestions(
|
|
milestoneId,
|
|
input,
|
|
requestProjectId
|
|
);
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed during fetch - discard response
|
|
return;
|
|
}
|
|
|
|
// Assign stable draft IDs to suggestions for UI binding and identity
|
|
const suggestionsWithIds: FeatureSuggestion[] = response.suggestions.map((s) => ({
|
|
id: generateDraftId(),
|
|
title: s.title,
|
|
description: s.description,
|
|
}));
|
|
setFeatureSuggestionsByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: suggestionsWithIds,
|
|
}));
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed during fetch - discard error
|
|
return;
|
|
}
|
|
|
|
const error = err instanceof Error ? err : new Error("Failed to generate feature suggestions");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
} finally {
|
|
// Only clear loading state if context hasn't changed
|
|
if (projectContextVersionRef.current === contextVersionAtStart) {
|
|
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: false }));
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const updateFeatureSuggestionDraft = useCallback((milestoneId: string, draftId: string, patch: SuggestionDraftPatch) => {
|
|
// Update ref first for immediate visibility to acceptAll
|
|
const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
|
|
const updatedSuggestions = currentSuggestions.map((s) => (s.id === draftId ? { ...s, ...patch } : s));
|
|
featureSuggestionsByMilestoneIdRef.current = {
|
|
...featureSuggestionsByMilestoneIdRef.current,
|
|
[milestoneId]: updatedSuggestions,
|
|
};
|
|
// Then update state for re-render
|
|
setFeatureSuggestionsByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: prev[milestoneId]?.map((s) => (s.id === draftId ? { ...s, ...patch } : s)) || [],
|
|
}));
|
|
}, []);
|
|
|
|
const acceptFeatureSuggestion = useCallback(async (
|
|
milestoneId: string,
|
|
draftId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// Capture state for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
|
|
|
|
// Find the suggestion by draft ID
|
|
const index = currentSuggestions.findIndex((s) => s.id === draftId);
|
|
if (index === -1) {
|
|
const error = new Error("Suggestion draft not found");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
const suggestion = currentSuggestions[index];
|
|
|
|
// Validate: title must not be empty/whitespace-only
|
|
if (!suggestion.title.trim()) {
|
|
const error = new Error("Title cannot be empty");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Optimistic update: remove from suggestions immediately
|
|
setFeatureSuggestionsByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: prev[milestoneId]?.filter((s) => s.id !== draftId) || [],
|
|
}));
|
|
|
|
try {
|
|
await api.createRoadmapFeature(
|
|
milestoneId,
|
|
{ title: suggestion.title, description: suggestion.description },
|
|
projectIdRef.current
|
|
);
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed - re-add to suggestions (optimistic rollback)
|
|
setFeatureSuggestionsByMilestoneId((prev) => {
|
|
const milestoneSuggestions = prev[milestoneId] || [];
|
|
const updated = [...milestoneSuggestions];
|
|
updated.splice(index, 0, suggestion);
|
|
return { ...prev, [milestoneId]: updated };
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Refresh the roadmap to get the new feature
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
|
|
opts?.onSuccess?.();
|
|
} catch (err) {
|
|
// Rollback: re-add to suggestions
|
|
setFeatureSuggestionsByMilestoneId((prev) => {
|
|
const milestoneSuggestions = prev[milestoneId] || [];
|
|
const updated = [...milestoneSuggestions];
|
|
updated.splice(index, 0, suggestion);
|
|
return { ...prev, [milestoneId]: updated };
|
|
});
|
|
|
|
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}, [fetchSelectedRoadmap]);
|
|
|
|
const acceptAllFeatureSuggestions = useCallback(async (
|
|
milestoneId: string,
|
|
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
|
) => {
|
|
// Read from ref - it's updated synchronously by updateFeatureSuggestionDraft
|
|
const suggestionsToAccept = [...(featureSuggestionsByMilestoneIdRef.current[milestoneId] || [])];
|
|
if (suggestionsToAccept.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Validate all titles before accepting any
|
|
const emptyTitleIndex = suggestionsToAccept.findIndex((s) => !s.title.trim());
|
|
if (emptyTitleIndex !== -1) {
|
|
const error = new Error(`Title cannot be empty at position ${emptyTitleIndex + 1}`);
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
|
|
// Clear suggestions for this milestone immediately (optimistic)
|
|
setFeatureSuggestionsByMilestoneId((prev) => ({
|
|
...prev,
|
|
[milestoneId]: [],
|
|
}));
|
|
|
|
// Capture state for stale-response protection
|
|
const contextVersionAtStart = projectContextVersionRef.current;
|
|
|
|
// Accept sequentially to preserve order
|
|
for (let i = 0; i < suggestionsToAccept.length; i++) {
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
// Project context changed - stop accepting
|
|
break;
|
|
}
|
|
|
|
const suggestion = suggestionsToAccept[i];
|
|
|
|
try {
|
|
await api.createRoadmapFeature(
|
|
milestoneId,
|
|
{ title: suggestion.title, description: suggestion.description },
|
|
projectIdRef.current
|
|
);
|
|
} catch (err) {
|
|
// On error, stop accepting and report
|
|
const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
|
|
opts?.onError?.(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Check for stale response
|
|
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
|
return;
|
|
}
|
|
|
|
// Refresh the roadmap to get all new features
|
|
if (selectedRoadmapIdRef.current) {
|
|
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
|
|
opts?.onSuccess?.();
|
|
}, [fetchSelectedRoadmap]);
|
|
|
|
const clearMilestoneSuggestions = useCallback(() => {
|
|
setMilestoneSuggestions([]);
|
|
setIsGeneratingSuggestions(false);
|
|
}, []);
|
|
|
|
const clearFeatureSuggestions = useCallback((milestoneId: string) => {
|
|
setFeatureSuggestionsByMilestoneId((prev) => {
|
|
const updated = { ...prev };
|
|
delete updated[milestoneId];
|
|
return updated;
|
|
});
|
|
setGeneratingFeatureSuggestions((prev) => {
|
|
const updated = { ...prev };
|
|
delete updated[milestoneId];
|
|
return updated;
|
|
});
|
|
}, []);
|
|
|
|
// ── 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) {
|
|
await fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
|
}
|
|
}, [fetchRoadmaps, fetchSelectedRoadmap]);
|
|
|
|
return {
|
|
roadmaps,
|
|
selectedRoadmapId,
|
|
selectedRoadmap,
|
|
milestones,
|
|
featuresByMilestoneId,
|
|
loading,
|
|
error,
|
|
createRoadmap,
|
|
updateRoadmap,
|
|
deleteRoadmap,
|
|
selectRoadmap,
|
|
createMilestone,
|
|
updateMilestone,
|
|
deleteMilestone,
|
|
reorderMilestones,
|
|
createFeature,
|
|
updateFeature,
|
|
deleteFeature,
|
|
reorderFeatures,
|
|
moveFeature,
|
|
milestoneSuggestions,
|
|
isGeneratingSuggestions,
|
|
generateMilestoneSuggestions,
|
|
updateMilestoneSuggestionDraft,
|
|
acceptMilestoneSuggestion,
|
|
acceptAllMilestoneSuggestions,
|
|
clearMilestoneSuggestions,
|
|
featureSuggestionsByMilestoneId,
|
|
isGeneratingFeatureSuggestions,
|
|
generateFeatureSuggestions,
|
|
updateFeatureSuggestionDraft,
|
|
acceptFeatureSuggestion,
|
|
acceptAllFeatureSuggestions,
|
|
clearFeatureSuggestions,
|
|
handoffPayload,
|
|
isFetchingHandoff,
|
|
handoffError,
|
|
fetchHandoff,
|
|
clearHandoff,
|
|
refresh,
|
|
};
|
|
}
|