Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt standard monorepo conventions (apps/ for runnable apps, packages/ for reusable libraries). Update all config files, shared package imports, test fixtures, and documentation to reflect new paths. Key fixes: - Update workspace config to ["apps/*", "packages/*"] - Update tsconfig.json rootDir/include for apps/server/ - Add apps/web/** to vitest exclude list - Update drizzle.config.ts schema path - Fix ensure-schema.ts migration path detection (3 levels up in dev, 2 levels up in dist) - Fix tests/integration/cli-server.test.ts import paths - Update packages/shared imports to apps/server/ paths - Update all docs/ files with new paths
150 lines
4.2 KiB
TypeScript
150 lines
4.2 KiB
TypeScript
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<Error | null>(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<ReturnType<typeof setTimeout> | 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,
|
|
};
|
|
}
|