feat: add Zustand store, dashboard UI, timer cards, create modal, alarm overlay

This commit is contained in:
saravanakumardb1 2026-02-27 20:55:40 -08:00
parent 6ac54d76fd
commit da4f3b5419
11 changed files with 1265 additions and 82 deletions

View File

@ -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;
}

View File

@ -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 (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
>
{children}
</body>

View File

@ -1,65 +1,5 @@
import Image from "next/image";
import { Dashboard } from '@/components/Dashboard';
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
return <Dashboard />;
}

View File

@ -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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0"
style={{
background: isCritical
? `radial-gradient(ellipse at center, ${urgencyConfig.bgColor} 0%, rgba(0,0,0,0.9) 100%)`
: 'rgba(0,0,0,0.8)',
}}
/>
{/* Content */}
<div className="relative text-center px-8 py-12 max-w-sm">
{/* Pulsing bell */}
<div
className="mx-auto mb-6 w-24 h-24 rounded-full flex items-center justify-center animate-pulse"
style={{ backgroundColor: urgencyConfig.bgColor }}
>
<Bell size={48} style={{ color: urgencyConfig.color }} />
</div>
<h1 className="text-3xl font-bold mb-2" style={{ color: urgencyConfig.color }}>
{timer.label}
</h1>
<p className="text-lg mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
{timer.type === 'pomodoro'
? timer.pomodoroState?.isBreak
? 'Break is over!'
: `Round ${timer.pomodoroState?.currentRound} complete!`
: 'Timer fired!'}
</p>
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
{formatTime(timer.firedAt ?? Date.now())}
{timer.snoozeCount > 0 && ` · Snoozed ${timer.snoozeCount}x`}
</p>
{/* Actions */}
<div className="flex flex-col gap-3">
{timer.type === 'pomodoro' && (
<button
onClick={() => advancePom(timer.id)}
className="w-full py-3 rounded-xl text-base font-semibold transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
{timer.pomodoroState?.isBreak ? 'Start Next Round' : 'Start Break'}
</button>
)}
<div className="flex gap-3">
<button
onClick={() => snooze(timer.id, 5)}
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
color: 'var(--cm-text-secondary)',
border: '1px solid var(--cm-border)',
}}
>
<BellOff size={16} /> 5 min
</button>
<button
onClick={() => snooze(timer.id, 15)}
className="flex-1 py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer flex items-center justify-center gap-2"
style={{
backgroundColor: 'var(--cm-surface-card)',
color: 'var(--cm-text-secondary)',
border: '1px solid var(--cm-border)',
}}
>
<BellOff size={16} /> 15 min
</button>
</div>
<button
onClick={() => dismiss(timer.id)}
className="w-full py-3 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: isCritical ? urgencyConfig.color : 'rgba(255,71,87,0.2)',
color: isCritical ? '#fff' : 'var(--cm-danger)',
}}
>
{isCritical ? 'Confirm Dismiss' : 'Dismiss'}
</button>
</div>
{/* Multiple firing indicator */}
{firingTimers.length > 1 && (
<p className="mt-4 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
+{firingTimers.length - 1} more timer{firingTimers.length > 2 ? 's' : ''} firing
</p>
)}
</div>
</div>
);
}

View File

@ -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<TabType>('countdown');
const [label, setLabel] = useState('');
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('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: <Timer size={16} /> },
{ key: 'alarm', label: 'Alarm', icon: <AlarmClock size={16} /> },
{ key: 'pomodoro', label: 'Pomodoro', icon: <Coffee size={16} /> },
];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div
className="relative w-full max-w-md mx-4 rounded-2xl border shadow-2xl overflow-hidden"
style={{
backgroundColor: 'var(--cm-bg-elevated)',
borderColor: 'var(--cm-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
New Timer
</h2>
<button
onClick={onClose}
className="p-1 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
>
<X size={20} />
</button>
</div>
{/* Tabs */}
<div className="flex border-b" style={{ borderColor: 'var(--cm-border)' }}>
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className="flex-1 flex items-center justify-center gap-2 py-3 text-sm font-medium transition-colors cursor-pointer"
style={{
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
}}
>
{t.icon} {t.label}
</button>
))}
</div>
{/* Form */}
<div className="p-4 space-y-4">
{/* Label */}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Label
</label>
<input
type="text"
value={label}
onChange={(e) => 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)',
}}
/>
</div>
{/* Tab-specific fields */}
{tab === 'alarm' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Time
</label>
<input
type="time"
value={alarmTime}
onChange={(e) => 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)',
}}
/>
</div>
)}
{tab === 'countdown' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Duration
</label>
<div className="flex gap-2">
{[
{ 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) => (
<div key={field.label} className="flex-1">
<input
type="number"
min={0}
max={field.max}
value={field.value}
onChange={(e) => 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)',
}}
/>
<span className="block text-center text-xs mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{field.label}
</span>
</div>
))}
</div>
{/* Quick presets */}
<div className="flex gap-2 mt-2">
{[
{ 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) => (
<button
key={preset.label}
onClick={() => { setHours(preset.h); setMinutes(preset.m); setSeconds(preset.s); }}
className="flex-1 py-1 rounded-md text-xs font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-muted)',
color: 'var(--cm-text-secondary)',
}}
>
{preset.label}
</button>
))}
</div>
</div>
)}
{tab === 'pomodoro' && (
<div className="grid grid-cols-2 gap-3">
{[
{ 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) => (
<div key={field.label}>
<label className="block text-xs font-medium mb-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{field.label}
</label>
<input
type="number"
min={1}
max={120}
value={field.value}
onChange={(e) => 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)',
}}
/>
</div>
))}
</div>
)}
{/* Urgency (non-pomodoro) */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Urgency
</label>
<div className="flex gap-1">
{URGENCY_ORDER.map((level) => {
const config = getUrgencyConfig(level);
return (
<button
key={level}
onClick={() => setUrgency(level)}
className="flex-1 py-1.5 rounded-lg text-xs font-medium transition-all cursor-pointer"
style={{
backgroundColor: urgency === level ? config.bgColor : 'var(--cm-surface-muted)',
color: urgency === level ? config.color : 'var(--cm-text-tertiary)',
border: urgency === level ? `1px solid ${config.borderColor}` : '1px solid transparent',
}}
>
{config.label}
</button>
);
})}
</div>
</div>
)}
{/* Cascade preset (non-pomodoro) */}
{tab !== 'pomodoro' && (
<div>
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
Pre-warning Cascade
</label>
<select
value={cascadePreset}
onChange={(e) => setCascadePreset(e.target.value as CascadePreset)}
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2 cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
color: 'var(--cm-text-primary)',
}}
>
{Object.entries(CASCADE_PRESET_LABELS).map(([key, label]) => (
<option key={key} value={key}>{label}</option>
))}
</select>
</div>
)}
{/* Create button */}
<button
onClick={handleCreate}
className="w-full py-3 rounded-xl text-sm font-semibold transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-accent)',
color: '#fff',
}}
>
Create {tab === 'pomodoro' ? 'Pomodoro' : tab === 'alarm' ? 'Alarm' : 'Countdown'}
</button>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div
className="min-h-screen flex items-center justify-center"
style={{ backgroundColor: 'var(--cm-bg-canvas)' }}
>
<Clock size={32} className="animate-spin" style={{ color: 'var(--cm-accent)' }} />
</div>
);
}
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 (
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
{/* Alarm overlay for firing timers */}
<AlarmOverlay />
{/* Header */}
<header
className="sticky top-0 z-40 border-b backdrop-blur-md"
style={{
backgroundColor: 'rgba(6, 7, 10, 0.85)',
borderColor: 'var(--cm-border)',
}}
>
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Clock size={24} style={{ color: 'var(--cm-accent)' }} />
<h1 className="text-lg font-bold" style={{ color: 'var(--cm-text-primary)' }}>
ChronoMind
</h1>
</div>
<div className="flex items-center gap-3">
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
{formatTime(now)} · {formatDate(now)}
</span>
<button
onClick={() => setIsCreateOpen(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Plus size={16} /> New Timer
</button>
</div>
</div>
</header>
{/* Main content */}
<main className="max-w-3xl mx-auto px-4 py-6">
{/* Active timers */}
{activeTimers.length > 0 ? (
<div className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
<Bell size={14} /> Active ({activeTimers.length})
</h2>
{activeTimers
.sort((a, b) => a.targetTime - b.targetTime)
.map((timer) => (
<TimerCard key={timer.id} timer={timer} />
))}
</div>
) : (
/* Empty state */
<div className="text-center py-20">
<Clock size={64} className="mx-auto mb-4 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--cm-text-primary)' }}>
No active timers
</h2>
<p className="mb-6" style={{ color: 'var(--cm-text-tertiary)' }}>
Create your first timer and never be caught off-guard again.
</p>
<button
onClick={() => setIsCreateOpen(true)}
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold transition-all cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Plus size={18} /> Create Timer
</button>
</div>
)}
{/* Completed timers */}
{completedTimers.length > 0 && (
<div className="mt-8 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--cm-text-tertiary)' }}>
Recent ({completedTimers.length})
</h2>
{completedTimers.map((timer) => (
<TimerCard key={timer.id} timer={timer} />
))}
</div>
)}
</main>
{/* Create Timer Modal */}
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
</div>
);
}

View File

@ -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: <AlarmClock size={16} />,
countdown: <TimerIcon size={16} />,
pomodoro: <Coffee size={16} />,
event: <Clock size={16} />,
}[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 (
<div
className={`relative rounded-xl border p-4 transition-all duration-200 ${
isFiring
? 'animate-pulse border-2'
: 'border'
}`}
style={{
backgroundColor: isFiring ? urgencyConfig.bgColor : 'var(--cm-surface-card)',
borderColor: isFiring ? urgencyConfig.color : 'var(--cm-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span style={{ color: urgencyConfig.color }}>{typeIcon}</span>
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
{timer.type.charAt(0).toUpperCase() + timer.type.slice(1)}
{pomLabel && ` · ${pomLabel}`}
</span>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: urgencyConfig.bgColor,
color: urgencyConfig.color,
}}
>
{urgencyConfig.label}
</span>
</div>
<span
className="text-xs font-mono px-2 py-0.5 rounded"
style={{
backgroundColor: isFiring ? urgencyConfig.color : 'var(--cm-surface-muted)',
color: isFiring ? '#fff' : 'var(--cm-text-tertiary)',
}}
>
{stateLabel}
</span>
</div>
{/* Label */}
<h3 className="text-lg font-semibold mb-1" style={{ color: 'var(--cm-text-primary)' }}>
{timer.label}
</h3>
{/* Countdown / Time */}
{!isDone && (
<div className="flex items-baseline gap-2 mb-3">
<span
className="text-3xl font-mono font-bold tabular-nums tracking-tight"
style={{ color: isFiring ? urgencyConfig.color : 'var(--cm-text-primary)' }}
>
{formatDuration(remaining)}
</span>
<span className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
fires at {formatTime(timer.targetTime)}
</span>
</div>
)}
{/* Cascade warnings progress */}
{timer.warnings.length > 0 && !isDone && (
<div className="flex gap-1 mb-3">
{timer.warnings.map((w) => (
<div
key={w.id}
className="flex-1 h-1.5 rounded-full transition-colors"
style={{
backgroundColor: w.fired ? urgencyConfig.color : 'var(--cm-surface-muted)',
opacity: w.fired ? 1 : 0.3,
}}
title={`${formatMinutesBefore(w.minutesBefore)} before — ${w.fired ? 'fired' : 'pending'}`}
/>
))}
</div>
)}
{/* Next warning */}
{nextWarning && !isDone && !isFiring && (
<p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}>
<Bell size={12} />
Next warning: {formatMinutesBefore(nextWarning.minutesBefore)} before
</p>
)}
{/* Snooze info */}
{timer.snoozeCount > 0 && (
<p className="text-xs mb-3 flex items-center gap-1" style={{ color: 'var(--cm-text-tertiary)' }}>
<BellOff size={12} />
Snoozed {timer.snoozeCount}x
</p>
)}
{/* Actions */}
<div className="flex gap-2">
{isActive && timer.type !== 'alarm' && (
<button
onClick={() => pause(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-surface-muted)',
color: 'var(--cm-text-secondary)',
}}
>
<Pause size={14} /> Pause
</button>
)}
{isPaused && (
<button
onClick={() => resume(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{
backgroundColor: 'var(--cm-accent)',
color: '#fff',
}}
>
<Play size={14} /> Resume
</button>
)}
{isFiring && (
<>
<button
onClick={() => snooze(timer.id, 5)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
5m
</button>
<button
onClick={() => snooze(timer.id, 15)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
15m
</button>
{timer.type === 'pomodoro' && (
<button
onClick={() => advancePom(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent-secondary)', color: '#000' }}
>
Next
</button>
)}
</>
)}
{(isActive || isPaused || isFiring) && (
<button
onClick={() => dismiss(timer.id)}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ml-auto"
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
>
<X size={14} /> Dismiss
</button>
)}
</div>
</div>
);
}

73
web/src/lib/format.ts Normal file
View File

@ -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',
});
}

View File

@ -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<NotificationPermission> {
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}` }
);
}

179
web/src/lib/store.ts Normal file
View File

@ -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<TimerStore>()(
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 }),
}
)
);

28
web/src/lib/use-tick.ts Normal file
View File

@ -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<number>(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]);
}