feat(sync): wire platform sync into timer and routine stores, extend fullSync for routines, add error/not-found pages
This commit is contained in:
parent
fe75bc30de
commit
3596a8f350
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
44
web/src/app/error.tsx
Normal file
44
web/src/app/error.tsx
Normal 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
22
web/src/app/not-found.tsx
Normal 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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
@ -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<SyncResult> {
|
||||
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<SyncResult> {
|
||||
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<SyncResult> {
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<RoutineStore>()(
|
||||
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<RoutineStore>()(
|
||||
|
||||
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<RoutineStore>()(
|
||||
|
||||
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<RoutineStore>()(
|
||||
if (!template) return null;
|
||||
const routine = startRoutine(instantiateTemplate(template));
|
||||
set((s) => ({ routines: [...s.routines, routine] }));
|
||||
if (isSyncEnabled()) enqueueRoutineChange(routine, 'create');
|
||||
return routine;
|
||||
},
|
||||
|
||||
|
||||
@ -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<TimerStore>()(
|
||||
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<TimerStore>()(
|
||||
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<TimerStore>()(
|
||||
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<TimerStore>()(
|
||||
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<TimerStore>()(
|
||||
}))
|
||||
.filter((c) => c.timerIds.length > 1),
|
||||
}));
|
||||
if (isSyncEnabled()) enqueueDeleteChange(id);
|
||||
},
|
||||
|
||||
addChain: (name, timerIds, delayMs = 0) => {
|
||||
@ -137,10 +143,14 @@ export const useTimerStore = create<TimerStore>()(
|
||||
|
||||
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<TimerStore>()(
|
||||
|
||||
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<TimerStore>()(
|
||||
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user