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);