feat: add Zustand store, dashboard UI, timer cards, create modal, alarm overlay
This commit is contained in:
parent
6ac54d76fd
commit
da4f3b5419
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
118
web/src/components/AlarmOverlay.tsx
Normal file
118
web/src/components/AlarmOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
333
web/src/components/CreateTimerModal.tsx
Normal file
333
web/src/components/CreateTimerModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
web/src/components/Dashboard.tsx
Normal file
149
web/src/components/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
226
web/src/components/TimerCard.tsx
Normal file
226
web/src/components/TimerCard.tsx
Normal 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
73
web/src/lib/format.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
83
web/src/lib/notifications.ts
Normal file
83
web/src/lib/notifications.ts
Normal 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
179
web/src/lib/store.ts
Normal 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
28
web/src/lib/use-tick.ts
Normal 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]);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user