feat(sync): wire platform sync into timer and routine stores, extend fullSync for routines, add error/not-found pages

This commit is contained in:
saravanakumardb1 2026-02-28 20:24:53 -08:00
parent fe75bc30de
commit 3596a8f350
7 changed files with 205 additions and 11 deletions

View File

@ -20,6 +20,7 @@ final class TimerStore: ObservableObject {
private let notifications = CMNotificationManager.shared private let notifications = CMNotificationManager.shared
private let sharedData = SharedTimerDataManager.shared private let sharedData = SharedTimerDataManager.shared
private let liveActivity = LiveActivityManager.shared private let liveActivity = LiveActivityManager.shared
private let syncManager = PlatformSyncManager.shared
// MARK: - Init // MARK: - Init
@ -41,6 +42,7 @@ final class TimerStore: ObservableObject {
notifications.scheduleNotifications(for: timer) notifications.scheduleNotifications(for: timer)
saveTimers() saveTimers()
liveActivity.startActivity(for: timer) liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer return timer
} }
@ -50,6 +52,7 @@ final class TimerStore: ObservableObject {
notifications.scheduleNotifications(for: timer) notifications.scheduleNotifications(for: timer)
saveTimers() saveTimers()
liveActivity.startActivity(for: timer) liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer return timer
} }
@ -59,6 +62,7 @@ final class TimerStore: ObservableObject {
notifications.scheduleNotifications(for: timer) notifications.scheduleNotifications(for: timer)
saveTimers() saveTimers()
liveActivity.startActivity(for: timer) liveActivity.startActivity(for: timer)
syncManager.enqueueChange(timer, action: .create)
return timer return timer
} }
@ -67,12 +71,14 @@ final class TimerStore: ObservableObject {
timers.removeAll { $0.id == id } timers.removeAll { $0.id == id }
notifications.removeNotifications(for: id) notifications.removeNotifications(for: id)
saveTimers() saveTimers()
syncManager.enqueueDelete(timerId: id)
} }
// MARK: - State Transitions // MARK: - State Transitions
func pause(_ id: String) { func pause(_ id: String) {
updateTimer(id) { pauseTimer($0) } updateTimer(id) { pauseTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
} }
func resume(_ id: String) { func resume(_ id: String) {
@ -81,6 +87,7 @@ final class TimerStore: ObservableObject {
self.notifications.scheduleNotifications(for: resumed) self.notifications.scheduleNotifications(for: resumed)
return resumed return resumed
} }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
} }
func fire(_ id: String) { func fire(_ id: String) {
@ -93,6 +100,7 @@ final class TimerStore: ObservableObject {
self.notifications.scheduleNotifications(for: snoozed) self.notifications.scheduleNotifications(for: snoozed)
return snoozed return snoozed
} }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
} }
func dismiss(_ id: String) { func dismiss(_ id: String) {
@ -102,11 +110,13 @@ final class TimerStore: ObservableObject {
checkForRescheduleSuggestion(dismissedTimer: timer) checkForRescheduleSuggestion(dismissedTimer: timer)
} }
updateTimer(id) { dismissTimer($0) } updateTimer(id) { dismissTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
} }
func complete(_ id: String) { func complete(_ id: String) {
liveActivity.endActivity(for: id) liveActivity.endActivity(for: id)
updateTimer(id) { completeTimer($0) } updateTimer(id) { completeTimer($0) }
if let updated = getTimer(id) { syncManager.enqueueChange(updated, action: .update) }
// Record completion for streak tracking // Record completion for streak tracking
GamificationStore.shared.recordCompletion() GamificationStore.shared.recordCompletion()
} }
@ -117,6 +127,7 @@ final class TimerStore: ObservableObject {
timers[index] = next timers[index] = next
notifications.scheduleNotifications(for: next) notifications.scheduleNotifications(for: next)
saveTimers() saveTimers()
syncManager.enqueueChange(next, action: .update)
} }
} }

View File

@ -6,8 +6,42 @@ const withSerwist = withSerwistInit({
swDest: "public/sw.js", 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 = { const nextConfig: NextConfig = {
/* config options here */ async headers() {
return [
{
source: "/(.*)",
headers: securityHeaders,
},
];
},
}; };
export default withSerwist(nextConfig); export default withSerwist(nextConfig);

44
web/src/app/error.tsx Normal file
View File

@ -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 (
<div className="flex min-h-screen items-center justify-center p-4" style={{ background: 'var(--cm-bg-canvas)' }}>
<div className="mx-auto max-w-md text-center">
<div className="mb-4 text-5xl"></div>
<h2 className="mb-2 text-xl font-semibold" style={{ color: 'var(--cm-text-primary)' }}>Something went wrong</h2>
<p className="mb-6 text-sm" style={{ color: 'var(--cm-text-secondary)' }}>
{error.message || 'An unexpected error occurred.'}
</p>
<div className="flex gap-3 justify-center">
<button
onClick={reset}
className="rounded-lg px-4 py-2 text-sm font-medium"
style={{ background: 'var(--cm-accent-primary)', color: 'var(--cm-bg-canvas)' }}
>
Try again
</button>
<Link
href="/"
className="rounded-lg px-4 py-2 text-sm font-medium"
style={{ background: 'var(--cm-surface-card)', color: 'var(--cm-text-primary)' }}
>
Home
</Link>
</div>
</div>
</div>
);
}

22
web/src/app/not-found.tsx Normal file
View File

@ -0,0 +1,22 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center p-4" style={{ background: 'var(--cm-bg-canvas)' }}>
<div className="mx-auto max-w-md text-center">
<div className="mb-4 text-6xl font-bold" style={{ color: 'var(--cm-text-secondary)' }}>404</div>
<h2 className="mb-2 text-xl font-semibold" style={{ color: 'var(--cm-text-primary)' }}>Page not found</h2>
<p className="mb-6 text-sm" style={{ color: 'var(--cm-text-secondary)' }}>
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<Link
href="/"
className="rounded-lg px-4 py-2 text-sm font-medium"
style={{ background: 'var(--cm-accent-primary)', color: 'var(--cm-bg-canvas)' }}
>
Go Home
</Link>
</div>
</div>
);
}

View File

@ -156,8 +156,12 @@ export function isAuthenticated(): boolean {
} }
export function isSyncEnabled(): boolean { export function isSyncEnabled(): boolean {
if (typeof window === 'undefined') return false; try {
return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true'; if (typeof window === 'undefined') return false;
return localStorage.getItem(STORAGE_KEYS.syncEnabled) === 'true';
} catch {
return false;
}
} }
export function setSyncEnabled(enabled: boolean): void { export function setSyncEnabled(enabled: boolean): void {
@ -276,6 +280,27 @@ export function enqueueDeleteChange(timerId: string): void {
saveOfflineQueue(queue); 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 { function clearSyncedFromQueue(syncedIds: string[]): void {
const queue = loadOfflineQueue().filter( const queue = loadOfflineQueue().filter(
(item) => !syncedIds.includes(item.id) (item) => !syncedIds.includes(item.id)
@ -287,27 +312,32 @@ function clearSyncedFromQueue(syncedIds: string[]): void {
export interface SyncResult { export interface SyncResult {
pulled: SyncTimerDTO[]; pulled: SyncTimerDTO[];
pulledRoutines: SyncRoutineDTO[];
conflicts: SyncConflict[]; conflicts: SyncConflict[];
error?: string; error?: string;
} }
export async function fullSync(): Promise<SyncResult> { export async function fullSync(): Promise<SyncResult> {
if (!isSyncEnabled() || !isAuthenticated()) { if (!isSyncEnabled() || !isAuthenticated()) {
return { pulled: [], conflicts: [], error: 'Not authenticated or sync disabled' }; return { pulled: [], pulledRoutines: [], conflicts: [], error: 'Not authenticated or sync disabled' };
} }
try { try {
// 1. Pull delta // 1. Pull delta (timers + routines)
const since = getLastSyncDate() ?? undefined; const since = getLastSyncDate() ?? undefined;
const pulled = await pullDelta(since); const [pulled, pulledRoutines] = await Promise.all([
pullDelta(since),
pullRoutineDelta(since),
]);
// 2. Push offline queue // 2. Push offline queue
const queue = loadOfflineQueue(); const queue = loadOfflineQueue();
let conflicts: SyncConflict[] = []; let conflicts: SyncConflict[] = [];
if (queue.length > 0) { if (queue.length > 0) {
// Timer upserts
const timersToSync = queue const timersToSync = queue
.filter((item) => item.action !== 'delete' && item.timer) .filter((item) => item.entityType === 'timer' && item.action !== 'delete' && item.timer)
.map((item) => item.timer!); .map((item) => item.timer!);
if (timersToSync.length > 0) { if (timersToSync.length > 0) {
@ -316,9 +346,20 @@ export async function fullSync(): Promise<SyncResult> {
clearSyncedFromQueue(result.synced); 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 // Handle deletes separately
const deletes = queue.filter((item) => item.action === 'delete'); const timerDeletes = queue.filter((item) => item.entityType === 'timer' && item.action === 'delete');
for (const del of deletes) { for (const del of timerDeletes) {
try { try {
await deleteRemoteTimer(del.id); await deleteRemoteTimer(del.id);
clearSyncedFromQueue([del.id]); clearSyncedFromQueue([del.id]);
@ -326,15 +367,25 @@ export async function fullSync(): Promise<SyncResult> {
// Keep in queue for retry // 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 // 3. Update last sync
setLastSyncDate(new Date().toISOString()); setLastSyncDate(new Date().toISOString());
return { pulled, conflicts }; return { pulled, pulledRoutines, conflicts };
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown sync error'; const message = err instanceof Error ? err.message : 'Unknown sync error';
return { pulled: [], conflicts: [], error: message }; return { pulled: [], pulledRoutines: [], conflicts: [], error: message };
} }
} }

View File

@ -14,6 +14,7 @@ import {
getBuiltInTemplates, getBuiltInTemplates,
shouldStepComplete, shouldStepComplete,
} from './routines'; } from './routines';
import { enqueueRoutineChange, enqueueRoutineDeleteChange, isSyncEnabled } from './platform-sync';
export interface RoutineStore { export interface RoutineStore {
routines: Routine[]; routines: Routine[];
@ -57,6 +58,7 @@ export const useRoutineStore = create<RoutineStore>()(
addRoutine: (params) => { addRoutine: (params) => {
const routine = createRoutine(params); const routine = createRoutine(params);
set((s) => ({ routines: [...s.routines, routine] })); set((s) => ({ routines: [...s.routines, routine] }));
if (isSyncEnabled()) enqueueRoutineChange(routine, 'create');
return routine; return routine;
}, },
@ -68,6 +70,7 @@ export const useRoutineStore = create<RoutineStore>()(
removeRoutine: (id) => { removeRoutine: (id) => {
set((s) => ({ routines: s.routines.filter((r) => r.id !== id) })); set((s) => ({ routines: s.routines.filter((r) => r.id !== id) }));
if (isSyncEnabled()) enqueueRoutineDeleteChange(id);
}, },
removeTemplate: (id) => { removeTemplate: (id) => {
@ -76,26 +79,38 @@ export const useRoutineStore = create<RoutineStore>()(
start: (id) => { start: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, startRoutine) })); 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) => { pause: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, pauseRoutine) })); 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) => { resume: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, resumeRoutine) })); 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) => { completeStep: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, completeCurrentStep) })); 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) => { skipStep: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, skipCurrentStep) })); 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) => { cancel: (id) => {
set((s) => ({ routines: updateRoutine(s.routines, id, cancelRoutine) })); 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) => { startFromTemplate: (templateId) => {
@ -103,6 +118,7 @@ export const useRoutineStore = create<RoutineStore>()(
if (!template) return null; if (!template) return null;
const routine = startRoutine(instantiateTemplate(template)); const routine = startRoutine(instantiateTemplate(template));
set((s) => ({ routines: [...s.routines, routine] })); set((s) => ({ routines: [...s.routines, routine] }));
if (isSyncEnabled()) enqueueRoutineChange(routine, 'create');
return routine; return routine;
}, },

View File

@ -22,6 +22,7 @@ import { checkWarnings } from './cascade';
import { playAlarmSound, playWarningChime } from './sounds'; import { playAlarmSound, playWarningChime } from './sounds';
import { sendFireNotification, sendWarningNotification } from './notifications'; import { sendFireNotification, sendWarningNotification } from './notifications';
import { trackTimerCreated, trackTimerCompleted, trackCascadeFired } from './analytics'; import { trackTimerCreated, trackTimerCompleted, trackCascadeFired } from './analytics';
import { enqueueChange, enqueueDeleteChange, isSyncEnabled } from './platform-sync';
export interface TimerStore { export interface TimerStore {
timers: Timer[]; timers: Timer[];
@ -73,6 +74,7 @@ export const useTimerStore = create<TimerStore>()(
const timer = createAlarm(params); const timer = createAlarm(params);
set((s) => ({ timers: [...s.timers, timer] })); set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('alarm', timer.urgency, timer.cascade?.preset ?? 'none'); trackTimerCreated('alarm', timer.urgency, timer.cascade?.preset ?? 'none');
if (isSyncEnabled()) enqueueChange(timer, 'create');
return timer; return timer;
}, },
@ -80,6 +82,7 @@ export const useTimerStore = create<TimerStore>()(
const timer = createCountdown(params); const timer = createCountdown(params);
set((s) => ({ timers: [...s.timers, timer] })); set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('countdown', timer.urgency, timer.cascade?.preset ?? 'none'); trackTimerCreated('countdown', timer.urgency, timer.cascade?.preset ?? 'none');
if (isSyncEnabled()) enqueueChange(timer, 'create');
return timer; return timer;
}, },
@ -87,6 +90,7 @@ export const useTimerStore = create<TimerStore>()(
const timer = createPomodoro(params); const timer = createPomodoro(params);
set((s) => ({ timers: [...s.timers, timer] })); set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('pomodoro', timer.urgency, 'none'); trackTimerCreated('pomodoro', timer.urgency, 'none');
if (isSyncEnabled()) enqueueChange(timer, 'create');
return timer; return timer;
}, },
@ -94,6 +98,7 @@ export const useTimerStore = create<TimerStore>()(
const timer = createEvent(params); const timer = createEvent(params);
set((s) => ({ timers: [...s.timers, timer] })); set((s) => ({ timers: [...s.timers, timer] }));
trackTimerCreated('event', timer.urgency, timer.cascade?.preset ?? 'none'); trackTimerCreated('event', timer.urgency, timer.cascade?.preset ?? 'none');
if (isSyncEnabled()) enqueueChange(timer, 'create');
return timer; return timer;
}, },
@ -108,6 +113,7 @@ export const useTimerStore = create<TimerStore>()(
})) }))
.filter((c) => c.timerIds.length > 1), .filter((c) => c.timerIds.length > 1),
})); }));
if (isSyncEnabled()) enqueueDeleteChange(id);
}, },
addChain: (name, timerIds, delayMs = 0) => { addChain: (name, timerIds, delayMs = 0) => {
@ -137,10 +143,14 @@ export const useTimerStore = create<TimerStore>()(
pause: (id) => { pause: (id) => {
set((s) => ({ timers: updateTimer(s.timers, id, pauseTimer) })); 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) => { resume: (id) => {
set((s) => ({ timers: updateTimer(s.timers, id, resumeTimer) })); 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) => { fire: (id) => {
@ -149,10 +159,14 @@ export const useTimerStore = create<TimerStore>()(
snooze: (id, minutes) => { snooze: (id, minutes) => {
set((s) => ({ timers: updateTimer(s.timers, id, (t) => snoozeTimer(t, 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) => { dismiss: (id) => {
set((s) => ({ timers: updateTimer(s.timers, id, dismissTimer) })); 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) => { complete: (id) => {
@ -161,6 +175,8 @@ export const useTimerStore = create<TimerStore>()(
trackTimerCompleted(timer.type, timer.targetTime - timer.createdAt); trackTimerCompleted(timer.type, timer.targetTime - timer.createdAt);
} }
set((s) => ({ timers: updateTimer(s.timers, id, completeTimer) })); 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 // Auto-start next timer in chain
const { chains, timers } = get(); const { chains, timers } = get();
const link = getNextInChain(chains, id); const link = getNextInChain(chains, id);