From 4be520476c68f161afadd649cd84cb6ec99dc29a Mon Sep 17 00:00:00 2001 From: cottongin Date: Thu, 7 May 2026 20:44:18 -0400 Subject: [PATCH] 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 --- frontend/src/App.jsx | 32 +- frontend/src/components/StopwatchWidget.jsx | 345 ++++++++++++++++++++ 2 files changed, 376 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/StopwatchWidget.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2243ec8..61dff4e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,7 @@ import { ToastProvider } from './components/Toast'; import { branding } from './config/branding'; import Logo from './components/Logo'; import ThemeToggle from './components/ThemeToggle'; +import StopwatchWidget from './components/StopwatchWidget'; import InstallPrompt from './components/InstallPrompt'; import SafariInstallPrompt from './components/SafariInstallPrompt'; import PresenceBar from './components/PresenceBar'; @@ -18,6 +19,7 @@ import SessionDetail from './pages/SessionDetail'; function App() { const { isAuthenticated, logout } = useAuth(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [stopwatchVisible, setStopwatchVisible] = useState(false); const closeMobileMenu = () => setMobileMenuOpen(false); @@ -72,12 +74,37 @@ function App() { )} + {/* Stopwatch Toggle */} + {/* Theme Toggle */} - {/* Mobile: Theme Toggle and Hamburger */} + {/* Mobile: Theme Toggle, Stopwatch, and Hamburger */}
+
); diff --git a/frontend/src/components/StopwatchWidget.jsx b/frontend/src/components/StopwatchWidget.jsx new file mode 100644 index 0000000..cf07c95 --- /dev/null +++ b/frontend/src/components/StopwatchWidget.jsx @@ -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 = ( +
+ {/* Drag handle / header */} +
+
+ + +
+ +
+ + {/* Display */} +
+
+ + {mode === 'stopwatch' + ? formatTime(swElapsed) + : formatTime(cdRemaining, false) + } + +
+ + {/* Controls */} + {mode === 'stopwatch' ? ( +
+ + {swRunning && ( + + )} + +
+ ) : ( + <> +
+ + +
+ {!cdRunning && ( + <> +
+ {COUNTDOWN_PRESETS.map(p => ( + + ))} +
+
+ setCdInputMin(e.target.value)} + className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" + /> + m + setCdInputSec(e.target.value)} + className="w-10 bg-gray-800 border border-gray-600 rounded px-1 py-0.5 text-center text-white" + /> + s + +
+ + )} + + )} + + {/* Lap list (stopwatch mode) */} + {mode === 'stopwatch' && laps.length > 0 && ( +
+ {laps.map((lapTime, i) => { + const prev = i === 0 ? 0 : laps[i - 1]; + const delta = lapTime - prev; + return ( +
+ #{i + 1} + {formatTime(delta)} + {formatTime(lapTime)} +
+ ); + })} +
+ )} +
+
+ ); + + return createPortal(content, document.body); +}