learning_ai_clock/web/src/lib/platform-sync.ts
2026-03-19 21:25:38 -07:00

547 lines
17 KiB
TypeScript

// ── 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<T>(
path: string,
method: string,
body?: unknown
): Promise<T> {
const client = getAuthClient();
const token = client.getAccessToken();
const baseUrl = getBaseUrl();
const headers: Record<string, string> = {
'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<T>(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<SyncTimerDTO[]> {
const params = since ? `?since=${encodeURIComponent(since)}` : '';
return apiRequest<SyncTimerDTO[]>(`/timers/sync${params}`, 'GET');
}
async function batchUpsert(
timers: SyncTimerDTO[]
): Promise<BatchResult> {
return apiRequest<BatchResult>('/timers/batch', 'POST', { timers });
}
async function pullRoutineDelta(since?: string): Promise<SyncRoutineDTO[]> {
const params = since ? `?since=${encodeURIComponent(since)}` : '';
return apiRequest<SyncRoutineDTO[]>(`/routines/sync${params}`, 'GET');
}
async function batchUpsertRoutines(
routines: SyncRoutineDTO[]
): Promise<BatchResult> {
return apiRequest<BatchResult>('/routines/batch', 'POST', { routines });
}
async function deleteRemoteTimer(id: string): Promise<void> {
return apiRequest<void>(`/timers/${id}`, 'DELETE');
}
async function deleteRemoteRoutine(id: string): Promise<void> {
return apiRequest<void>(`/routines/${id}`, 'DELETE');
}
// ── Full Sync ─────────────────────────────────────────────────
export interface SyncResult {
pulled: SyncTimerDTO[];
pulledRoutines: SyncRoutineDTO[];
conflicts: SyncConflict[];
error?: string;
}
export async function fullSync(): Promise<SyncResult> {
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<Routine> {
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<Timer> {
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,
};
}