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 ─────────────────────────────────────
|
// ── Timer & Routine Export / Import ──────────────────────────
|
||||||
// Export all timers as JSON, import from JSON file
|
// Export all timers + routines as JSON, import from JSON file
|
||||||
|
|
||||||
import type { Timer } from './timer-engine';
|
import type { Timer } from './timer-engine';
|
||||||
|
import type { Routine } from './routines';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ export interface ExportData {
|
|||||||
exportedAt: number;
|
exportedAt: number;
|
||||||
app: 'chronomind';
|
app: 'chronomind';
|
||||||
timers: Timer[];
|
timers: Timer[];
|
||||||
|
routines?: Routine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportResult {
|
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.
|
* Trigger a browser download of the export data.
|
||||||
*/
|
*/
|
||||||
@ -51,6 +66,24 @@ export function downloadExport(timers: Timer[]): void {
|
|||||||
URL.revokeObjectURL(url);
|
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 ────────────────────────────────────────────────────
|
// ── 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.
|
* 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',
|
baseUrl: process.env.NEXT_PUBLIC_PLATFORM_SERVICE_URL ?? 'https://api.chronomind.app',
|
||||||
platform: 'web',
|
platform: 'web',
|
||||||
channel: 'pwa',
|
channel: 'pwa',
|
||||||
transport: 'fetch',
|
transport: 'beacon',
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
buildNumber: '1',
|
buildNumber: '1',
|
||||||
releaseChannel: 'beta',
|
releaseChannel: 'beta',
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTimerStore } from './store';
|
import { useTimerStore } from './store';
|
||||||
|
import { useRoutineStore } from './routine-store';
|
||||||
import {
|
import {
|
||||||
fullSync,
|
fullSync,
|
||||||
isSyncEnabled,
|
isSyncEnabled,
|
||||||
@ -13,13 +14,13 @@ import {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
setAuthToken,
|
setAuthToken,
|
||||||
loadOfflineQueue,
|
loadOfflineQueue,
|
||||||
enqueueChange,
|
|
||||||
enqueueDeleteChange,
|
|
||||||
dtoToTimerPatch,
|
dtoToTimerPatch,
|
||||||
|
dtoToRoutinePatch,
|
||||||
type SyncResult,
|
type SyncResult,
|
||||||
type SyncConflict,
|
type SyncConflict,
|
||||||
} from './platform-sync';
|
} from './platform-sync';
|
||||||
import type { Timer } from './timer-engine';
|
import type { Timer } from './timer-engine';
|
||||||
|
import type { Routine } from './routines';
|
||||||
|
|
||||||
const SYNC_INTERVAL_MS = 60_000; // 1 minute
|
const SYNC_INTERVAL_MS = 60_000; // 1 minute
|
||||||
|
|
||||||
@ -36,11 +37,6 @@ export interface UseSyncReturn {
|
|||||||
setSyncEnabled: (enabled: boolean) => void;
|
setSyncEnabled: (enabled: boolean) => void;
|
||||||
login: (token: string) => void;
|
login: (token: string) => void;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
|
||||||
// Wrappers that enqueue changes
|
|
||||||
syncedAddTimer: (timer: Timer) => void;
|
|
||||||
syncedUpdateTimer: (timer: Timer) => void;
|
|
||||||
syncedRemoveTimer: (id: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSync(): UseSyncReturn {
|
export function useSync(): UseSyncReturn {
|
||||||
@ -99,6 +95,28 @@ export function useSync(): UseSyncReturn {
|
|||||||
useTimerStore.setState({ timers: currentTimers });
|
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) {
|
if (result.conflicts.length > 0) {
|
||||||
setConflicts((prev) => [...prev, ...result.conflicts]);
|
setConflicts((prev) => [...prev, ...result.conflicts]);
|
||||||
}
|
}
|
||||||
@ -157,28 +175,6 @@ export function useSync(): UseSyncReturn {
|
|||||||
setConflicts([]);
|
setConflicts([]);
|
||||||
}, [setSyncEnabled]);
|
}, [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 {
|
return {
|
||||||
isSyncing,
|
isSyncing,
|
||||||
syncEnabled,
|
syncEnabled,
|
||||||
@ -190,8 +186,5 @@ export function useSync(): UseSyncReturn {
|
|||||||
setSyncEnabled,
|
setSyncEnabled,
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
syncedAddTimer,
|
|
||||||
syncedUpdateTimer,
|
|
||||||
syncedRemoveTimer,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user