Files
Codewalkers/apps/web/src/hooks/usePhaseAutoSave.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
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
2026-03-03 11:22:53 +01:00

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,
};
}