From da4f3b5419f7fbe1a39372123c6bd7c4467088c5 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 20:55:40 -0800 Subject: [PATCH] feat: add Zustand store, dashboard UI, timer cards, create modal, alarm overlay --- web/src/app/globals.css | 78 +++++- web/src/app/layout.tsx | 16 +- web/src/app/page.tsx | 64 +---- web/src/components/AlarmOverlay.tsx | 118 +++++++++ web/src/components/CreateTimerModal.tsx | 333 ++++++++++++++++++++++++ web/src/components/Dashboard.tsx | 149 +++++++++++ web/src/components/TimerCard.tsx | 226 ++++++++++++++++ web/src/lib/format.ts | 73 ++++++ web/src/lib/notifications.ts | 83 ++++++ web/src/lib/store.ts | 179 +++++++++++++ web/src/lib/use-tick.ts | 28 ++ 11 files changed, 1265 insertions(+), 82 deletions(-) create mode 100644 web/src/components/AlarmOverlay.tsx create mode 100644 web/src/components/CreateTimerModal.tsx create mode 100644 web/src/components/Dashboard.tsx create mode 100644 web/src/components/TimerCard.tsx create mode 100644 web/src/lib/format.ts create mode 100644 web/src/lib/notifications.ts create mode 100644 web/src/lib/store.ts create mode 100644 web/src/lib/use-tick.ts diff --git a/web/src/app/globals.css b/web/src/app/globals.css index a2dc41e..fc61139 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,26 +1,80 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + /* ChronoMind Dark Theme */ + --cm-bg-canvas: #06070A; + --cm-bg-elevated: #0E1118; + --cm-surface-card: #121725; + --cm-surface-muted: #1A2335; + --cm-text-primary: #EFF4FF; + --cm-text-secondary: #A5B1C7; + --cm-text-tertiary: #6C7C98; + --cm-border: #1E293B; + --cm-border-subtle: #151D2E; + + /* Urgency colors */ + --cm-critical: #FF4757; + --cm-important: #FF9F43; + --cm-standard: #FECA57; + --cm-gentle: #2ED573; + --cm-passive: #A5B1C7; + + /* Accent */ + --cm-accent: #5A8CFF; + --cm-accent-secondary: #2EE6D6; + + /* Semantic */ + --cm-success: #34D399; + --cm-warning: #F59E0B; + --cm-danger: #FF6E6E; + + --background: var(--cm-bg-canvas); + --foreground: var(--cm-text-primary); +} + +.light { + --cm-bg-canvas: #F8FAFC; + --cm-bg-elevated: #FFFFFF; + --cm-surface-card: #F1F5F9; + --cm-surface-muted: #E2E8F0; + --cm-text-primary: #0F172A; + --cm-text-secondary: #475569; + --cm-text-tertiary: #94A3B8; + --cm-border: #CBD5E1; + --cm-border-subtle: #E2E8F0; + --background: var(--cm-bg-canvas); + --foreground: var(--cm-text-primary); } @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); -} - -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", ui-monospace, monospace; } body { background: var(--background); color: var(--foreground); - font-family: Arial, Helvetica, sans-serif; + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: var(--cm-bg-canvas); +} +::-webkit-scrollbar-thumb { + background: var(--cm-surface-muted); + border-radius: 3px; +} + +/* Focus visible */ +:focus-visible { + outline: 2px solid var(--cm-accent); + outline-offset: 2px; } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index f7fa87e..d67009b 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,20 +1,20 @@ import type { Metadata } from "next"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Inter, JetBrains_Mono } from "next/font/google"; import "./globals.css"; -const geistSans = Geist({ - variable: "--font-geist-sans", +const inter = Inter({ + variable: "--font-inter", subsets: ["latin"], }); -const geistMono = Geist_Mono({ - variable: "--font-geist-mono", +const jetbrainsMono = JetBrains_Mono({ + variable: "--font-jetbrains-mono", subsets: ["latin"], }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "ChronoMind — Smart Pre-Warning Timer", + description: "AI-powered time awareness layer with pre-warning cascades, visual timeline, and Pomodoro sessions.", }; export default function RootLayout({ @@ -25,7 +25,7 @@ export default function RootLayout({ return ( {children} diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 295f8fd..2bc91b8 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { Dashboard } from '@/components/Dashboard'; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
-
- - Vercel logomark - Deploy Now - - - Documentation - -
-
-
- ); + return ; } diff --git a/web/src/components/AlarmOverlay.tsx b/web/src/components/AlarmOverlay.tsx new file mode 100644 index 0000000..42ca8b0 --- /dev/null +++ b/web/src/components/AlarmOverlay.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useTimerStore } from '@/lib/store'; +import { getUrgencyConfig } from '@/lib/urgency'; +import { formatTime } from '@/lib/format'; +import { Bell, BellOff } from 'lucide-react'; + +export function AlarmOverlay() { + const timers = useTimerStore((s) => s.timers); + const { snooze, dismiss, advancePom } = useTimerStore(); + + const firingTimers = timers.filter((t) => t.state === 'firing'); + + if (firingTimers.length === 0) return null; + + // Show the most urgent firing timer + const timer = firingTimers[0]; + const urgencyConfig = getUrgencyConfig(timer.urgency); + const isCritical = timer.urgency === 'critical'; + + return ( +
+ {/* Backdrop */} +
+ + {/* Content */} +
+ {/* Pulsing bell */} +
+ +
+ +

+ {timer.label} +

+ +

+ {timer.type === 'pomodoro' + ? timer.pomodoroState?.isBreak + ? 'Break is over!' + : `Round ${timer.pomodoroState?.currentRound} complete!` + : 'Timer fired!'} +

+ +

+ {formatTime(timer.firedAt ?? Date.now())} + {timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`} +

+ + {/* Actions */} +
+ {timer.type === 'pomodoro' && ( + + )} + +
+ + +
+ + +
+ + {/* Multiple firing indicator */} + {firingTimers.length > 1 && ( +

+ +{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing +

+ )} +
+
+ ); +} diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx new file mode 100644 index 0000000..bce8d9a --- /dev/null +++ b/web/src/components/CreateTimerModal.tsx @@ -0,0 +1,333 @@ +'use client'; + +import { useState } from 'react'; +import { useTimerStore } from '@/lib/store'; +import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency'; +import type { UrgencyLevel } from '@/lib/urgency'; +import { CASCADE_PRESET_LABELS } from '@/lib/cascade'; +import type { CascadePreset } from '@/lib/cascade'; +import { X, AlarmClock, Timer, Coffee } from 'lucide-react'; + +type TabType = 'alarm' | 'countdown' | 'pomodoro'; + +interface CreateTimerModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { + const { addAlarm, addCountdown, addPomodoro } = useTimerStore(); + + const [tab, setTab] = useState('countdown'); + const [label, setLabel] = useState(''); + const [urgency, setUrgency] = useState('standard'); + const [cascadePreset, setCascadePreset] = useState('standard'); + + // Alarm fields + const [alarmTime, setAlarmTime] = useState(''); + + // Countdown fields + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(25); + const [seconds, setSeconds] = useState(0); + + // Pomodoro fields + const [workMin, setWorkMin] = useState(25); + const [breakMin, setBreakMin] = useState(5); + const [longBreakMin, setLongBreakMin] = useState(15); + const [rounds, setRounds] = useState(4); + + if (!isOpen) return null; + + const handleCreate = () => { + const cascade = { preset: cascadePreset, intervals: [] as number[] }; + + if (tab === 'alarm') { + if (!alarmTime) return; + const [h, m] = alarmTime.split(':').map(Number); + const target = new Date(); + target.setHours(h, m, 0, 0); + if (target.getTime() <= Date.now()) { + target.setDate(target.getDate() + 1); + } + addAlarm({ + label: label || 'Alarm', + targetTime: target.getTime(), + urgency, + cascade, + }); + } else if (tab === 'countdown') { + const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000; + if (durationMs <= 0) return; + addCountdown({ + label: label || 'Countdown', + durationMs, + urgency, + cascade, + }); + } else if (tab === 'pomodoro') { + addPomodoro({ + label: label || 'Focus Session', + config: { + workMinutes: workMin, + breakMinutes: breakMin, + longBreakMinutes: longBreakMin, + rounds, + }, + urgency, + }); + } + + // Reset form + setLabel(''); + setAlarmTime(''); + setHours(0); + setMinutes(25); + setSeconds(0); + onClose(); + }; + + const tabs: { key: TabType; label: string; icon: React.ReactNode }[] = [ + { key: 'countdown', label: 'Countdown', icon: }, + { key: 'alarm', label: 'Alarm', icon: }, + { key: 'pomodoro', label: 'Pomodoro', icon: }, + ]; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal */} +
+ {/* Header */} +
+

+ New Timer +

+ +
+ + {/* Tabs */} +
+ {tabs.map((t) => ( + + ))} +
+ + {/* Form */} +
+ {/* Label */} +
+ + setLabel(e.target.value)} + placeholder={tab === 'pomodoro' ? 'Focus Session' : tab === 'alarm' ? 'Wake up' : 'Timer'} + className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> +
+ + {/* Tab-specific fields */} + {tab === 'alarm' && ( +
+ + setAlarmTime(e.target.value)} + className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> +
+ )} + + {tab === 'countdown' && ( +
+ +
+ {[ + { label: 'H', value: hours, setter: setHours, max: 23 }, + { label: 'M', value: minutes, setter: setMinutes, max: 59 }, + { label: 'S', value: seconds, setter: setSeconds, max: 59 }, + ].map((field) => ( +
+ field.setter(Math.min(field.max, Math.max(0, parseInt(e.target.value) || 0)))} + className="w-full px-3 py-2 rounded-lg border text-sm text-center focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> + + {field.label} + +
+ ))} +
+ {/* Quick presets */} +
+ {[ + { label: '5m', h: 0, m: 5, s: 0 }, + { label: '15m', h: 0, m: 15, s: 0 }, + { label: '25m', h: 0, m: 25, s: 0 }, + { label: '45m', h: 0, m: 45, s: 0 }, + { label: '1h', h: 1, m: 0, s: 0 }, + ].map((preset) => ( + + ))} +
+
+ )} + + {tab === 'pomodoro' && ( +
+ {[ + { label: 'Work (min)', value: workMin, setter: setWorkMin }, + { label: 'Break (min)', value: breakMin, setter: setBreakMin }, + { label: 'Long Break (min)', value: longBreakMin, setter: setLongBreakMin }, + { label: 'Rounds', value: rounds, setter: setRounds }, + ].map((field) => ( +
+ + field.setter(Math.max(1, parseInt(e.target.value) || 1))} + className="w-full px-3 py-2 rounded-lg border text-sm text-center focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> +
+ ))} +
+ )} + + {/* Urgency (non-pomodoro) */} + {tab !== 'pomodoro' && ( +
+ +
+ {URGENCY_ORDER.map((level) => { + const config = getUrgencyConfig(level); + return ( + + ); + })} +
+
+ )} + + {/* Cascade preset (non-pomodoro) */} + {tab !== 'pomodoro' && ( +
+ + +
+ )} + + {/* Create button */} + +
+
+
+ ); +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx new file mode 100644 index 0000000..b33fbd9 --- /dev/null +++ b/web/src/components/Dashboard.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useTimerStore } from '@/lib/store'; +import { useTickLoop } from '@/lib/use-tick'; +import { TimerCard } from './TimerCard'; +import { CreateTimerModal } from './CreateTimerModal'; +import { AlarmOverlay } from './AlarmOverlay'; +import { requestNotificationPermission } from '@/lib/notifications'; +import { formatTime, formatDate } from '@/lib/format'; +import { Plus, Clock, Bell } from 'lucide-react'; + +export function Dashboard() { + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [mounted, setMounted] = useState(false); + const timers = useTimerStore((s) => s.timers); + const now = useTimerStore((s) => s.now); + + // Start the tick loop + useTickLoop(); + + // Hydration guard + useEffect(() => { + setMounted(true); + requestNotificationPermission(); + }, []); + + if (!mounted) { + return ( +
+ +
+ ); + } + + const activeTimers = timers.filter((t) => + ['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state) + ); + const completedTimers = timers + .filter((t) => ['dismissed', 'completed'].includes(t.state)) + .slice(-10) + .reverse(); + + // Tab title update + useEffect(() => { + const next = activeTimers + .filter((t) => ['active', 'warning'].includes(t.state)) + .sort((a, b) => a.targetTime - b.targetTime)[0]; + + if (next) { + const remaining = Math.max(0, next.targetTime - now); + const mins = Math.floor(remaining / 60000); + const secs = Math.floor((remaining % 60000) / 1000); + document.title = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')} — ${next.label} | ChronoMind`; + } else { + document.title = 'ChronoMind — Smart Pre-Warning Timer'; + } + }, [now, activeTimers]); + + return ( +
+ {/* Alarm overlay for firing timers */} + + + {/* Header */} +
+
+
+ +

+ ChronoMind +

+
+
+ + {formatTime(now)} · {formatDate(now)} + + +
+
+
+ + {/* Main content */} +
+ {/* Active timers */} + {activeTimers.length > 0 ? ( +
+

+ Active ({activeTimers.length}) +

+ {activeTimers + .sort((a, b) => a.targetTime - b.targetTime) + .map((timer) => ( + + ))} +
+ ) : ( + /* Empty state */ +
+ +

+ No active timers +

+

+ Create your first timer and never be caught off-guard again. +

+ +
+ )} + + {/* Completed timers */} + {completedTimers.length > 0 && ( +
+

+ Recent ({completedTimers.length}) +

+ {completedTimers.map((timer) => ( + + ))} +
+ )} +
+ + {/* Create Timer Modal */} + setIsCreateOpen(false)} /> +
+ ); +} diff --git a/web/src/components/TimerCard.tsx b/web/src/components/TimerCard.tsx new file mode 100644 index 0000000..78a7c8e --- /dev/null +++ b/web/src/components/TimerCard.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useTimerStore } from '@/lib/store'; +import { getRemainingMs } from '@/lib/timer-engine'; +import type { Timer } from '@/lib/timer-engine'; +import { getUrgencyConfig } from '@/lib/urgency'; +import { formatDuration, formatTime, formatDurationCompact } from '@/lib/format'; +import { formatMinutesBefore } from '@/lib/cascade'; +import { + Clock, + Pause, + Play, + X, + AlarmClock, + Timer as TimerIcon, + Coffee, + Bell, + BellOff, +} from 'lucide-react'; + +interface TimerCardProps { + timer: Timer; +} + +export function TimerCard({ timer }: TimerCardProps) { + const now = useTimerStore((s) => s.now); + const { pause, resume, dismiss, snooze, advancePom } = useTimerStore(); + const urgencyConfig = getUrgencyConfig(timer.urgency); + const remaining = getRemainingMs(timer, now); + + const typeIcon = { + alarm: , + countdown: , + pomodoro: , + event: , + }[timer.type]; + + const stateLabel = { + idle: 'Idle', + active: 'Active', + warning: 'Warning', + firing: 'FIRING!', + snoozed: 'Snoozed', + dismissed: 'Dismissed', + completed: 'Done', + paused: 'Paused', + }[timer.state]; + + const isFiring = timer.state === 'firing'; + const isPaused = timer.state === 'paused'; + const isActive = ['active', 'warning', 'snoozed'].includes(timer.state); + const isDone = ['dismissed', 'completed'].includes(timer.state); + + // Pomodoro label + const pomLabel = timer.pomodoroState + ? timer.pomodoroState.isBreak || timer.pomodoroState.isLongBreak + ? `Break` + : `Round ${timer.pomodoroState.currentRound}/${timer.pomodoroConfig?.rounds ?? 4}` + : null; + + // Next warning + const nextWarning = timer.warnings.find((w) => !w.fired); + + return ( +
+ {/* Header */} +
+
+ {typeIcon} + + {timer.type.charAt(0).toUpperCase() + timer.type.slice(1)} + {pomLabel && ` · ${pomLabel}`} + + + {urgencyConfig.label} + +
+ + {stateLabel} + +
+ + {/* Label */} +

+ {timer.label} +

+ + {/* Countdown / Time */} + {!isDone && ( +
+ + {formatDuration(remaining)} + + + fires at {formatTime(timer.targetTime)} + +
+ )} + + {/* Cascade warnings progress */} + {timer.warnings.length > 0 && !isDone && ( +
+ {timer.warnings.map((w) => ( +
+ ))} +
+ )} + + {/* Next warning */} + {nextWarning && !isDone && !isFiring && ( +

+ + Next warning: {formatMinutesBefore(nextWarning.minutesBefore)} before +

+ )} + + {/* Snooze info */} + {timer.snoozeCount > 0 && ( +

+ + Snoozed {timer.snoozeCount}x +

+ )} + + {/* Actions */} +
+ {isActive && timer.type !== 'alarm' && ( + + )} + + {isPaused && ( + + )} + + {isFiring && ( + <> + + + {timer.type === 'pomodoro' && ( + + )} + + )} + + {(isActive || isPaused || isFiring) && ( + + )} +
+
+ ); +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..07be1c4 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,73 @@ +// ── Format Utilities ─────────────────────────────────────────── + +/** + * Format milliseconds as HH:MM:SS or MM:SS + */ +export function formatDuration(ms: number): string { + if (ms <= 0) return '00:00'; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const pad = (n: number) => n.toString().padStart(2, '0'); + + if (hours > 0) { + return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`; + } + return `${pad(minutes)}:${pad(seconds)}`; +} + +/** + * Format milliseconds as compact string: "2h 15m", "45m", "30s" + */ +export function formatDurationCompact(ms: number): string { + if (ms <= 0) return '0s'; + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h`; + if (minutes > 0 && seconds > 0 && minutes < 5) return `${minutes}m ${seconds}s`; + if (minutes > 0) return `${minutes}m`; + return `${seconds}s`; +} + +/** + * Format a timestamp as relative time: "in 5m", "2h ago", "now" + */ +export function formatRelativeTime(targetTime: number, now: number = Date.now()): string { + const diff = targetTime - now; + const absDiff = Math.abs(diff); + + if (absDiff < 30_000) return 'now'; + + const compact = formatDurationCompact(absDiff); + return diff > 0 ? `in ${compact}` : `${compact} ago`; +} + +/** + * Format time as HH:MM AM/PM + */ +export function formatTime(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); +} + +/** + * Format date as "Mon, Feb 27" + */ +export function formatDate(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); +} diff --git a/web/src/lib/notifications.ts b/web/src/lib/notifications.ts new file mode 100644 index 0000000..b4ffc5e --- /dev/null +++ b/web/src/lib/notifications.ts @@ -0,0 +1,83 @@ +// ── Web Notification System ───────────────────────────────────── +import type { UrgencyLevel } from './urgency'; +import { getUrgencyConfig } from './urgency'; + +export type NotificationPermission = 'default' | 'granted' | 'denied'; + +export async function requestNotificationPermission(): Promise { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'denied'; + } + if (Notification.permission === 'granted') return 'granted'; + if (Notification.permission === 'denied') return 'denied'; + const result = await Notification.requestPermission(); + return result as NotificationPermission; +} + +export function getNotificationPermission(): NotificationPermission { + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'denied'; + } + return Notification.permission as NotificationPermission; +} + +export function sendNotification( + title: string, + body: string, + urgency: UrgencyLevel, + options?: { tag?: string; onClick?: () => void } +): Notification | null { + if (typeof window === 'undefined' || !('Notification' in window)) return null; + if (Notification.permission !== 'granted') return null; + + const config = getUrgencyConfig(urgency); + + const notification = new Notification(title, { + body, + icon: '/favicon.ico', + tag: options?.tag ?? `chronomind-${Date.now()}`, + requireInteraction: config.notificationStyle === 'persistent', + silent: !config.soundEnabled, + }); + + if (options?.onClick) { + notification.onclick = () => { + window.focus(); + options.onClick?.(); + notification.close(); + }; + } + + return notification; +} + +export function sendWarningNotification( + timerLabel: string, + minutesBefore: number, + urgency: UrgencyLevel, + timerId: string +): Notification | null { + const timeStr = minutesBefore >= 60 + ? `${Math.floor(minutesBefore / 60)}h ${minutesBefore % 60}m` + : `${minutesBefore}m`; + + return sendNotification( + `⏰ ${timerLabel} in ${timeStr}`, + `Pre-warning: "${timerLabel}" fires in ${timeStr}`, + urgency, + { tag: `warning-${timerId}-${minutesBefore}` } + ); +} + +export function sendFireNotification( + timerLabel: string, + urgency: UrgencyLevel, + timerId: string +): Notification | null { + return sendNotification( + `🔔 ${timerLabel} — NOW!`, + `Timer "${timerLabel}" is firing!`, + urgency, + { tag: `fire-${timerId}` } + ); +} diff --git a/web/src/lib/store.ts b/web/src/lib/store.ts new file mode 100644 index 0000000..a6dd63d --- /dev/null +++ b/web/src/lib/store.ts @@ -0,0 +1,179 @@ +// ── Zustand Store with IndexedDB Persistence ─────────────────── +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import type { Timer, CreateAlarmParams, CreateCountdownParams, CreatePomodoroParams } from './timer-engine'; +import { + createAlarm, + createCountdown, + createPomodoro, + pauseTimer, + resumeTimer, + fireTimer, + snoozeTimer, + dismissTimer, + completeTimer, + advancePomodoro, + shouldTimerFire, +} from './timer-engine'; +import { checkWarnings } from './cascade'; + +export interface TimerStore { + timers: Timer[]; + now: number; // current time for reactivity + + // CRUD + addAlarm: (params: CreateAlarmParams) => Timer; + addCountdown: (params: CreateCountdownParams) => Timer; + addPomodoro: (params?: CreatePomodoroParams) => Timer; + removeTimer: (id: string) => void; + + // State transitions + pause: (id: string) => void; + resume: (id: string) => void; + fire: (id: string) => void; + snooze: (id: string, minutes: number) => void; + dismiss: (id: string) => void; + complete: (id: string) => void; + advancePom: (id: string) => void; + + // Tick — called by rAF loop + tick: (now: number) => string[]; // returns IDs of newly-fired warnings + + // Helpers + getTimer: (id: string) => Timer | undefined; + getActiveTimers: () => Timer[]; + getNextFiringTimer: () => Timer | undefined; +} + +function updateTimer(timers: Timer[], id: string, updater: (t: Timer) => Timer): Timer[] { + return timers.map((t) => (t.id === id ? updater(t) : t)); +} + +export const useTimerStore = create()( + persist( + (set, get) => ({ + timers: [], + now: Date.now(), + + addAlarm: (params) => { + const timer = createAlarm(params); + set((s) => ({ timers: [...s.timers, timer] })); + return timer; + }, + + addCountdown: (params) => { + const timer = createCountdown(params); + set((s) => ({ timers: [...s.timers, timer] })); + return timer; + }, + + addPomodoro: (params) => { + const timer = createPomodoro(params); + set((s) => ({ timers: [...s.timers, timer] })); + return timer; + }, + + removeTimer: (id) => { + set((s) => ({ timers: s.timers.filter((t) => t.id !== id) })); + }, + + pause: (id) => { + set((s) => ({ timers: updateTimer(s.timers, id, pauseTimer) })); + }, + + resume: (id) => { + set((s) => ({ timers: updateTimer(s.timers, id, resumeTimer) })); + }, + + fire: (id) => { + set((s) => ({ timers: updateTimer(s.timers, id, fireTimer) })); + }, + + snooze: (id, minutes) => { + set((s) => ({ timers: updateTimer(s.timers, id, (t) => snoozeTimer(t, minutes)) })); + }, + + dismiss: (id) => { + set((s) => ({ timers: updateTimer(s.timers, id, dismissTimer) })); + }, + + complete: (id) => { + set((s) => ({ timers: updateTimer(s.timers, id, completeTimer) })); + }, + + advancePom: (id) => { + set((s) => ({ + timers: s.timers.map((t) => { + if (t.id !== id) return t; + const next = advancePomodoro(t); + return next ?? t; + }), + })); + }, + + tick: (now) => { + const firedWarningIds: string[] = []; + const { timers } = get(); + let changed = false; + + const updatedTimers = timers.map((timer) => { + // Check if timer should fire + if (shouldTimerFire(timer, now)) { + changed = true; + return fireTimer(timer); + } + + // Check cascade warnings + const newlyFired = checkWarnings(timer.warnings, now); + if (newlyFired.length > 0) { + firedWarningIds.push(...newlyFired.map((wId) => `${timer.id}:${wId}`)); + changed = true; + // If any warning fired, update state to 'warning' if still active + if (timer.state === 'active') { + return { ...timer, state: 'warning' as const }; + } + } + + return timer; + }); + + if (changed) { + set({ timers: updatedTimers, now }); + } else { + set({ now }); + } + + return firedWarningIds; + }, + + getTimer: (id) => get().timers.find((t) => t.id === id), + + getActiveTimers: () => + get().timers.filter((t) => + ['active', 'warning', 'snoozed', 'paused', 'firing'].includes(t.state) + ), + + getNextFiringTimer: () => { + const active = get() + .timers.filter((t) => ['active', 'warning'].includes(t.state)) + .sort((a, b) => a.targetTime - b.targetTime); + return active[0]; + }, + }), + { + name: 'chronomind-timers', + storage: createJSONStorage(() => { + if (typeof window === 'undefined') { + // SSR fallback + return { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + }; + } + return localStorage; + }), + partialize: (state) => ({ timers: state.timers }), + } + ) +); diff --git a/web/src/lib/use-tick.ts b/web/src/lib/use-tick.ts new file mode 100644 index 0000000..8be1792 --- /dev/null +++ b/web/src/lib/use-tick.ts @@ -0,0 +1,28 @@ +// ── rAF Tick Loop Hook ───────────────────────────────────────── +// Drives the UI countdown display at ~60fps in active tab +'use client'; + +import { useEffect, useRef } from 'react'; +import { useTimerStore } from './store'; + +export function useTickLoop() { + const tick = useTimerStore((s) => s.tick); + const rafRef = useRef(0); + + useEffect(() => { + let running = true; + + function loop() { + if (!running) return; + tick(Date.now()); + rafRef.current = requestAnimationFrame(loop); + } + + rafRef.current = requestAnimationFrame(loop); + + return () => { + running = false; + cancelAnimationFrame(rafRef.current); + }; + }, [tick]); +}