fix: preserve timer state during debounced field saves (#7)

When editing a task field (title, description, etc.) while a timer was
running, the debounced save's onSuccess handler would overwrite the
timeTracking cache with stale server data. This happened because:

1. User stops timer → useStopTimer patches cache (isRunning: false)
2. Pending debounced save fires → server responds with pre-stop data
3. useUpdateTask.onSuccess writes server response → isRunning: true again

The fix: in useUpdateTask.onSuccess, preserve the cached timeTracking
data unless the update explicitly included timeTracking changes. Since
useDebouncedSave only sends dirty fields (title, description, etc.),
timeTracking is never in the update input for normal field edits, so the
cache's more recent timer state is preserved.

Fixes #7
This commit is contained in:
Brad Groux 2026-01-31 04:08:06 -06:00
parent bb453ab6e9
commit bf61fd698b

View file

@ -92,16 +92,28 @@ export function useUpdateTask() {
return useMutation({
mutationFn: ({ id, input }: { id: string; input: UpdateTaskInput }) =>
api.tasks.update(id, input),
// On success, patch the cache with the server response (authoritative)
// No optimistic update here — useDebouncedSave provides instant UI feedback
// via localTask, and optimistic updates were causing race conditions with
// timer start/stop mutations (cancelQueries would abort their cache patches).
onSuccess: (task) => {
// Patch both the list and individual caches with the real server response
// On success, merge the server response with the current cache.
// Preserve timeTracking from the cache if it wasn't part of this update,
// since concurrent timer mutations (start/stop) may have already patched
// the cache with newer time tracking state. Without this, the debounced
// save response (which doesn't include timeTracking changes) would
// overwrite the timer stop/start that happened in between.
onSuccess: (serverTask, { input }) => {
const mergeWithCachedTimeTracking = (cached: Task | undefined): Task => {
if (!cached || input.timeTracking !== undefined) {
// If this update explicitly included timeTracking, use server response as-is
return serverTask;
}
// Preserve the cached timeTracking (which may reflect a more recent timer mutation)
return { ...serverTask, timeTracking: cached.timeTracking };
};
queryClient.setQueryData<Task[]>(['tasks'], (old) =>
old ? old.map((t) => (t.id === task.id ? task : t)) : old
old ? old.map((t) => (t.id === serverTask.id ? mergeWithCachedTimeTracking(t) : t)) : old
);
queryClient.setQueryData(['tasks', task.id], task);
const cachedTask = queryClient.getQueryData<Task>(['tasks', serverTask.id]);
queryClient.setQueryData(['tasks', serverTask.id], mergeWithCachedTimeTracking(cachedTask));
},
// Always refetch to sync with server
onSettled: () => {