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";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
/* ChronoMind Dark Theme */
|
||||||
--foreground: #171717;
|
--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 {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: "Inter", system-ui, -apple-system, sans-serif;
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: "JetBrains Mono", ui-monospace, monospace;
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
color: var(--foreground);
|
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 type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-inter",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-jetbrains-mono",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "ChronoMind — Smart Pre-Warning Timer",
|
||||||
description: "Generated by create next app",
|
description: "AI-powered time awareness layer with pre-warning cascades, visual timeline, and Pomodoro sessions.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -25,7 +25,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { Dashboard } from '@/components/Dashboard';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return <Dashboard />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
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