191 lines
5.1 KiB
TypeScript
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,
|
|
};
|
|
}
|