fix(web): beacon transport, remove dead sync wrappers, add routine export/import

This commit is contained in:
saravanakumardb1 2026-02-28 19:07:14 -08:00
parent a5aec74e4d
commit ef27f9dcc6
3 changed files with 101 additions and 35 deletions

View File

@ -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.
*/

View File

@ -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',

View File

@ -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,
};
}