547 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|