feat(timer): add subtle pomodoro timer to header
- Added PomodoroTimer component with work/break modes - Timer appears as icon in header, expands to full widget on click - Supports 25m work, 5m short break, 15m long break cycles - Shows progress bar, cycle dots, and mode switcher - Plays sound when timer completes - Can be minimized while running (shows pulsing indicator)
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { useAuth } from '@/components/auth/AuthProvider';
|
import { useAuth } from '@/components/auth/AuthProvider';
|
||||||
import { useTheme } from '@/components/theme/ThemeProvider';
|
import { useTheme } from '@/components/theme/ThemeProvider';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import PomodoroTimer from '@/components/timer/PomodoroTimer';
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
@@ -38,6 +39,7 @@ export default function DashboardLayout({
|
|||||||
const { isDark, toggleDark } = useTheme();
|
const { isDark, toggleDark } = useTheme();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [isTimerOpen, setIsTimerOpen] = useState(false);
|
||||||
|
|
||||||
const tzSynced = useRef(false);
|
const tzSynced = useRef(false);
|
||||||
|
|
||||||
@@ -89,6 +91,12 @@ export default function DashboardLayout({
|
|||||||
<span className="font-bold text-gray-900 dark:text-gray-100">Synculous</span>
|
<span className="font-bold text-gray-900 dark:text-gray-100">Synculous</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<PomodoroTimer
|
||||||
|
isExpanded={isTimerOpen}
|
||||||
|
onToggle={() => setIsTimerOpen(!isTimerOpen)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={toggleDark}
|
onClick={toggleDark}
|
||||||
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
|||||||
255
synculous-client/src/components/timer/PomodoroTimer.tsx
Normal file
255
synculous-client/src/components/timer/PomodoroTimer.tsx
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { TimerIcon, PlayIcon, PauseIcon, RotateCcwIcon, XIcon } from '@/components/ui/Icons';
|
||||||
|
import { playTimerEnd } from '@/lib/sounds';
|
||||||
|
|
||||||
|
interface PomodoroTimerProps {
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORK_MINUTES = 25;
|
||||||
|
const SHORT_BREAK_MINUTES = 5;
|
||||||
|
const LONG_BREAK_MINUTES = 15;
|
||||||
|
|
||||||
|
export default function PomodoroTimer({ isExpanded, onToggle }: PomodoroTimerProps) {
|
||||||
|
const [mode, setMode] = useState<'work' | 'shortBreak' | 'longBreak'>('work');
|
||||||
|
const [timeLeft, setTimeLeft] = useState(WORK_MINUTES * 60);
|
||||||
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
|
const [cycles, setCycles] = useState(0);
|
||||||
|
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
const getDuration = useCallback(() => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'work': return WORK_MINUTES * 60;
|
||||||
|
case 'shortBreak': return SHORT_BREAK_MINUTES * 60;
|
||||||
|
case 'longBreak': return LONG_BREAK_MINUTES * 60;
|
||||||
|
}
|
||||||
|
}, [mode]);
|
||||||
|
|
||||||
|
const formatTime = (seconds: number) => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProgress = () => {
|
||||||
|
const duration = getDuration();
|
||||||
|
return ((duration - timeLeft) / duration) * 100;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRunning && timeLeft > 0) {
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setTimeLeft(prev => {
|
||||||
|
if (prev <= 1) {
|
||||||
|
// Timer finished
|
||||||
|
playTimerEnd();
|
||||||
|
setIsRunning(false);
|
||||||
|
|
||||||
|
if (mode === 'work') {
|
||||||
|
const newCycles = cycles + 1;
|
||||||
|
setCycles(newCycles);
|
||||||
|
// After 4 work sessions, take a long break
|
||||||
|
if (newCycles % 4 === 0) {
|
||||||
|
setMode('longBreak');
|
||||||
|
return LONG_BREAK_MINUTES * 60;
|
||||||
|
} else {
|
||||||
|
setMode('shortBreak');
|
||||||
|
return SHORT_BREAK_MINUTES * 60;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Break is over, back to work
|
||||||
|
setMode('work');
|
||||||
|
return WORK_MINUTES * 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [isRunning, timeLeft, mode, cycles]);
|
||||||
|
|
||||||
|
const toggleTimer = () => setIsRunning(!isRunning);
|
||||||
|
|
||||||
|
const resetTimer = () => {
|
||||||
|
setIsRunning(false);
|
||||||
|
setTimeLeft(getDuration());
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipToNext = () => {
|
||||||
|
setIsRunning(false);
|
||||||
|
if (mode === 'work') {
|
||||||
|
const newCycles = cycles + 1;
|
||||||
|
setCycles(newCycles);
|
||||||
|
if (newCycles % 4 === 0) {
|
||||||
|
setMode('longBreak');
|
||||||
|
setTimeLeft(LONG_BREAK_MINUTES * 60);
|
||||||
|
} else {
|
||||||
|
setMode('shortBreak');
|
||||||
|
setTimeLeft(SHORT_BREAK_MINUTES * 60);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setMode('work');
|
||||||
|
setTimeLeft(WORK_MINUTES * 60);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeLabel = () => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'work': return 'Focus';
|
||||||
|
case 'shortBreak': return 'Short Break';
|
||||||
|
case 'longBreak': return 'Long Break';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getModeColor = () => {
|
||||||
|
switch (mode) {
|
||||||
|
case 'work': return 'text-indigo-600 dark:text-indigo-400';
|
||||||
|
case 'shortBreak': return 'text-green-600 dark:text-green-400';
|
||||||
|
case 'longBreak': return 'text-blue-600 dark:text-blue-400';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compact view (icon only)
|
||||||
|
if (!isExpanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="relative p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||||||
|
aria-label="Open timer"
|
||||||
|
>
|
||||||
|
<TimerIcon size={20} />
|
||||||
|
{isRunning && (
|
||||||
|
<span className="absolute top-1 right-1 w-2 h-2 bg-indigo-500 rounded-full animate-pulse" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-72 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-4 z-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<TimerIcon className={getModeColor()} size={18} />
|
||||||
|
<span className={`font-medium ${getModeColor()}`}>{getModeLabel()}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
aria-label="Close timer"
|
||||||
|
>
|
||||||
|
<XIcon size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer Display */}
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<div className="text-4xl font-mono font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||||
|
{formatTime(timeLeft)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-1000 ${
|
||||||
|
mode === 'work' ? 'bg-indigo-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${getProgress()}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cycles indicator */}
|
||||||
|
<div className="flex justify-center gap-1 mb-4">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`w-2 h-2 rounded-full ${
|
||||||
|
i < (cycles % 4) ? 'bg-indigo-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={resetTimer}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||||
|
aria-label="Reset timer"
|
||||||
|
>
|
||||||
|
<RotateCcwIcon size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={toggleTimer}
|
||||||
|
className={`p-3 rounded-full ${
|
||||||
|
isRunning
|
||||||
|
? 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400'
|
||||||
|
: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/30 dark:text-indigo-400'
|
||||||
|
}`}
|
||||||
|
aria-label={isRunning ? 'Pause timer' : 'Start timer'}
|
||||||
|
>
|
||||||
|
{isRunning ? <PauseIcon size={20} /> : <PlayIcon size={20} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={skipToNext}
|
||||||
|
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 text-sm font-medium"
|
||||||
|
aria-label="Skip to next"
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode switcher */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('work'); setTimeLeft(WORK_MINUTES * 60); setIsRunning(false); }}
|
||||||
|
className={`flex-1 py-1 px-2 text-xs rounded ${
|
||||||
|
mode === 'work'
|
||||||
|
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Work (25m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('shortBreak'); setTimeLeft(SHORT_BREAK_MINUTES * 60); setIsRunning(false); }}
|
||||||
|
className={`flex-1 py-1 px-2 text-xs rounded ${
|
||||||
|
mode === 'shortBreak'
|
||||||
|
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Short (5m)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setMode('longBreak'); setTimeLeft(LONG_BREAK_MINUTES * 60); setIsRunning(false); }}
|
||||||
|
className={`flex-1 py-1 px-2 text-xs rounded ${
|
||||||
|
mode === 'longBreak'
|
||||||
|
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||||
|
: 'text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Long (15m)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user