feat: add draggable stopwatch widget with lap counter and countdown timer

New general-purpose floating stopwatch accessible from a clock icon in
the nav bar. Supports two modes: stopwatch (count up with laps) and
countdown timer (presets + custom input, red flash on expiry). Desktop
renders as a compact draggable card; mobile docks to the bottom as a
fixed sheet. Timer persists while hidden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cottongin
2026-05-07 20:44:18 -04:00
parent 10c34557c5
commit 4be520476c
2 changed files with 376 additions and 1 deletions

View File

@@ -5,6 +5,7 @@ import { ToastProvider } from './components/Toast';
import { branding } from './config/branding'; import { branding } from './config/branding';
import Logo from './components/Logo'; import Logo from './components/Logo';
import ThemeToggle from './components/ThemeToggle'; import ThemeToggle from './components/ThemeToggle';
import StopwatchWidget from './components/StopwatchWidget';
import InstallPrompt from './components/InstallPrompt'; import InstallPrompt from './components/InstallPrompt';
import SafariInstallPrompt from './components/SafariInstallPrompt'; import SafariInstallPrompt from './components/SafariInstallPrompt';
import PresenceBar from './components/PresenceBar'; import PresenceBar from './components/PresenceBar';
@@ -18,6 +19,7 @@ import SessionDetail from './pages/SessionDetail';
function App() { function App() {
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [stopwatchVisible, setStopwatchVisible] = useState(false);
const closeMobileMenu = () => setMobileMenuOpen(false); const closeMobileMenu = () => setMobileMenuOpen(false);
@@ -72,12 +74,37 @@ function App() {
</Link> </Link>
)} )}
{/* Stopwatch Toggle */}
<button
onClick={() => setStopwatchVisible(v => !v)}
title="Stopwatch"
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<circle cx="12" cy="13" r="8" />
<path strokeLinecap="round" d="M12 9v4l2 2" />
<path strokeLinecap="round" d="M12 5V3" />
<path strokeLinecap="round" d="M10 3h4" />
</svg>
</button>
{/* Theme Toggle */} {/* Theme Toggle */}
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* Mobile: Theme Toggle and Hamburger */} {/* Mobile: Theme Toggle, Stopwatch, and Hamburger */}
<div className="flex sm:hidden items-center gap-2"> <div className="flex sm:hidden items-center gap-2">
<button
onClick={() => setStopwatchVisible(v => !v)}
title="Stopwatch"
className="p-1.5 hover:bg-indigo-700 dark:hover:bg-indigo-900 rounded transition"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<circle cx="12" cy="13" r="8" />
<path strokeLinecap="round" d="M12 9v4l2 2" />
<path strokeLinecap="round" d="M12 5V3" />
<path strokeLinecap="round" d="M10 3h4" />
</svg>
</button>
<ThemeToggle /> <ThemeToggle />
<button <button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)} onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
@@ -189,6 +216,9 @@ function App() {
{/* PWA Install Prompts */} {/* PWA Install Prompts */}
<InstallPrompt /> <InstallPrompt />
<SafariInstallPrompt /> <SafariInstallPrompt />
{/* Stopwatch Widget */}
<StopwatchWidget visible={stopwatchVisible} onHide={() => setStopwatchVisible(false)} />
</div> </div>
</ToastProvider> </ToastProvider>
); );

View File

@@ -0,0 +1,345 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const handler = (e) => setMatches(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [query]);
return matches;
}
function formatTime(ms, showCentiseconds = true) {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
if (!showCentiseconds) {
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
const cs = Math.floor((ms % 1000) / 10);
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}:${String(cs).padStart(2, '0')}`;
}
const COUNTDOWN_PRESETS = [
{ label: '1:00', ms: 60000 },
{ label: '2:00', ms: 120000 },
{ label: '3:00', ms: 180000 },
{ label: '5:00', ms: 300000 },
];
export default function StopwatchWidget({ visible, onHide }) {
const isDesktop = useMediaQuery('(min-width: 640px)');
// Mode: 'stopwatch' | 'timer'
const [mode, setMode] = useState('stopwatch');
// Stopwatch state
const [swRunning, setSwRunning] = useState(false);
const [swElapsed, setSwElapsed] = useState(0);
const [laps, setLaps] = useState([]);
const swIntervalRef = useRef(null);
const swStartRef = useRef(0);
// Countdown state
const [cdTarget, setCdTarget] = useState(60000);
const [cdRemaining, setCdRemaining] = useState(60000);
const [cdRunning, setCdRunning] = useState(false);
const [cdExpired, setCdExpired] = useState(false);
const [cdInputMin, setCdInputMin] = useState('1');
const [cdInputSec, setCdInputSec] = useState('00');
const cdIntervalRef = useRef(null);
const cdStartRef = useRef(0);
const cdRemainingAtStartRef = useRef(0);
// Drag state (desktop only)
const [position, setPosition] = useState({ x: window.innerWidth - 300, y: 80 });
const [isDragging, setIsDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const cardRef = useRef(null);
// Stopwatch logic
useEffect(() => {
if (swRunning) {
swStartRef.current = Date.now() - swElapsed;
swIntervalRef.current = setInterval(() => {
setSwElapsed(Date.now() - swStartRef.current);
}, 10);
} else {
clearInterval(swIntervalRef.current);
}
return () => clearInterval(swIntervalRef.current);
}, [swRunning]);
const handleSwStartStop = () => setSwRunning(r => !r);
const handleSwReset = () => {
setSwRunning(false);
setSwElapsed(0);
setLaps([]);
};
const handleSwLap = () => {
setLaps(prev => [...prev, swElapsed]);
};
// Countdown logic
useEffect(() => {
if (cdRunning) {
cdStartRef.current = Date.now();
cdRemainingAtStartRef.current = cdRemaining;
cdIntervalRef.current = setInterval(() => {
const elapsed = Date.now() - cdStartRef.current;
const remaining = cdRemainingAtStartRef.current - elapsed;
if (remaining <= 0) {
setCdRemaining(0);
setCdRunning(false);
setCdExpired(true);
clearInterval(cdIntervalRef.current);
} else {
setCdRemaining(remaining);
}
}, 10);
} else {
clearInterval(cdIntervalRef.current);
}
return () => clearInterval(cdIntervalRef.current);
}, [cdRunning]);
const handleCdStartPause = () => {
if (cdExpired) return;
setCdRunning(r => !r);
};
const handleCdReset = () => {
setCdRunning(false);
setCdRemaining(cdTarget);
setCdExpired(false);
};
const handleCdPreset = (ms) => {
setCdRunning(false);
setCdTarget(ms);
setCdRemaining(ms);
setCdExpired(false);
setCdInputMin(String(Math.floor(ms / 60000)));
setCdInputSec(String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'));
};
const handleCdCustomSet = () => {
const min = parseInt(cdInputMin) || 0;
const sec = parseInt(cdInputSec) || 0;
const ms = (min * 60 + sec) * 1000;
if (ms > 0) {
setCdTarget(ms);
setCdRemaining(ms);
setCdRunning(false);
setCdExpired(false);
}
};
// Expired flash effect
useEffect(() => {
if (cdExpired) {
const timeout = setTimeout(() => setCdExpired(false), 3000);
return () => clearTimeout(timeout);
}
}, [cdExpired]);
// Drag handlers (desktop)
const handleDragStart = useCallback((e) => {
if (!isDesktop) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
dragOffset.current = { x: clientX - position.x, y: clientY - position.y };
setIsDragging(true);
}, [isDesktop, position]);
useEffect(() => {
if (!isDragging) return;
const handleMove = (e) => {
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
setPosition({
x: Math.max(0, Math.min(window.innerWidth - 280, clientX - dragOffset.current.x)),
y: Math.max(0, Math.min(window.innerHeight - 100, clientY - dragOffset.current.y)),
});
};
const handleUp = () => setIsDragging(false);
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleUp);
document.addEventListener('touchmove', handleMove);
document.addEventListener('touchend', handleUp);
return () => {
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleUp);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleUp);
};
}, [isDragging]);
if (!visible) return null;
const cardClasses = isDesktop
? 'fixed z-50 w-[280px] rounded-xl shadow-2xl'
: 'fixed z-50 bottom-0 left-0 right-0 rounded-t-xl shadow-2xl';
const cardStyle = isDesktop ? { left: position.x, top: position.y } : {};
const borderColor = cdExpired
? 'border-red-500 animate-pulse'
: 'border-gray-700 dark:border-gray-600';
const content = (
<div
ref={cardRef}
className={`${cardClasses} bg-gray-900 dark:bg-gray-950 border-2 ${borderColor} text-white select-none`}
style={cardStyle}
>
{/* Drag handle / header */}
<div
className={`flex items-center justify-between px-3 py-2 ${isDesktop ? 'cursor-grab active:cursor-grabbing' : ''}`}
onMouseDown={handleDragStart}
onTouchStart={handleDragStart}
>
<div className="flex gap-1">
<button
onClick={() => setMode('stopwatch')}
className={`px-2 py-1 text-xs rounded transition ${mode === 'stopwatch' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
>
Stopwatch
</button>
<button
onClick={() => setMode('timer')}
className={`px-2 py-1 text-xs rounded transition ${mode === 'timer' ? 'bg-indigo-600 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
>
Timer
</button>
</div>
<button
onClick={onHide}
className="w-7 h-7 sm:w-6 sm:h-6 flex items-center justify-center text-gray-400 hover:text-white hover:bg-gray-700 rounded transition"
title="Hide"
>
</button>
</div>
{/* Display */}
<div className="px-3 pb-2">
<div className="bg-black/40 rounded-lg px-4 py-3 sm:py-2 text-center mb-3">
<span
className={`font-mono tracking-wider ${isDesktop ? 'text-2xl' : 'text-3xl'} ${mode === 'timer' && cdRemaining < 10000 && cdRunning ? 'text-red-400' : 'text-green-400'}`}
style={{ fontFamily: "'Courier New', monospace" }}
>
{mode === 'stopwatch'
? formatTime(swElapsed)
: formatTime(cdRemaining, false)
}
</span>
</div>
{/* Controls */}
{mode === 'stopwatch' ? (
<div className="flex gap-2 mb-2">
<button
onClick={handleSwStartStop}
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition ${swRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-green-600 hover:bg-green-700'}`}
>
{swRunning ? 'Stop' : 'Start'}
</button>
{swRunning && (
<button
onClick={handleSwLap}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-blue-600 hover:bg-blue-700 transition"
>
Lap
</button>
)}
<button
onClick={handleSwReset}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
>
Reset
</button>
</div>
) : (
<>
<div className="flex gap-2 mb-2">
<button
onClick={handleCdStartPause}
disabled={cdExpired || cdRemaining <= 0}
className={`flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold transition disabled:opacity-50 ${cdRunning ? 'bg-yellow-600 hover:bg-yellow-700' : 'bg-green-600 hover:bg-green-700'}`}
>
{cdRunning ? 'Pause' : 'Start'}
</button>
<button
onClick={handleCdReset}
className="flex-1 py-2 sm:py-1.5 rounded text-sm font-semibold bg-gray-600 hover:bg-gray-700 transition"
>
Reset
</button>
</div>
{!cdRunning && (
<>
<div className="flex gap-1 mb-2">
{COUNTDOWN_PRESETS.map(p => (
<button
key={p.ms}
onClick={() => handleCdPreset(p.ms)}
className={`flex-1 py-1 rounded text-xs font-medium transition ${cdTarget === p.ms ? 'bg-indigo-600' : 'bg-gray-700 hover:bg-gray-600'}`}
>
{p.label}
</button>
))}
</div>
<div className="flex items-center gap-1 text-xs">
<input
type="number"
min="0"
max="99"
value={cdInputMin}
onChange={(e) => setCdInputMin(e.target.value)}
className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white"
/>
<span className="text-gray-400">m</span>
<input
type="number"
min="0"
max="59"
value={cdInputSec}
onChange={(e) => setCdInputSec(e.target.value)}
className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white"
/>
<span className="text-gray-400">s</span>
<button
onClick={handleCdCustomSet}
className="ml-1 px-2 py-0.5 bg-indigo-600 hover:bg-indigo-700 rounded text-xs font-medium transition"
>
Set
</button>
</div>
</>
)}
</>
)}
{/* Lap list (stopwatch mode) */}
{mode === 'stopwatch' && laps.length > 0 && (
<div className={`mt-2 border-t border-gray-700 pt-2 overflow-y-auto ${isDesktop ? 'max-h-32' : 'max-h-24'}`}>
{laps.map((lapTime, i) => {
const prev = i === 0 ? 0 : laps[i - 1];
const delta = lapTime - prev;
return (
<div key={i} className="flex justify-between text-xs text-gray-300 py-0.5">
<span className="text-gray-500">#{i + 1}</span>
<span className="font-mono">{formatTime(delta)}</span>
<span className="font-mono text-gray-500">{formatTime(lapTime)}</span>
</div>
);
})}
</div>
)}
</div>
</div>
);
return createPortal(content, document.body);
}