import { useRef, useCallback, useEffect, useState } from "react"; import { trpc } from "@/lib/trpc"; import { toast } from "sonner"; interface UsePhaseAutoSaveOptions { debounceMs?: number; onSaved?: () => void; onError?: (error: Error) => void; } export function usePhaseAutoSave({ debounceMs = 1000, onSaved, onError }: UsePhaseAutoSaveOptions = {}) { const [lastError, setLastError] = useState(null); const [retryCount, setRetryCount] = useState(0); const utils = trpc.useUtils(); const updateMutation = trpc.updatePhase.useMutation({ onMutate: async (variables) => { // Cancel any outgoing refetches await utils.getPhase.cancel({ id: variables.id }); await utils.listPhases.cancel(); // Snapshot previous values const previousPhase = utils.getPhase.getData({ id: variables.id }); const previousPhases = utils.listPhases.getData(); // Optimistically update phase in cache if (previousPhase) { const optimisticUpdate = { ...previousPhase, ...(variables.content !== undefined && { content: variables.content }), updatedAt: new Date().toISOString(), }; utils.getPhase.setData({ id: variables.id }, optimisticUpdate); // Also update in the phases list if present if (previousPhases) { utils.listPhases.setData(undefined, previousPhases.map(phase => phase.id === variables.id ? optimisticUpdate : phase ) ); } } return { previousPhase, previousPhases }; }, onSuccess: () => { setLastError(null); setRetryCount(0); onSaved?.(); }, onError: (error, variables, context) => { // Revert optimistic updates if (context?.previousPhase) { utils.getPhase.setData({ id: variables.id }, context.previousPhase); } if (context?.previousPhases) { utils.listPhases.setData(undefined, context.previousPhases); } setLastError(error); onError?.(error); }, // Invalidation handled globally by MutationCache }); const timerRef = useRef | null>(null); const pendingRef = useRef<{ id: string; content?: string | null; } | null>(null); const flush = useCallback(async () => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } if (pendingRef.current) { const data = pendingRef.current; pendingRef.current = null; try { await updateMutation.mutateAsync(data); return; } catch (error) { // Retry logic for transient failures if (retryCount < 2 && error instanceof Error) { setRetryCount(prev => prev + 1); pendingRef.current = data; // Restore data for retry // Exponential backoff: 1s, 2s const delay = 1000 * Math.pow(2, retryCount); setTimeout(() => void flush(), delay); return; } // Final failure - show user feedback toast.error(`Failed to save phase: ${error instanceof Error ? error.message : 'Unknown error'}`, { action: { label: 'Retry', onClick: () => { setRetryCount(0); pendingRef.current = data; void flush(); }, }, }); throw error; } } return Promise.resolve(); }, [updateMutation, retryCount]); const save = useCallback( (id: string, data: { content?: string | null }) => { pendingRef.current = { id, ...data }; if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current = setTimeout(() => void flush(), debounceMs); }, [debounceMs, flush], ); // Flush on unmount useEffect(() => { return () => { if (timerRef.current) { clearTimeout(timerRef.current); } if (pendingRef.current) { const data = pendingRef.current; pendingRef.current = null; updateMutation.mutate(data); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { save, flush, isSaving: updateMutation.isPending, lastError, hasError: lastError !== null, retryCount, }; }