From 3596a8f35024d09f4e58ba56880ec57af0b4fb3c Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sat, 28 Feb 2026 20:24:53 -0800 Subject: [PATCH] feat(sync): wire platform sync into timer and routine stores, extend fullSync for routines, add error/not-found pages --- ios/ChronoMind/Shared/Store/TimerStore.swift | 11 +++ web/next.config.ts | 36 +++++++++- web/src/app/error.tsx | 44 ++++++++++++ web/src/app/not-found.tsx | 22 ++++++ web/src/lib/platform-sync.ts | 71 +++++++++++++++++--- web/src/lib/routine-store.ts | 16 +++++ web/src/lib/store.ts | 16 +++++ 7 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 web/src/app/error.tsx create mode 100644 web/src/app/not-found.tsx diff --git a/ios/ChronoMind/Shared/Store/TimerStore.swift b/ios/ChronoMind/Shared/Store/TimerStore.swift index 40e94a0..53e8fca 100644 --- a/ios/ChronoMind/Shared/Store/TimerStore.swift +++ b/ios/ChronoMind/Shared/Store/TimerStore.swift @@ -20,6 +20,7 @@ final class TimerStore: ObservableObject { private let notifications = CMNotificationManager.shared private let sharedData = SharedTimerDataManager.shared private let liveActivity = LiveActivityManager.shared + private let syncManager = PlatformSyncManager.shared // MARK: - Init @@ -41,6 +42,7 @@ final class TimerStore: ObservableObject { notifications.scheduleNotifications(for: timer) saveTimers() liveActivity.startActivity(for: timer) + syncManager.enqueueChange(timer, action: .create) return timer } @@ -50,6 +52,7 @@ final class TimerStore: ObservableObject { notifications.scheduleNotifications(for: timer) saveTimers() liveActivity.startActivity(for: timer) + syncManager.enqueueChange(timer, action: .create) return timer } @@ -59,6 +62,7 @@ final class TimerStore: ObservableObject { notifications.scheduleNotifications(for: timer) saveTimers() liveActivity.startActivity(for: timer) + syncManager.enqueueChange(timer, action: .create) return timer } @@ -67,12 +71,14 @@ final class TimerStore: ObservableObject { timers.removeAll { $0.id == id } notifications.removeNotifications(for: id) saveTimers() + syncManager.enqueueDelete(timerId: id) } // MARK: - State Transitions func pause(_ id: String) { updateTimer(id) { pauseTimer($0) } + if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) } } func resume(_ id: String) { @@ -81,6 +87,7 @@ final class TimerStore: ObservableObject { self.notifications.scheduleNotifications(for: resumed) return resumed } + if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) } } func fire(_ id: String) { @@ -93,6 +100,7 @@ final class TimerStore: ObservableObject { self.notifications.scheduleNotifications(for: snoozed) return snoozed } + if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) } } func dismiss(_ id: String) { @@ -102,11 +110,13 @@ final class TimerStore: ObservableObject { checkForRescheduleSuggestion(dismissedTimer: timer) } updateTimer(id) { dismissTimer($0) } + if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) } } func complete(_ id: String) { liveActivity.endActivity(for: id) updateTimer(id) { completeTimer($0) } + if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) } // Record completion for streak tracking GamificationStore.shared.recordCompletion() } @@ -117,6 +127,7 @@ final class TimerStore: ObservableObject { timers[index] = next notifications.scheduleNotifications(for: next) saveTimers() + syncManager.enqueueChange(next, action: .update) } } diff --git a/web/next.config.ts b/web/next.config.ts index 5710675..b95154b 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -6,8 +6,42 @@ const withSerwist = withSerwistInit({ swDest: "public/sw.js", }); +const securityHeaders = [ + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=31536000; includeSubDomains", + }, +]; + const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default withSerwist(nextConfig); diff --git a/web/src/app/error.tsx b/web/src/app/error.tsx new file mode 100644 index 0000000..0eb831b --- /dev/null +++ b/web/src/app/error.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // TODO: send to telemetry once wired + }, [error]); + + return ( +
+
+
+

Something went wrong

+

+ {error.message || 'An unexpected error occurred.'} +

+
+ + + Home + +
+
+
+ ); +} diff --git a/web/src/app/not-found.tsx b/web/src/app/not-found.tsx new file mode 100644 index 0000000..73e01f9 --- /dev/null +++ b/web/src/app/not-found.tsx @@ -0,0 +1,22 @@ +import Link from 'next/link'; + +export default function NotFound() { + return ( +
+
+
404
+

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+ + Go Home + +
+
+ ); +} diff --git a/web/src/lib/platform-sync.ts b/web/src/lib/platform-sync.ts index feee330..3aff003 100644 --- a/web/src/lib/platform-sync.ts +++ b/web/src/lib/platform-sync.ts @@ -156,8 +156,12 @@ export function isAuthenticated(): boolean { } export function isSyncEnabled(): boolean { - if (typeof window === 'undefined') return false; - return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true'; + try { + if (typeof window === 'undefined') return false; + return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true'; + } catch { + return false; + } } export function setSyncEnabled(enabled: boolean): void { @@ -276,6 +280,27 @@ export function enqueueDeleteChange(timerId: string): void { saveOfflineQueue(queue); } +export function enqueueRoutineChange( + routine: Routine, + action: 'create' | 'update' | 'delete' +): void { + 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 { + 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) @@ -287,27 +312,32 @@ function clearSyncedFromQueue(syncedIds: string[]): void { export interface SyncResult { pulled: SyncTimerDTO[]; + pulledRoutines: SyncRoutineDTO[]; conflicts: SyncConflict[]; error?: string; } export async function fullSync(): Promise { if (!isSyncEnabled() || !isAuthenticated()) { - return { pulled: [], conflicts: [], error: 'Not authenticated or sync disabled' }; + return { pulled: [], pulledRoutines: [], conflicts: [], error: 'Not authenticated or sync disabled' }; } try { - // 1. Pull delta + // 1. Pull delta (timers + routines) const since = getLastSyncDate() ?? undefined; - const pulled = await pullDelta(since); + 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.action !== 'delete' && item.timer) + .filter((item) => item.entityType === 'timer' && item.action !== 'delete' && item.timer) .map((item) => item.timer!); if (timersToSync.length > 0) { @@ -316,9 +346,20 @@ export async function fullSync(): Promise { 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 deletes = queue.filter((item) => item.action === 'delete'); - for (const del of deletes) { + const timerDeletes = queue.filter((item) => item.entityType === 'timer' && item.action === 'delete'); + for (const del of timerDeletes) { try { await deleteRemoteTimer(del.id); clearSyncedFromQueue([del.id]); @@ -326,15 +367,25 @@ export async function fullSync(): Promise { // 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. Update last sync setLastSyncDate(new Date().toISOString()); - return { pulled, conflicts }; + return { pulled, pulledRoutines, conflicts }; } catch (err) { const message = err instanceof Error ? err.message : 'Unknown sync error'; - return { pulled: [], conflicts: [], error: message }; + return { pulled: [], pulledRoutines: [], conflicts: [], error: message }; } } diff --git a/web/src/lib/routine-store.ts b/web/src/lib/routine-store.ts index 33dcab0..7b956a7 100644 --- a/web/src/lib/routine-store.ts +++ b/web/src/lib/routine-store.ts @@ -14,6 +14,7 @@ import { getBuiltInTemplates, shouldStepComplete, } from './routines'; +import { enqueueRoutineChange, enqueueRoutineDeleteChange, isSyncEnabled } from './platform-sync'; export interface RoutineStore { routines: Routine[]; @@ -57,6 +58,7 @@ export const useRoutineStore = create()( addRoutine: (params) => { const routine = createRoutine(params); set((s) => ({ routines: [...s.routines, routine] })); + if (isSyncEnabled()) enqueueRoutineChange(routine, 'create'); return routine; }, @@ -68,6 +70,7 @@ export const useRoutineStore = create()( removeRoutine: (id) => { set((s) => ({ routines: s.routines.filter((r) => r.id !== id) })); + if (isSyncEnabled()) enqueueRoutineDeleteChange(id); }, removeTemplate: (id) => { @@ -76,26 +79,38 @@ export const useRoutineStore = create()( start: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, startRoutine) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, pause: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, pauseRoutine) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, resume: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, resumeRoutine) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, completeStep: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, completeCurrentStep) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, skipStep: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, skipCurrentStep) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, cancel: (id) => { set((s) => ({ routines: updateRoutine(s.routines, id, cancelRoutine) })); + const updated = get().routines.find((r) => r.id === id); + if (updated && isSyncEnabled()) enqueueRoutineChange(updated, 'update'); }, startFromTemplate: (templateId) => { @@ -103,6 +118,7 @@ export const useRoutineStore = create()( if (!template) return null; const routine = startRoutine(instantiateTemplate(template)); set((s) => ({ routines: [...s.routines, routine] })); + if (isSyncEnabled()) enqueueRoutineChange(routine, 'create'); return routine; }, diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts index 19ff539..9574d34 100644 --- a/web/src/lib/store.ts +++ b/web/src/lib/store.ts @@ -22,6 +22,7 @@ import { checkWarnings } from './cascade'; import { playAlarmSound, playWarningChime } from './sounds'; import { sendFireNotification, sendWarningNotification } from './notifications'; import { trackTimerCreated, trackTimerCompleted, trackCascadeFired } from './analytics'; +import { enqueueChange, enqueueDeleteChange, isSyncEnabled } from './platform-sync'; export interface TimerStore { timers: Timer[]; @@ -73,6 +74,7 @@ export const useTimerStore = create()( const timer = createAlarm(params); set((s) => ({ timers: [...s.timers, timer] })); trackTimerCreated('alarm', timer.urgency, timer.cascade?.preset ?? 'none'); + if (isSyncEnabled()) enqueueChange(timer, 'create'); return timer; }, @@ -80,6 +82,7 @@ export const useTimerStore = create()( const timer = createCountdown(params); set((s) => ({ timers: [...s.timers, timer] })); trackTimerCreated('countdown', timer.urgency, timer.cascade?.preset ?? 'none'); + if (isSyncEnabled()) enqueueChange(timer, 'create'); return timer; }, @@ -87,6 +90,7 @@ export const useTimerStore = create()( const timer = createPomodoro(params); set((s) => ({ timers: [...s.timers, timer] })); trackTimerCreated('pomodoro', timer.urgency, 'none'); + if (isSyncEnabled()) enqueueChange(timer, 'create'); return timer; }, @@ -94,6 +98,7 @@ export const useTimerStore = create()( const timer = createEvent(params); set((s) => ({ timers: [...s.timers, timer] })); trackTimerCreated('event', timer.urgency, timer.cascade?.preset ?? 'none'); + if (isSyncEnabled()) enqueueChange(timer, 'create'); return timer; }, @@ -108,6 +113,7 @@ export const useTimerStore = create()( })) .filter((c) => c.timerIds.length > 1), })); + if (isSyncEnabled()) enqueueDeleteChange(id); }, addChain: (name, timerIds, delayMs = 0) => { @@ -137,10 +143,14 @@ export const useTimerStore = create()( pause: (id) => { set((s) => ({ timers: updateTimer(s.timers, id, pauseTimer) })); + const updated = get().timers.find((t) => t.id === id); + if (updated && isSyncEnabled()) enqueueChange(updated, 'update'); }, resume: (id) => { set((s) => ({ timers: updateTimer(s.timers, id, resumeTimer) })); + const updated = get().timers.find((t) => t.id === id); + if (updated && isSyncEnabled()) enqueueChange(updated, 'update'); }, fire: (id) => { @@ -149,10 +159,14 @@ export const useTimerStore = create()( snooze: (id, minutes) => { set((s) => ({ timers: updateTimer(s.timers, id, (t) => snoozeTimer(t, minutes)) })); + const updated = get().timers.find((t) => t.id === id); + if (updated && isSyncEnabled()) enqueueChange(updated, 'update'); }, dismiss: (id) => { set((s) => ({ timers: updateTimer(s.timers, id, dismissTimer) })); + const updated = get().timers.find((t) => t.id === id); + if (updated && isSyncEnabled()) enqueueChange(updated, 'update'); }, complete: (id) => { @@ -161,6 +175,8 @@ export const useTimerStore = create()( trackTimerCompleted(timer.type, timer.targetTime - timer.createdAt); } set((s) => ({ timers: updateTimer(s.timers, id, completeTimer) })); + const completed = get().timers.find((t) => t.id === id); + if (completed && isSyncEnabled()) enqueueChange(completed, 'update'); // Auto-start next timer in chain const { chains, timers } = get(); const link = getNextInChain(chains, id);