// ── Platform Sync Client ────────────────────────────────────── // Cross-platform sync via @bytelyst/sync engine + platform-service REST API // Consumed by useSyncHook for React integration // // Migration note: offline queue, retry, deduplication, and connectivity // are now delegated to @bytelyst/sync. DTO conversion and auth helpers // remain ChronoMind-specific. import type { Timer } from './timer-engine'; import type { Routine, RoutineStep } from './routines'; import { getAuthClient, getBaseUrl, PRODUCT_ID as _PRODUCT_ID } from './auth-api'; import { createSyncEngine, LocalStorageAdapter, type SyncEngine as BLSyncEngine, type SyncResult as BLSyncResult, } from '@bytelyst/sync'; import { createApiClient, type ApiClient } from '@bytelyst/api-client'; // ── DTOs ────────────────────────────────────────────────────── export interface SyncTimerDTO { id: string; label: string; description?: string; type: string; state: string; urgency: string; duration: number; targetTime: string; // ISO 8601 createdAt: string; startedAt?: string; pausedAt?: string; firedAt?: string; completedAt?: string; cascade?: { preset: string; intervals: number[] }; pomodoro?: { focusMinutes: number; shortBreakMinutes: number; longBreakMinutes: number; roundsBeforeLong: number; currentRound: number; isBreak: boolean; totalRoundsCompleted: number; }; isCalendarSync?: boolean; category?: string; syncVersion: number; } export interface SyncConflict { id: string; serverVersion: number; } export interface BatchResult { synced: string[]; conflicts: SyncConflict[]; errors: string[]; } export interface SyncRoutineStepDTO { id: string; label: string; durationMinutes: number; transition: string; customTransitionMinutes?: number; notes?: string; status: string; } export interface SyncRoutineDTO { id: string; name: string; description?: string; steps: SyncRoutineStepDTO[]; totalDurationMinutes: number; status: string; currentStepIndex: number; isTemplate: boolean; createdAt: string; startedAt?: string; completedAt?: string; syncVersion: number; } export interface OfflineQueueItem { id: string; action: 'create' | 'update' | 'delete'; timer?: SyncTimerDTO; routine?: SyncRoutineDTO; entityType: 'timer' | 'routine'; enqueuedAt: number; } // ── Storage Keys ────────────────────────────────────────────── const STORAGE_KEYS = { lastSync: 'chronomind-platform-last-sync', offlineQueue: 'chronomind-offline-queue', syncEnabled: 'chronomind-platform-sync-enabled', } as const; export const PRODUCT_ID = _PRODUCT_ID; // ── @bytelyst/sync Engine (lazy singleton) ─────────────────── let _syncEngine: BLSyncEngine | null = null; let _apiClient: ApiClient | null = null; function getApiClient(): ApiClient { if (!_apiClient) { _apiClient = createApiClient({ baseUrl: getBaseUrl(), getToken: () => getAuthClient().getAccessToken(), }); } return _apiClient; } export function getSyncEngine(): BLSyncEngine { if (!_syncEngine) { _syncEngine = createSyncEngine({ productId: PRODUCT_ID, entities: { timers: { endpoint: '/timers', partitionKey: 'userId', conflictStrategy: 'server-wins', }, routines: { endpoint: '/routines', partitionKey: 'userId', conflictStrategy: 'server-wins', }, }, storage: new LocalStorageAdapter('chronomind:sync:'), apiClient: getApiClient(), }); } return _syncEngine; } // ── Public API ──────────────────────────────────────────────── export function setAuthToken(token: string | null): void { const client = getAuthClient(); if (token) { client.setTokens(token, client.getRefreshToken() ?? ''); } else { client.clearTokens(); } } export function isAuthenticated(): boolean { return getAuthClient().isAuthenticated(); } export function isSyncEnabled(): boolean { try { if (typeof window === 'undefined') return false; return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true'; } catch { return false; } } export function setSyncEnabled(enabled: boolean): void { if (typeof window === 'undefined') return; localStorage.setItem(STORAGE_KEYS.syncEnabled, String(enabled)); } export function getLastSyncDate(): string | null { if (typeof window === 'undefined') return null; return localStorage.getItem(STORAGE_KEYS.lastSync); } function setLastSyncDate(date: string): void { localStorage.setItem(STORAGE_KEYS.lastSync, date); } // ── Auth Operations (delegated to @bytelyst/auth-client) ───── // Most auth operations are now handled by @bytelyst/react-auth in auth-context.tsx. // Only verifyEmail is still used directly by the verify-email page. export async function verifyEmail(token: string): Promise<{ message: string }> { return getAuthClient().verifyEmail(token); } // ── Offline Queue (delegated to @bytelyst/sync engine) ─────── // These functions maintain the same public API but now use the // sync engine's queue internally for deduplication and retry. export function loadOfflineQueue(): OfflineQueueItem[] { // Read from legacy localStorage key for backward compat if (typeof window === 'undefined') return []; try { const raw = localStorage.getItem(STORAGE_KEYS.offlineQueue); return raw ? JSON.parse(raw) : []; } catch { return []; } } function saveOfflineQueue(queue: OfflineQueueItem[]): void { if (typeof window === 'undefined') return; localStorage.setItem(STORAGE_KEYS.offlineQueue, JSON.stringify(queue)); } export function enqueueChange( timer: Timer, action: 'create' | 'update' | 'delete' ): void { const engine = getSyncEngine(); const operation = action === 'create' ? 'create' : action === 'update' ? 'update' : 'delete'; if (action === 'delete') { void engine.delete('timers', timer.id); } else { void engine.push('timers', timerToDTO(timer), operation); } // Also maintain legacy queue for loadOfflineQueue() consumers const queue = loadOfflineQueue().filter((item) => item.id !== timer.id); queue.push({ id: timer.id, action, timer: action !== 'delete' ? timerToDTO(timer) : undefined, entityType: 'timer', enqueuedAt: Date.now(), }); saveOfflineQueue(queue); } export function enqueueDeleteChange(timerId: string): void { void getSyncEngine().delete('timers', timerId); // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== timerId); queue.push({ id: timerId, action: 'delete', entityType: 'timer', enqueuedAt: Date.now() }); saveOfflineQueue(queue); } export function enqueueRoutineChange( routine: Routine, action: 'create' | 'update' | 'delete' ): void { const engine = getSyncEngine(); const operation = action === 'create' ? 'create' : action === 'update' ? 'update' : 'delete'; if (action === 'delete') { void engine.delete('routines', routine.id); } else { void engine.push('routines', routineToDTO(routine), operation); } // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== routine.id); queue.push({ id: routine.id, action, routine: action !== 'delete' ? routineToDTO(routine) : undefined, entityType: 'routine', enqueuedAt: Date.now(), }); saveOfflineQueue(queue); } export function enqueueRoutineDeleteChange(routineId: string): void { void getSyncEngine().delete('routines', routineId); // Also maintain legacy queue const queue = loadOfflineQueue().filter((item) => item.id !== routineId); queue.push({ id: routineId, action: 'delete', entityType: 'routine', enqueuedAt: Date.now() }); saveOfflineQueue(queue); } function clearSyncedFromQueue(syncedIds: string[]): void { const queue = loadOfflineQueue().filter( (item) => !syncedIds.includes(item.id) ); saveOfflineQueue(queue); } // ── Sync Operations (direct API — used by fullSync below) ──── async function apiRequest( path: string, method: string, body?: unknown ): Promise { const client = getAuthClient(); const token = client.getAccessToken(); const baseUrl = getBaseUrl(); const headers: Record = { 'Content-Type': 'application/json', 'x-request-id': crypto.randomUUID(), 'x-product-id': PRODUCT_ID, }; if (token) headers['Authorization'] = `Bearer ${token}`; const res = await fetch(`${baseUrl}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 409) { const data = await res.json(); throw new Error(`Sync conflict: ${JSON.stringify(data)}`); } if (res.status === 401 && !path.startsWith('/auth/')) { const refreshed = await client.refreshAccessToken(); if (refreshed) { return apiRequest(path, method, body); } } if (!res.ok) { throw new Error(`Sync API error: ${res.status} ${res.statusText}`); } if (res.status === 204) return undefined as T; return res.json(); } async function pullDelta(since?: string): Promise { const params = since ? `?since=${encodeURIComponent(since)}` : ''; return apiRequest(`/timers/sync${params}`, 'GET'); } async function batchUpsert( timers: SyncTimerDTO[] ): Promise { return apiRequest('/timers/batch', 'POST', { timers }); } async function pullRoutineDelta(since?: string): Promise { const params = since ? `?since=${encodeURIComponent(since)}` : ''; return apiRequest(`/routines/sync${params}`, 'GET'); } async function batchUpsertRoutines( routines: SyncRoutineDTO[] ): Promise { return apiRequest('/routines/batch', 'POST', { routines }); } async function deleteRemoteTimer(id: string): Promise { return apiRequest(`/timers/${id}`, 'DELETE'); } async function deleteRemoteRoutine(id: string): Promise { return apiRequest(`/routines/${id}`, 'DELETE'); } // ── Full Sync ───────────────────────────────────────────────── export interface SyncResult { pulled: SyncTimerDTO[]; pulledRoutines: SyncRoutineDTO[]; conflicts: SyncConflict[]; error?: string; } export async function fullSync(): Promise { if (!isSyncEnabled() || !isAuthenticated()) { return { pulled: [], pulledRoutines: [], conflicts: [], error: 'Not authenticated or sync disabled' }; } try { // 1. Pull delta (timers + routines) const since = getLastSyncDate() ?? undefined; const [pulled, pulledRoutines] = await Promise.all([ pullDelta(since), pullRoutineDelta(since), ]); // 2. Push offline queue const queue = loadOfflineQueue(); let conflicts: SyncConflict[] = []; if (queue.length > 0) { // Timer upserts const timersToSync = queue .filter((item) => item.entityType === 'timer' && item.action !== 'delete' && item.timer) .map((item) => item.timer!); if (timersToSync.length > 0) { const result = await batchUpsert(timersToSync); conflicts = result.conflicts; clearSyncedFromQueue(result.synced); } // Routine upserts const routinesToSync = queue .filter((item) => item.entityType === 'routine' && item.action !== 'delete' && item.routine) .map((item) => item.routine!); if (routinesToSync.length > 0) { const result = await batchUpsertRoutines(routinesToSync); conflicts = [...conflicts, ...result.conflicts]; clearSyncedFromQueue(result.synced); } // Handle deletes separately const timerDeletes = queue.filter((item) => item.entityType === 'timer' && item.action === 'delete'); for (const del of timerDeletes) { try { await deleteRemoteTimer(del.id); clearSyncedFromQueue([del.id]); } catch { // Keep in queue for retry } } const routineDeletes = queue.filter((item) => item.entityType === 'routine' && item.action === 'delete'); for (const del of routineDeletes) { try { await deleteRemoteRoutine(del.id); clearSyncedFromQueue([del.id]); } catch { // Keep in queue for retry } } } // 3. Also flush the @bytelyst/sync engine queue (picks up items // enqueued via the new code path) try { await getSyncEngine().flush(); } catch { // best-effort — legacy path already handled above } // 4. Update last sync setLastSyncDate(new Date().toISOString()); return { pulled, pulledRoutines, conflicts }; } catch (err) { const message = err instanceof Error ? err.message : 'Unknown sync error'; return { pulled: [], pulledRoutines: [], conflicts: [], error: message }; } } // ── DTO Conversion ──────────────────────────────────────────── export function timerToDTO(timer: Timer): SyncTimerDTO { return { id: timer.id, label: timer.label, description: timer.description, type: timer.type, state: timer.state, urgency: timer.urgency, duration: timer.duration ?? 0, targetTime: new Date(timer.targetTime).toISOString(), createdAt: new Date(timer.createdAt).toISOString(), startedAt: timer.startedAt ? new Date(timer.startedAt).toISOString() : undefined, pausedAt: timer.pausedAt ? new Date(timer.pausedAt).toISOString() : undefined, firedAt: timer.firedAt ? new Date(timer.firedAt).toISOString() : undefined, completedAt: timer.completedAt ? new Date(timer.completedAt).toISOString() : undefined, cascade: timer.cascade ? { preset: timer.cascade.preset, intervals: timer.cascade.intervals ?? [], } : undefined, pomodoro: timer.pomodoroConfig ? { focusMinutes: timer.pomodoroConfig.workMinutes, shortBreakMinutes: timer.pomodoroConfig.breakMinutes, longBreakMinutes: timer.pomodoroConfig.longBreakMinutes, roundsBeforeLong: timer.pomodoroConfig.rounds, currentRound: timer.pomodoroState?.currentRound ?? 1, isBreak: timer.pomodoroState?.isBreak ?? false, totalRoundsCompleted: timer.pomodoroState?.completedRounds ?? 0, } : undefined, category: timer.category, syncVersion: 1, }; } // ── Routine DTO Conversion ─────────────────────────────────── export function routineToDTO(routine: Routine): SyncRoutineDTO { return { id: routine.id, name: routine.name, description: routine.description, steps: routine.steps.map((s) => ({ id: s.id, label: s.label, durationMinutes: s.durationMinutes, transition: s.transition, customTransitionMinutes: s.customTransitionMinutes, notes: s.notes, status: s.status, })), totalDurationMinutes: routine.totalDurationMinutes, status: routine.status, currentStepIndex: routine.currentStepIndex, isTemplate: routine.isTemplate, createdAt: new Date(routine.createdAt).toISOString(), startedAt: routine.startedAt ? new Date(routine.startedAt).toISOString() : undefined, completedAt: routine.completedAt ? new Date(routine.completedAt).toISOString() : undefined, syncVersion: 1, }; } export function dtoToRoutinePatch(dto: SyncRoutineDTO): Partial { return { id: dto.id, name: dto.name, description: dto.description, steps: dto.steps.map((s) => ({ id: s.id, label: s.label, durationMinutes: s.durationMinutes, transition: s.transition as RoutineStep['transition'], customTransitionMinutes: s.customTransitionMinutes ?? 0, notes: s.notes, status: s.status as RoutineStep['status'], startedAt: null, completedAt: null, })), totalDurationMinutes: dto.totalDurationMinutes, status: dto.status as Routine['status'], currentStepIndex: dto.currentStepIndex, isTemplate: dto.isTemplate, createdAt: new Date(dto.createdAt).getTime(), startedAt: dto.startedAt ? new Date(dto.startedAt).getTime() : null, completedAt: dto.completedAt ? new Date(dto.completedAt).getTime() : null, }; } export function dtoToTimerPatch(dto: SyncTimerDTO): Partial { return { id: dto.id, label: dto.label, description: dto.description, type: dto.type as Timer['type'], state: dto.state as Timer['state'], urgency: dto.urgency as Timer['urgency'], duration: dto.duration, targetTime: new Date(dto.targetTime).getTime(), createdAt: new Date(dto.createdAt).getTime(), startedAt: dto.startedAt ? new Date(dto.startedAt).getTime() : undefined, pausedAt: dto.pausedAt ? new Date(dto.pausedAt).getTime() : undefined, firedAt: dto.firedAt ? new Date(dto.firedAt).getTime() : undefined, completedAt: dto.completedAt ? new Date(dto.completedAt).getTime() : undefined, category: dto.category, }; }