diff --git a/web/src/components/CountdownRing.tsx b/web/src/components/CountdownRing.tsx new file mode 100644 index 0000000..6ac8358 --- /dev/null +++ b/web/src/components/CountdownRing.tsx @@ -0,0 +1,68 @@ +'use client'; + +interface CountdownRingProps { + progress: number; // 0-1, where 1 = full, 0 = empty + size?: number; + strokeWidth?: number; + color?: string; + trackColor?: string; + children?: React.ReactNode; +} + +export function CountdownRing({ + progress, + size = 200, + strokeWidth = 8, + color, + trackColor = 'var(--cm-surface-muted)', + children, +}: CountdownRingProps) { + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference * (1 - Math.min(1, Math.max(0, progress))); + + // Color transitions: green → yellow → orange → red + const dynamicColor = color ?? getProgressColor(progress); + + return ( +
+ + {/* Track */} + + {/* Progress */} + + + {/* Center content */} + {children && ( +
+ {children} +
+ )} +
+ ); +} + +function getProgressColor(progress: number): string { + if (progress > 0.6) return 'var(--cm-gentle)'; // green + if (progress > 0.3) return 'var(--cm-standard)'; // yellow + if (progress > 0.1) return 'var(--cm-important)'; // orange + return 'var(--cm-critical)'; // red +} diff --git a/web/src/components/Dashboard.tsx b/web/src/components/Dashboard.tsx index b33fbd9..908c74d 100644 --- a/web/src/components/Dashboard.tsx +++ b/web/src/components/Dashboard.tsx @@ -1,24 +1,50 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useTimerStore } from '@/lib/store'; import { useTickLoop } from '@/lib/use-tick'; +import { useKeyboardShortcuts, SHORTCUT_MAP } from '@/lib/use-keyboard-shortcuts'; import { TimerCard } from './TimerCard'; +import { PomodoroView } from './PomodoroView'; +import { QuickTimerBar } from './QuickTimerBar'; 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'; +import { Plus, Clock, Bell, Keyboard } from 'lucide-react'; export function Dashboard() { const [isCreateOpen, setIsCreateOpen] = useState(false); + const [showShortcuts, setShowShortcuts] = useState(false); const [mounted, setMounted] = useState(false); const timers = useTimerStore((s) => s.timers); const now = useTimerStore((s) => s.now); + const { pause, resume } = useTimerStore(); // Start the tick loop useTickLoop(); + // Keyboard shortcuts + const getFirstActiveTimer = useCallback(() => { + return timers.find((t) => ['active', 'warning', 'paused'].includes(t.state)); + }, [timers]); + + useKeyboardShortcuts({ + onNewTimer: () => setIsCreateOpen(true), + onQuickTimer: () => setIsCreateOpen(true), + onTogglePause: () => { + const t = getFirstActiveTimer(); + if (!t) return; + if (t.state === 'paused') resume(t.id); + else if (t.type !== 'alarm') pause(t.id); + }, + onDismiss: () => { + if (isCreateOpen) setIsCreateOpen(false); + else if (showShortcuts) setShowShortcuts(false); + }, + onShowHelp: () => setShowShortcuts((p) => !p), + }); + // Hydration guard useEffect(() => { setMounted(true); @@ -84,6 +110,14 @@ export function Dashboard() { {formatTime(now)} · {formatDate(now)} + + )} + + {isPaused && ( + + )} + + {isFiring && ( + + )} + + + + + {/* Stats */} +
+ Completed: {pomState.completedRounds}/{config.rounds} + Work: {config.workMinutes}m + Break: {config.breakMinutes}m +
+ + ); +} diff --git a/web/src/components/QuickTimerBar.tsx b/web/src/components/QuickTimerBar.tsx new file mode 100644 index 0000000..f344c0e --- /dev/null +++ b/web/src/components/QuickTimerBar.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { useTimerStore } from '@/lib/store'; +import { Timer, Coffee, AlarmClock, Zap } from 'lucide-react'; + +const QUICK_PRESETS = [ + { label: '5m', durationMs: 5 * 60_000, icon: }, + { label: '15m', durationMs: 15 * 60_000, icon: }, + { label: '25m', durationMs: 25 * 60_000, icon: }, + { label: '45m', durationMs: 45 * 60_000, icon: }, + { label: '1h', durationMs: 60 * 60_000, icon: }, +]; + +export function QuickTimerBar() { + const { addCountdown, addPomodoro } = useTimerStore(); + + return ( +
+ {QUICK_PRESETS.map((preset) => ( + + ))} + +
+ ); +} diff --git a/web/src/lib/use-keyboard-shortcuts.ts b/web/src/lib/use-keyboard-shortcuts.ts new file mode 100644 index 0000000..b3064bf --- /dev/null +++ b/web/src/lib/use-keyboard-shortcuts.ts @@ -0,0 +1,57 @@ +// ── Keyboard Shortcuts ───────────────────────────────────────── +'use client'; + +import { useEffect } from 'react'; + +interface ShortcutActions { + onNewTimer: () => void; + onQuickTimer: () => void; + onTogglePause: () => void; + onDismiss: () => void; + onShowHelp: () => void; +} + +export function useKeyboardShortcuts(actions: ShortcutActions) { + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + // Don't trigger shortcuts when typing in inputs + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') { + return; + } + + switch (e.key.toLowerCase()) { + case 'n': + e.preventDefault(); + actions.onNewTimer(); + break; + case 'q': + e.preventDefault(); + actions.onQuickTimer(); + break; + case ' ': + e.preventDefault(); + actions.onTogglePause(); + break; + case 'escape': + actions.onDismiss(); + break; + case '?': + e.preventDefault(); + actions.onShowHelp(); + break; + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [actions]); +} + +export const SHORTCUT_MAP = [ + { key: 'N', description: 'New timer' }, + { key: 'Q', description: 'Quick timer modal' }, + { key: 'Space', description: 'Start/pause active timer' }, + { key: 'Esc', description: 'Dismiss alarm / close modal' }, + { key: '?', description: 'Show shortcuts' }, +];