feat: add PomodoroView, CountdownRing, QuickTimerBar, keyboard shortcuts

This commit is contained in:
saravanakumardb1 2026-02-27 20:58:06 -08:00
parent da4f3b5419
commit 6b46384304
5 changed files with 408 additions and 5 deletions

View File

@ -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 (
<div className="relative inline-flex items-center justify-center" style={{ width: size, height: size }}>
<svg width={size} height={size} className="transform -rotate-90">
{/* Track */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={trackColor}
strokeWidth={strokeWidth}
/>
{/* Progress */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={dynamicColor}
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
className="transition-all duration-300"
/>
</svg>
{/* Center content */}
{children && (
<div className="absolute inset-0 flex items-center justify-center">
{children}
</div>
)}
</div>
);
}
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
}

View File

@ -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() {
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
{formatTime(now)} · {formatDate(now)}
</span>
<button
onClick={() => setShowShortcuts((p) => !p)}
className="p-2 rounded-lg transition-colors cursor-pointer"
style={{ color: 'var(--cm-text-tertiary)' }}
title="Keyboard shortcuts (?)"
>
<Keyboard size={18} />
</button>
<button
onClick={() => setIsCreateOpen(true)}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
@ -95,8 +129,41 @@ export function Dashboard() {
</div>
</header>
{/* Keyboard shortcuts overlay */}
{showShortcuts && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60" onClick={() => setShowShortcuts(false)} />
<div
className="relative rounded-2xl border p-6 max-w-sm w-full mx-4"
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)' }}
>
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
Keyboard Shortcuts
</h3>
<div className="space-y-2">
{SHORTCUT_MAP.map((s) => (
<div key={s.key} className="flex items-center justify-between">
<span className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>{s.description}</span>
<kbd
className="px-2 py-1 rounded text-xs font-mono"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)' }}
>
{s.key}
</kbd>
</div>
))}
</div>
</div>
</div>
)}
{/* Main content */}
<main className="max-w-3xl mx-auto px-4 py-6">
{/* Quick timer bar */}
<div className="mb-6">
<QuickTimerBar />
</div>
{/* Active timers */}
{activeTimers.length > 0 ? (
<div className="space-y-3">
@ -105,9 +172,13 @@ export function Dashboard() {
</h2>
{activeTimers
.sort((a, b) => a.targetTime - b.targetTime)
.map((timer) => (
<TimerCard key={timer.id} timer={timer} />
))}
.map((timer) =>
timer.type === 'pomodoro' ? (
<PomodoroView key={timer.id} timer={timer} />
) : (
<TimerCard key={timer.id} timer={timer} />
)
)}
</div>
) : (
/* Empty state */
@ -144,6 +215,13 @@ export function Dashboard() {
{/* Create Timer Modal */}
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
{/* Footer */}
<footer className="max-w-3xl mx-auto px-4 py-8 text-center">
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
ChronoMind Press <kbd className="px-1 py-0.5 rounded text-xs font-mono" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>?</kbd> for shortcuts
</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,147 @@
'use client';
import { useTimerStore } from '@/lib/store';
import { getRemainingMs } from '@/lib/timer-engine';
import type { Timer } from '@/lib/timer-engine';
import { CountdownRing } from './CountdownRing';
import { formatDuration } from '@/lib/format';
import { Coffee, Pause, Play, X, SkipForward } from 'lucide-react';
interface PomodoroViewProps {
timer: Timer;
}
export function PomodoroView({ timer }: PomodoroViewProps) {
const now = useTimerStore((s) => s.now);
const { pause, resume, dismiss, advancePom } = useTimerStore();
const remaining = getRemainingMs(timer, now);
const duration = timer.duration ?? 1;
const progress = Math.max(0, remaining / duration);
const pomState = timer.pomodoroState;
const config = timer.pomodoroConfig;
if (!pomState || !config) return null;
const isBreak = pomState.isBreak || pomState.isLongBreak;
const isPaused = timer.state === 'paused';
const isFiring = timer.state === 'firing';
const roundLabel = isBreak
? pomState.isLongBreak ? 'Long Break' : 'Break'
: `Round ${pomState.currentRound} of ${config.rounds}`;
const ringColor = isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)';
return (
<div
className="rounded-2xl border p-6 text-center"
style={{
backgroundColor: 'var(--cm-surface-card)',
borderColor: 'var(--cm-border)',
}}
>
{/* Header */}
<div className="flex items-center justify-center gap-2 mb-4">
<Coffee size={18} style={{ color: isBreak ? 'var(--cm-accent-secondary)' : 'var(--cm-accent)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
{timer.label}
</span>
</div>
{/* Round indicator */}
<div className="flex justify-center gap-1.5 mb-6">
{Array.from({ length: config.rounds }).map((_, i) => (
<div
key={i}
className="w-3 h-3 rounded-full transition-colors"
style={{
backgroundColor:
i < pomState.completedRounds
? 'var(--cm-success)'
: i === pomState.currentRound - 1 && !isBreak
? 'var(--cm-accent)'
: 'var(--cm-surface-muted)',
}}
/>
))}
</div>
{/* Countdown ring */}
<div className="flex justify-center mb-4">
<CountdownRing progress={progress} size={220} strokeWidth={10} color={ringColor}>
<div className="text-center">
<div
className="text-4xl font-mono font-bold tabular-nums"
style={{ color: 'var(--cm-text-primary)' }}
>
{formatDuration(remaining)}
</div>
<div className="text-sm mt-1" style={{ color: 'var(--cm-text-tertiary)' }}>
{roundLabel}
</div>
</div>
</CountdownRing>
</div>
{/* Status */}
{isPaused && (
<p className="text-sm mb-4 font-medium" style={{ color: 'var(--cm-warning)' }}>
Paused
</p>
)}
{isFiring && (
<p className="text-sm mb-4 font-medium animate-pulse" style={{ color: 'var(--cm-critical)' }}>
{isBreak ? 'Break over! Start next round?' : 'Time\'s up! Take a break?'}
</p>
)}
{/* Actions */}
<div className="flex justify-center gap-3">
{(timer.state === 'active' || timer.state === 'warning') && (
<button
onClick={() => pause(timer.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
>
<Pause size={16} /> Pause
</button>
)}
{isPaused && (
<button
onClick={() => resume(timer.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<Play size={16} /> Resume
</button>
)}
{isFiring && (
<button
onClick={() => advancePom(timer.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
>
<SkipForward size={16} /> {isBreak ? 'Start Work' : 'Start Break'}
</button>
)}
<button
onClick={() => dismiss(timer.id)}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer"
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
>
<X size={16} /> End
</button>
</div>
{/* Stats */}
<div className="mt-4 flex justify-center gap-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
<span>Completed: {pomState.completedRounds}/{config.rounds}</span>
<span>Work: {config.workMinutes}m</span>
<span>Break: {config.breakMinutes}m</span>
</div>
</div>
);
}

View File

@ -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: <Zap size={14} /> },
{ label: '15m', durationMs: 15 * 60_000, icon: <Timer size={14} /> },
{ label: '25m', durationMs: 25 * 60_000, icon: <Coffee size={14} /> },
{ label: '45m', durationMs: 45 * 60_000, icon: <Timer size={14} /> },
{ label: '1h', durationMs: 60 * 60_000, icon: <AlarmClock size={14} /> },
];
export function QuickTimerBar() {
const { addCountdown, addPomodoro } = useTimerStore();
return (
<div className="flex gap-2 flex-wrap">
{QUICK_PRESETS.map((preset) => (
<button
key={preset.label}
onClick={() =>
addCountdown({
label: `${preset.label} Timer`,
durationMs: preset.durationMs,
urgency: 'standard',
cascade: { preset: 'light', intervals: [] },
})
}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105"
style={{
backgroundColor: 'var(--cm-surface-card)',
color: 'var(--cm-text-secondary)',
border: '1px solid var(--cm-border)',
}}
>
{preset.icon} {preset.label}
</button>
))}
<button
onClick={() => addPomodoro({ label: 'Focus Session' })}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium transition-all cursor-pointer hover:scale-105"
style={{
backgroundColor: 'rgba(90, 140, 255, 0.1)',
color: 'var(--cm-accent)',
border: '1px solid rgba(90, 140, 255, 0.2)',
}}
>
<Coffee size={14} /> Pomodoro
</button>
</div>
);
}

View File

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