fix(web): beacon transport, remove dead sync wrappers, add routine export/import
This commit is contained in:
parent
a5aec74e4d
commit
ef27f9dcc6
@ -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.
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user