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 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
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 {
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user