From ef27f9dcc6f160ea0cd8f860482b36da3981e26d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 19:07:14 -0800 Subject: [PATCH] fix(web): beacon transport, remove dead sync wrappers, add routine export/import --- web/src/lib/export.ts | 77 ++++++++++++++++++++++++++++++++++++++-- web/src/lib/telemetry.ts | 2 +- web/src/lib/use-sync.ts | 57 +++++++++++++---------------- 3 files changed, 101 insertions(+), 35 deletions(-) diff --git a/web/src/lib/export.ts b/web/src/lib/export.ts index 3f51d08..ad4add2 100644 --- a/web/src/lib/export.ts +++ b/web/src/lib/export.ts @@ -1,7 +1,8 @@ -// ── Timer Export / Import ───────────────────────────────────── -// Export all timers as JSON, import from JSON file +// ── Timer & Routine Export / Import ────────────────────────── +// Export all timers + routines as JSON, import from JSON file import type { Timer } from './timer-engine'; +import type { Routine } from './routines'; // ── Types ───────────────────────────────────────────────────── @@ -10,6 +11,7 @@ export interface ExportData { exportedAt: number; app: 'chronomind'; timers: Timer[]; + routines?: Routine[]; } export interface ImportResult { @@ -33,6 +35,19 @@ export function exportTimers(timers: Timer[]): ExportData { }; } +/** + * Export timers + routines as a downloadable JSON blob. + */ +export function exportAll(timers: Timer[], routines: Routine[]): ExportData { + return { + version: 1, + exportedAt: Date.now(), + app: 'chronomind', + timers: timers.map((t) => ({ ...t })), + routines: routines.map((r) => ({ ...r, steps: r.steps.map((s) => ({ ...s })) })), + }; +} + /** * Trigger a browser download of the export data. */ @@ -51,6 +66,24 @@ export function downloadExport(timers: Timer[]): void { URL.revokeObjectURL(url); } +/** + * Trigger a browser download of timers + routines. + */ +export function downloadExportAll(timers: Timer[], routines: Routine[]): void { + const data = exportAll(timers, routines); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `chronomind-export-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + // ── Import ──────────────────────────────────────────────────── /** @@ -114,6 +147,46 @@ export function importTimers( }; } +/** + * Import routines from parsed export data, deduplicating by ID. + */ +export function importRoutines( + exportData: ExportData, + existingRoutines: Routine[] +): ImportResult { + if (!exportData.routines || exportData.routines.length === 0) { + return { success: true, imported: 0, skipped: 0, errors: [] }; + } + + const errors: string[] = []; + let imported = 0; + let skipped = 0; + + const existingIds = new Set(existingRoutines.map((r) => r.id)); + + for (const routine of exportData.routines) { + if (!routine.id || !routine.name || !Array.isArray(routine.steps)) { + errors.push(`Invalid routine: missing required fields (id, name, or steps)`); + skipped++; + continue; + } + + if (existingIds.has(routine.id)) { + skipped++; + continue; + } + + imported++; + } + + return { + success: errors.length === 0, + imported, + skipped, + errors, + }; +} + /** * Read a File object and return the parsed content as string. */ diff --git a/web/src/lib/telemetry.ts b/web/src/lib/telemetry.ts index ece5f81..02eb232 100644 --- a/web/src/lib/telemetry.ts +++ b/web/src/lib/telemetry.ts @@ -14,7 +14,7 @@ function getClient(): TelemetryClient { baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app', platform: 'web', channel: 'pwa', - transport: 'fetch', + transport: 'beacon', appVersion: '0.1.0', buildNumber: '1', releaseChannel: 'beta', diff --git a/web/src/lib/use-sync.ts b/web/src/lib/use-sync.ts index b9ca87d..97345f2 100644 --- a/web/src/lib/use-sync.ts +++ b/web/src/lib/use-sync.ts @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTimerStore } from './store'; +import { useRoutineStore } from './routine-store'; import { fullSync, isSyncEnabled, @@ -13,13 +14,13 @@ import { isAuthenticated, setAuthToken, loadOfflineQueue, - enqueueChange, - enqueueDeleteChange, 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 @@ -36,11 +37,6 @@ export interface UseSyncReturn { setSyncEnabled: (enabled: boolean) => void; login: (token: string) => void; logout: () => void; - - // Wrappers that enqueue changes - syncedAddTimer: (timer: Timer) => void; - syncedUpdateTimer: (timer: Timer) => void; - syncedRemoveTimer: (id: string) => void; } export function useSync(): UseSyncReturn { @@ -99,6 +95,28 @@ export function useSync(): UseSyncReturn { 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]); } @@ -157,28 +175,6 @@ export function useSync(): UseSyncReturn { setConflicts([]); }, [setSyncEnabled]); - // Wrappers that enqueue changes for sync - const syncedAddTimer = useCallback((timer: Timer) => { - if (isSyncEnabled()) { - enqueueChange(timer, 'create'); - setPendingChanges(loadOfflineQueue().length); - } - }, []); - - const syncedUpdateTimer = useCallback((timer: Timer) => { - if (isSyncEnabled()) { - enqueueChange(timer, 'update'); - setPendingChanges(loadOfflineQueue().length); - } - }, []); - - const syncedRemoveTimer = useCallback((id: string) => { - if (isSyncEnabled()) { - enqueueDeleteChange(id); - setPendingChanges(loadOfflineQueue().length); - } - }, []); - return { isSyncing, syncEnabled, @@ -190,8 +186,5 @@ export function useSync(): UseSyncReturn { setSyncEnabled, login, logout, - syncedAddTimer, - syncedUpdateTimer, - syncedRemoveTimer, }; }