learning_ai_clock/web/src/lib/use-sync.ts

191 lines
5.1 KiB
TypeScript

// ── useSyncHook ───────────────────────────────────────────────
// React hook for cross-platform sync with platform-service
// Auto-syncs on interval, merges remote changes into Zustand store
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTimerStore } from './store';
import { useRoutineStore } from './routine-store';
import {
fullSync,
isSyncEnabled,
setSyncEnabled as _setSyncEnabled,
isAuthenticated,
setAuthToken,
loadOfflineQueue,
dtoToTimerPatch,
dtoToRoutinePatch,
type SyncResult,
type SyncConflict,
} from './platform-sync';
import type { Timer } from './timer-engine';
import type { Routine } from './routines';
const SYNC_INTERVAL_MS = 60_000; // 1 minute
export interface UseSyncReturn {
isSyncing: boolean;
syncEnabled: boolean;
lastSyncDate: string | null;
pendingChanges: number;
lastError: string | null;
conflicts: SyncConflict[];
// Actions
syncNow: () => Promise<SyncResult>;
setSyncEnabled: (enabled: boolean) => void;
login: (token: string) => void;
logout: () => void;
}
export function useSync(): UseSyncReturn {
const [isSyncing, setIsSyncing] = useState(false);
const [syncEnabled, setSyncEnabledState] = useState(false);
const [lastSyncDate, setLastSyncDate] = useState<string | null>(null);
const [pendingChanges, setPendingChanges] = useState(0);
const [lastError, setLastError] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Load initial state
useEffect(() => {
setSyncEnabledState(isSyncEnabled());
setPendingChanges(loadOfflineQueue().length);
setLastSyncDate(
typeof window !== 'undefined'
? localStorage.getItem('chronomind-platform-last-sync')
: null
);
}, []);
// Perform sync and merge results into store
const syncNow = useCallback(async (): Promise<SyncResult> => {
setIsSyncing(true);
setLastError(null);
try {
const result = await fullSync();
if (result.error) {
setLastError(result.error);
}
// Merge pulled timers into Zustand store
if (result.pulled.length > 0) {
const store = useTimerStore.getState();
const currentTimers = [...store.timers];
for (const dto of result.pulled) {
const patch = dtoToTimerPatch(dto);
const existingIdx = currentTimers.findIndex((t) => t.id === dto.id);
if (existingIdx >= 0) {
// Merge: remote wins if syncVersion >= local
currentTimers[existingIdx] = {
...currentTimers[existingIdx],
...patch,
} as Timer;
} else {
// New timer from remote — add it
currentTimers.push(patch as Timer);
}
}
useTimerStore.setState({ timers: currentTimers });
}
// Merge pulled routines into routine store
if (result.pulledRoutines.length > 0) {
const routineStore = useRoutineStore.getState();
const currentRoutines = [...routineStore.routines];
for (const dto of result.pulledRoutines) {
const patch = dtoToRoutinePatch(dto);
const existingIdx = currentRoutines.findIndex((r) => r.id === dto.id);
if (existingIdx >= 0) {
currentRoutines[existingIdx] = {
...currentRoutines[existingIdx],
...patch,
} as Routine;
} else {
currentRoutines.push(patch as Routine);
}
}
useRoutineStore.setState({ routines: currentRoutines });
}
if (result.conflicts.length > 0) {
setConflicts((prev) => [...prev, ...result.conflicts]);
}
setPendingChanges(loadOfflineQueue().length);
setLastSyncDate(
typeof window !== 'undefined'
? localStorage.getItem('chronomind-platform-last-sync')
: null
);
return result;
} finally {
setIsSyncing(false);
}
}, []);
// Auto-sync interval
useEffect(() => {
if (syncEnabled && isAuthenticated()) {
// Initial sync on enable
syncNow();
intervalRef.current = setInterval(() => {
syncNow();
}, SYNC_INTERVAL_MS);
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [syncEnabled, syncNow]);
const setSyncEnabled = useCallback(
(enabled: boolean) => {
_setSyncEnabled(enabled);
setSyncEnabledState(enabled);
},
[]
);
const login = useCallback(
(token: string) => {
setAuthToken(token);
setSyncEnabled(true);
},
[setSyncEnabled]
);
const logout = useCallback(() => {
setAuthToken(null);
setSyncEnabled(false);
setConflicts([]);
}, [setSyncEnabled]);
return {
isSyncing,
syncEnabled,
lastSyncDate,
pendingChanges,
lastError,
conflicts,
syncNow,
setSyncEnabled,
login,
logout,
};
}