304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
Play,
|
|
Pause,
|
|
RotateCcw,
|
|
SkipForward,
|
|
Flame,
|
|
Brain,
|
|
Coffee,
|
|
CheckCircle2,
|
|
Sparkles
|
|
} from 'lucide-react';
|
|
import { Task } from '@/lib/types';
|
|
import { playNotificationChime } from '@/lib/audio';
|
|
|
|
interface PomodoroViewProps {
|
|
tasks: Task[];
|
|
onCompleteSession?: () => void;
|
|
}
|
|
|
|
type TimerMode = 'focus' | 'shortBreak' | 'longBreak';
|
|
|
|
const MODE_CONFIG: Record<TimerMode, { label: string; duration: number; icon: typeof Brain; color: string; ringColor: string }> = {
|
|
focus: {
|
|
label: 'Focus Session',
|
|
duration: 25 * 60,
|
|
icon: Brain,
|
|
color: 'text-amber-400 bg-amber-500/10 border-amber-500/30',
|
|
ringColor: '#f59e0b',
|
|
},
|
|
shortBreak: {
|
|
label: 'Short Rest',
|
|
duration: 5 * 60,
|
|
icon: Coffee,
|
|
color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30',
|
|
ringColor: '#06b6d4',
|
|
},
|
|
longBreak: {
|
|
label: 'Long Recovery',
|
|
duration: 15 * 60,
|
|
icon: Coffee,
|
|
color: 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30',
|
|
ringColor: '#10b981',
|
|
},
|
|
};
|
|
|
|
export const PomodoroView: React.FC<PomodoroViewProps> = ({ tasks }) => {
|
|
const [mode, setMode] = useState<TimerMode>('focus');
|
|
const [timeLeft, setTimeLeft] = useState<number>(MODE_CONFIG.focus.duration);
|
|
const [isActive, setIsActive] = useState<boolean>(false);
|
|
const [completedSessions, setCompletedSessions] = useState<number>(3);
|
|
const [selectedTaskId, setSelectedTaskId] = useState<string>('');
|
|
|
|
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (isActive) {
|
|
timerRef.current = setInterval(() => {
|
|
setTimeLeft((prev) => {
|
|
if (prev <= 1) {
|
|
clearInterval(timerRef.current!);
|
|
setIsActive(false);
|
|
playNotificationChime('complete');
|
|
if (mode === 'focus') {
|
|
setCompletedSessions((c) => c + 1);
|
|
}
|
|
return 0;
|
|
}
|
|
return prev - 1;
|
|
});
|
|
}, 1000);
|
|
} else if (timerRef.current) {
|
|
clearInterval(timerRef.current);
|
|
}
|
|
|
|
return () => {
|
|
if (timerRef.current) clearInterval(timerRef.current);
|
|
};
|
|
}, [isActive, mode]);
|
|
|
|
const switchMode = (newMode: TimerMode) => {
|
|
setIsActive(false);
|
|
setMode(newMode);
|
|
setTimeLeft(MODE_CONFIG[newMode].duration);
|
|
playNotificationChime('click');
|
|
};
|
|
|
|
const handleToggleTimer = () => {
|
|
setIsActive(!isActive);
|
|
playNotificationChime('click');
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setIsActive(false);
|
|
setTimeLeft(MODE_CONFIG[mode].duration);
|
|
playNotificationChime('click');
|
|
};
|
|
|
|
const handleSkip = () => {
|
|
setIsActive(false);
|
|
if (mode === 'focus') {
|
|
switchMode('shortBreak');
|
|
} else {
|
|
switchMode('focus');
|
|
}
|
|
};
|
|
|
|
const minutes = Math.floor(timeLeft / 60);
|
|
const seconds = timeLeft % 60;
|
|
const timeFormatted = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
|
|
const currentDuration = MODE_CONFIG[mode].duration;
|
|
const progressPercent = ((currentDuration - timeLeft) / currentDuration) * 100;
|
|
|
|
// SVG ring properties
|
|
const radius = 130;
|
|
const circumference = 2 * Math.PI * radius;
|
|
const strokeDashoffset = circumference - (progressPercent / 100) * circumference;
|
|
|
|
const currentTask = tasks.find((t) => t.id === selectedTaskId);
|
|
|
|
return (
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
{/* Mode Switcher */}
|
|
<div className="flex items-center justify-center">
|
|
<div className="flex items-center p-1.5 rounded-2xl bg-slate-900 border border-slate-800 shadow-xl">
|
|
{(['focus', 'shortBreak', 'longBreak'] as const).map((m) => {
|
|
const isSelected = mode === m;
|
|
const Icon = MODE_CONFIG[m].icon;
|
|
|
|
return (
|
|
<button
|
|
key={m}
|
|
onClick={() => switchMode(m)}
|
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
|
isSelected
|
|
? MODE_CONFIG[m].color
|
|
: 'text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Icon className="w-4 h-4" />
|
|
<span>{MODE_CONFIG[m].label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center Circular Countdown Card */}
|
|
<div className="glass-card p-8 md:p-12 flex flex-col items-center justify-center relative overflow-hidden border-slate-800/80 shadow-2xl">
|
|
{/* Ambient background glow */}
|
|
<div
|
|
className="absolute w-72 h-72 rounded-full blur-[100px] opacity-20 pointer-events-none transition-all duration-700"
|
|
style={{ backgroundColor: MODE_CONFIG[mode].ringColor }}
|
|
/>
|
|
|
|
{/* Circular SVG Timer */}
|
|
<div className="relative flex items-center justify-center">
|
|
<svg className="w-72 h-72 md:w-80 md:h-80 -rotate-90 transform">
|
|
{/* Background Track */}
|
|
<circle
|
|
cx="50%"
|
|
cy="50%"
|
|
r={radius}
|
|
className="text-slate-800/60 stroke-current"
|
|
strokeWidth="10"
|
|
fill="transparent"
|
|
/>
|
|
{/* Animated Progress Ring */}
|
|
<circle
|
|
cx="50%"
|
|
cy="50%"
|
|
r={radius}
|
|
stroke={MODE_CONFIG[mode].ringColor}
|
|
strokeWidth="12"
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={strokeDashoffset}
|
|
strokeLinecap="round"
|
|
fill="transparent"
|
|
className="transition-all duration-500"
|
|
style={{
|
|
filter: `drop-shadow(0 0 10px ${MODE_CONFIG[mode].ringColor}80)`,
|
|
}}
|
|
/>
|
|
</svg>
|
|
|
|
{/* Time Display Inside Circle */}
|
|
<div className="absolute flex flex-col items-center justify-center text-center">
|
|
<span className="font-mono text-5xl md:text-6xl font-extrabold text-white tracking-tighter">
|
|
{timeFormatted}
|
|
</span>
|
|
<span className="text-xs uppercase font-bold tracking-widest text-slate-400 mt-2">
|
|
{isActive ? 'Session In Progress' : 'Paused'}
|
|
</span>
|
|
{currentTask && (
|
|
<span className="mt-3 px-3 py-1 rounded-full bg-slate-900 border border-slate-800 text-[11px] text-cyan-400 font-medium max-w-[200px] truncate">
|
|
🎯 {currentTask.title}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Control Buttons */}
|
|
<div className="mt-8 flex items-center gap-4 z-10">
|
|
<button
|
|
onClick={handleReset}
|
|
className="p-3.5 rounded-2xl bg-slate-900/80 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800 transition-all cursor-pointer shadow-md"
|
|
title="Reset timer"
|
|
>
|
|
<RotateCcw className="w-5 h-5" />
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleToggleTimer}
|
|
className={`flex items-center gap-2.5 px-8 py-4 rounded-2xl text-slate-950 font-extrabold text-base shadow-xl transition-all hover:scale-105 active:scale-95 cursor-pointer`}
|
|
style={{
|
|
backgroundColor: MODE_CONFIG[mode].ringColor,
|
|
boxShadow: `0 10px 25px -5px ${MODE_CONFIG[mode].ringColor}60`,
|
|
}}
|
|
>
|
|
{isActive ? (
|
|
<>
|
|
<Pause className="w-5 h-5 fill-slate-950" />
|
|
<span>Pause</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="w-5 h-5 fill-slate-950" />
|
|
<span>Start Focus</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
onClick={handleSkip}
|
|
className="p-3.5 rounded-2xl bg-slate-900/80 hover:bg-slate-800 text-slate-400 hover:text-white border border-slate-800 transition-all cursor-pointer shadow-md"
|
|
title="Skip to next session"
|
|
>
|
|
<SkipForward className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Focus Session Target & Task Pairing */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{/* Daily Focus Sessions Card */}
|
|
<div className="glass-card p-5 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-3 rounded-xl bg-amber-500/10 text-amber-400">
|
|
<Flame className="w-6 h-6 fill-amber-400/20" />
|
|
</div>
|
|
<div>
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider">
|
|
Sessions Completed
|
|
</span>
|
|
<div className="text-2xl font-extrabold text-white mt-0.5">
|
|
{completedSessions} / 6 Sessions
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<span className="text-xs font-bold text-amber-400">{completedSessions * 25} Mins</span>
|
|
<p className="text-[11px] text-slate-400">Deep Work Today</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Task Attachment Picker */}
|
|
<div className="glass-card p-5 flex flex-col justify-between">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-1.5">
|
|
<Sparkles className="w-3.5 h-3.5 text-cyan-400" />
|
|
Pair with Task
|
|
</span>
|
|
{selectedTaskId && (
|
|
<button
|
|
onClick={() => setSelectedTaskId('')}
|
|
className="text-[11px] text-slate-500 hover:text-slate-300"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
<select
|
|
value={selectedTaskId}
|
|
onChange={(e) => setSelectedTaskId(e.target.value)}
|
|
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-slate-800 text-xs text-slate-200 focus:outline-none focus:border-amber-500"
|
|
>
|
|
<option value="">No task linked (Freestyle Focus)</option>
|
|
{tasks
|
|
.filter((t) => t.status !== 'completed')
|
|
.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
[{t.priority.toUpperCase()}] {t.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|