feat: add PomodoroView, CountdownRing, QuickTimerBar, keyboard shortcuts
This commit is contained in:
parent
da4f3b5419
commit
6b46384304
68
web/src/components/CountdownRing.tsx
Normal file
68
web/src/components/CountdownRing.tsx
Normal 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
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
147
web/src/components/PomodoroView.tsx
Normal file
147
web/src/components/PomodoroView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
web/src/components/QuickTimerBar.tsx
Normal file
53
web/src/components/QuickTimerBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
web/src/lib/use-keyboard-shortcuts.ts
Normal file
57
web/src/lib/use-keyboard-shortcuts.ts
Normal 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' },
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user