diff --git a/client/src/lib/time-tools.ts b/client/src/lib/time-tools.ts index 8d7a11b5..3a279677 100644 --- a/client/src/lib/time-tools.ts +++ b/client/src/lib/time-tools.ts @@ -363,3 +363,26 @@ export function playTimerBeep(audioContext: AudioContext | null): void { oscillator.start(audioContext.currentTime); oscillator.stop(audioContext.currentTime + 0.5); } + +// Play a lighter milestone beep (for quartile notifications) +export function playMilestoneBeep(audioContext: AudioContext | null): void { + if (!audioContext) return; + + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.setValueAtTime(600, audioContext.currentTime); + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0.12, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.2 + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.2); +} diff --git a/client/src/pages/tools/timer.tsx b/client/src/pages/tools/timer.tsx index 75130d96..47342698 100644 --- a/client/src/pages/tools/timer.tsx +++ b/client/src/pages/tools/timer.tsx @@ -23,11 +23,16 @@ import { X, Timer as TimerIcon, VolumeX, + Expand, } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { useTheme } from "@/providers/theme-provider"; +import { PieChart, Pie, Cell } from "recharts"; import { formatTimerTime, createTimerSound, playTimerBeep, + playMilestoneBeep, } from "@/lib/time-tools"; import { getParam, updateURL, copyShareableURL } from "@/lib/url-sharing"; import { useToast } from "@/hooks/use-toast"; @@ -50,6 +55,11 @@ interface TimerInstance { isFinished: boolean; alarmCount: number; // 1, 2, 3, or -1 for manual stop currentAlarmPlays: number; + overrunEnabled: boolean; // keep counting negative after timer ends + leadTime: number; // seconds to count down before timer starts + leadTimeRemaining: number; // current lead time countdown + beepAtQuartiles: boolean; // beep at 25%, 50%, 75% milestones + quartilesBeeped: number[]; // which % milestones have beeped [25, 50, 75] } export default function Timer() { @@ -60,21 +70,71 @@ export default function Timer() { const [newTimerSeconds, setNewTimerSeconds] = useState(0); const [newTimerName, setNewTimerName] = useState(""); const [newTimerAlarmCount, setNewTimerAlarmCount] = useState(3); + const [newTimerOverrunEnabled, setNewTimerOverrunEnabled] = useState(false); + const [newTimerLeadTime, setNewTimerLeadTime] = useState(5); + const [newTimerBeepAtQuartiles, setNewTimerBeepAtQuartiles] = useState(true); const [timers, setTimers] = useState([]); const [showAddTimer, setShowAddTimer] = useState(false); + const [fullscreenTimerId, setFullscreenTimerId] = useState( + null + ); // Track which timers were paused by the global pause so we can resume only those const [pausedByGlobal, setPausedByGlobal] = useState>(new Set()); + // Track which timers are currently flashing red (milestone or finish) + const [flashingTimers, setFlashingTimers] = useState>(new Set()); const intervalRef = useRef(null); const audioContextRef = useRef(null); const alarmIntervalsRef = useRef>(new Map()); + const flashClearTimeoutsRef = useRef>(new Map()); + const prevTimerStateRef = useRef< + Map + >(new Map()); const { toast } = useToast(); + const { theme } = useTheme(); const { isLocked, request: requestWakeLock, release: releaseWakeLock, } = useWakeLock(); + const toggleFullscreen = (id: string) => { + if (fullscreenTimerId === id) { + setFullscreenTimerId(null); + if (document.fullscreenElement) { + void document.exitFullscreen(); + } + } else { + setFullscreenTimerId(id); + } + }; + + useEffect(() => { + if (fullscreenTimerId) { + const element = document.getElementById( + `timer-card-${fullscreenTimerId}` + ); + if (element && !document.fullscreenElement) { + void element.requestFullscreen().catch(err => { + console.error("Fullscreen request failed:", err); + setFullscreenTimerId(null); // Reset on failure + }); + } + } else if (document.fullscreenElement) { + void document.exitFullscreen(); + } + + const handleFullscreenChange = () => { + if (!document.fullscreenElement) { + setFullscreenTimerId(null); + } + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => + document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, [fullscreenTimerId]); + // Effect to manage the wake lock useEffect(() => { const hasRunningTimers = timers.some(timer => timer.isRunning); @@ -194,6 +254,11 @@ export default function Timer() { isFinished: false, alarmCount: newTimerAlarmCount, currentAlarmPlays: 0, + overrunEnabled: newTimerOverrunEnabled, + leadTime: newTimerLeadTime, + leadTimeRemaining: newTimerLeadTime, + beepAtQuartiles: newTimerBeepAtQuartiles, + quartilesBeeped: [], }; setTimers(prev => [...prev, newTimer]); @@ -204,14 +269,21 @@ export default function Timer() { setNewTimerMinutes(5); setNewTimerSeconds(0); setNewTimerAlarmCount(3); + setNewTimerOverrunEnabled(false); + setNewTimerLeadTime(5); + setNewTimerBeepAtQuartiles(true); setShowAddTimer(false); - // Update URL with latest timer + // Update URL with latest timer settings updateURL({ h: newTimerHours, m: newTimerMinutes, s: newTimerSeconds, name, + alarm: newTimerAlarmCount, + overrun: newTimerOverrunEnabled, + lead: newTimerLeadTime, + beep: newTimerBeepAtQuartiles, }); toast({ @@ -224,6 +296,9 @@ export default function Timer() { newTimerSeconds, newTimerName, newTimerAlarmCount, + newTimerOverrunEnabled, + newTimerLeadTime, + newTimerBeepAtQuartiles, toast, ]); @@ -232,33 +307,55 @@ export default function Timer() { const target = timers.find(t => t.id === id); const wasRunning = target?.isRunning ?? false; const wasFinished = target?.isFinished ?? false; - - setTimers(prev => - prev.map(timer => - timer.id === id - ? { ...timer, isRunning: !timer.isRunning, isFinished: false } - : timer - ) - ); - - if (wasFinished) { + const hasOverrun = target?.overrunEnabled ?? false; + + if (wasFinished && !hasOverrun) { + // Non-overrun finished timer: restart from the beginning + const handle = alarmIntervalsRef.current.get(id); + if (handle) { + clearAlarmHandle(handle); + alarmIntervalsRef.current.delete(id); + } + setTimers(prev => + prev.map(timer => + timer.id === id + ? { + ...timer, + isRunning: true, + isFinished: false, + timeLeft: timer.duration, + leadTimeRemaining: timer.leadTime, + quartilesBeeped: [], + currentAlarmPlays: 0, + } + : timer + ) + ); toast({ title: "Timer Restarted", description: `${target?.name ?? "Timer"} restarted.`, }); - } else if (wasRunning) { - toast({ - title: "Timer Paused", - description: `${target?.name ?? "Timer"} paused.`, - }); } else { - toast({ - title: "Timer Started", - description: `${target?.name ?? "Timer"} started.`, - }); + // Normal toggle (running ↔ paused), or overrun timer pause/continue + setTimers(prev => + prev.map(timer => + timer.id === id ? { ...timer, isRunning: !timer.isRunning } : timer + ) + ); + if (wasRunning) { + toast({ + title: "Timer Paused", + description: `${target?.name ?? "Timer"} paused.`, + }); + } else { + toast({ + title: wasFinished ? "Timer Continued" : "Timer Started", + description: `${target?.name ?? "Timer"} ${wasFinished ? "continued" : "started"}.`, + }); + } } }, - [timers, toast] + [timers, toast, clearAlarmHandle] ); const stopAllTimers = useCallback(() => { @@ -271,7 +368,10 @@ export default function Timer() { ...timer, isRunning: false, isFinished: false, + // Reset negative time from overrun + timeLeft: timer.timeLeft < 0 ? timer.duration : timer.timeLeft, currentAlarmPlays: 0, + quartilesBeeped: [], })) ); // Clear any global paused tracking @@ -307,7 +407,8 @@ export default function Timer() { const continueAllTimers = useCallback(() => { setTimers(prev => prev.map(timer => - pausedByGlobal.has(timer.id) && !timer.isFinished + pausedByGlobal.has(timer.id) && + (!timer.isFinished || timer.overrunEnabled) ? { ...timer, isRunning: true } : timer ) @@ -358,6 +459,8 @@ export default function Timer() { isFinished: false, timeLeft: timer.duration, currentAlarmPlays: 0, + leadTimeRemaining: timer.leadTime, + quartilesBeeped: [], })) ); @@ -373,14 +476,19 @@ export default function Timer() { useEffect(() => { audioContextRef.current = createTimerSound(); - // Capture ref value at effect start + // Capture ref values at effect start const intervals = alarmIntervalsRef.current; + const flashTimeouts = flashClearTimeoutsRef.current; // Load from URL parameters or create default timer const urlHours = getParam("h", 0); const urlMinutes = getParam("m", 5); const urlSeconds = getParam("s", 0); const urlName = getParam("name", ""); + const urlOverrun = getParam("overrun", false); + const urlLead = getParam("lead", 0); + const urlBeep = getParam("beep", true); + const urlAlarm = getParam("alarm", 3); if (urlHours > 0 || urlMinutes > 0 || urlSeconds > 0) { const duration = urlHours * 3600 + urlMinutes * 60 + urlSeconds; @@ -393,8 +501,13 @@ export default function Timer() { timeLeft: duration, isRunning: false, // Don't auto-start per STYLE.md for tools that make sound isFinished: false, - alarmCount: 3, + alarmCount: urlAlarm, currentAlarmPlays: 0, + overrunEnabled: urlOverrun, + leadTime: urlLead, + leadTimeRemaining: urlLead, + beepAtQuartiles: urlBeep, + quartilesBeeped: [], }; setTimers([defaultTimer]); } @@ -406,30 +519,131 @@ export default function Timer() { // Clear all alarm intervals using captured ref value intervals.forEach(interval => clearAlarmHandle(interval)); intervals.clear(); + // Clear all flash timeouts using captured ref value + flashTimeouts.forEach(t => clearTimeout(t as unknown as number)); + flashTimeouts.clear(); }; }, [clearAlarmHandle]); + // Detect milestone/finish transitions and trigger a 1-second red flash + useEffect(() => { + timers.forEach(timer => { + const prev = prevTimerStateRef.current.get(timer.id); + const prevLen = prev?.quartileLength ?? 0; + const prevFinished = prev?.isFinished ?? false; + + const justHitMilestone = timer.quartilesBeeped.length > prevLen; + const justFinished = !prevFinished && timer.isFinished; + + if (justHitMilestone || justFinished) { + // Cancel any in-progress flash clear for this timer + const existing = flashClearTimeoutsRef.current.get(timer.id); + if (existing) { + clearTimeout(existing as unknown as number); + flashClearTimeoutsRef.current.delete(timer.id); + } + + setFlashingTimers(prev => new Set(Array.from(prev).concat(timer.id))); + + // Milestones clear after 1s; finished state stays red permanently via isFinished + if (!justFinished) { + const t = setTimeout(() => { + setFlashingTimers(prev => { + const next = new Set(prev); + next.delete(timer.id); + return next; + }); + flashClearTimeoutsRef.current.delete(timer.id); + }, 1000) as unknown as NodeJS.Timeout; + flashClearTimeoutsRef.current.set(timer.id, t); + } + } + + prevTimerStateRef.current.set(timer.id, { + quartileLength: timer.quartilesBeeped.length, + isFinished: timer.isFinished, + }); + }); + }, [timers]); + // Main timer update loop useEffect(() => { if (timers.some(timer => timer.isRunning)) { intervalRef.current = setInterval(() => { setTimers(prevTimers => prevTimers.map(timer => { - if (!timer.isRunning || timer.timeLeft <= 0) return timer; + if (!timer.isRunning) return timer; + + // Lead time countdown: count down before the main timer starts + if (timer.leadTimeRemaining > 0) { + const newLeadTimeRemaining = timer.leadTimeRemaining - 1; + if (newLeadTimeRemaining === 0 && audioContextRef.current) { + // Signal that the timer is about to start + playTimerBeep(audioContextRef.current); + } + return { ...timer, leadTimeRemaining: newLeadTimeRemaining }; + } + + // Overrun: keep counting negative after the timer has finished + if (timer.isFinished && timer.overrunEnabled) { + return { ...timer, timeLeft: timer.timeLeft - 1 }; + } + + // Normal timer: guard against already-expired timer + if (timer.timeLeft <= 0) return timer; const newTimeLeft = timer.timeLeft - 1; + + // Check quartile milestones (only for timers long enough to matter) + let updatedQuartilesBeeped = timer.quartilesBeeped; + if (timer.beepAtQuartiles && timer.duration >= 4) { + const milestones = [ + { pct: 25, threshold: Math.round(timer.duration * 0.75) }, + { pct: 50, threshold: Math.round(timer.duration * 0.5) }, + { pct: 75, threshold: Math.round(timer.duration * 0.25) }, + ]; + for (const { pct, threshold } of milestones) { + if ( + threshold > 0 && + newTimeLeft <= threshold && + !timer.quartilesBeeped.includes(pct) + ) { + updatedQuartilesBeeped = [...updatedQuartilesBeeped, pct]; + if (audioContextRef.current) { + playMilestoneBeep(audioContextRef.current); + } + break; // Only one milestone beep per tick + } + } + } + if (newTimeLeft <= 0) { // Timer finished startAlarm(timer); + if (timer.overrunEnabled) { + // Keep running, counting negative + return { + ...timer, + timeLeft: 0, + isFinished: true, + isRunning: true, + quartilesBeeped: updatedQuartilesBeeped, + }; + } return { ...timer, timeLeft: 0, isRunning: false, isFinished: true, + quartilesBeeped: updatedQuartilesBeeped, }; } - return { ...timer, timeLeft: newTimeLeft }; + return { + ...timer, + timeLeft: newTimeLeft, + quartilesBeeped: updatedQuartilesBeeped, + }; }) ); }, 1000); @@ -504,6 +718,8 @@ export default function Timer() { isFinished: false, timeLeft: timer.duration, currentAlarmPlays: 0, + leadTimeRemaining: timer.leadTime, + quartilesBeeped: [], } : timer ) @@ -537,12 +753,25 @@ export default function Timer() { const hours = Math.floor(timer.duration / 3600); const minutes = Math.floor((timer.duration % 3600) / 60); const seconds = timer.duration % 60; - copyShareableURL({ h: hours, m: minutes, s: seconds, name: timer.name }); + copyShareableURL({ + h: hours, + m: minutes, + s: seconds, + name: timer.name, + alarm: timer.alarmCount, + overrun: timer.overrunEnabled, + lead: timer.leadTime, + beep: timer.beepAtQuartiles, + }); } else { copyShareableURL({ h: newTimerHours, m: newTimerMinutes, s: newTimerSeconds, + alarm: newTimerAlarmCount, + overrun: newTimerOverrunEnabled, + lead: newTimerLeadTime, + beep: newTimerBeepAtQuartiles, }); } @@ -550,7 +779,28 @@ export default function Timer() { title: "URL Copied", description: "Timer URL has been copied to clipboard", }); - }, [timers, newTimerHours, newTimerMinutes, newTimerSeconds, toast]); + }, [ + timers, + newTimerHours, + newTimerMinutes, + newTimerSeconds, + newTimerAlarmCount, + newTimerOverrunEnabled, + newTimerLeadTime, + newTimerBeepAtQuartiles, + toast, + ]); + + const getTimerTextColor = ( + flashing: boolean, + isFinished: boolean, + isRunning: boolean, + leadTimeRemaining: number + ) => { + if (flashing || (isFinished && !isRunning)) return "text-red-600"; + if (leadTimeRemaining > 0) return "text-slate-400 dark:text-slate-500"; + return "text-slate-900 dark:text-slate-100"; + }; const getAlarmText = (count: number) => { switch (count) { @@ -594,6 +844,9 @@ export default function Timer() { setNewTimerSeconds(0); setNewTimerName(""); setNewTimerAlarmCount(3); + setNewTimerOverrunEnabled(false); + setNewTimerLeadTime(5); + setNewTimerBeepAtQuartiles(true); setShowAddTimer(false); toast({ title: "Reset", @@ -618,11 +871,86 @@ export default function Timer() { newTimerSeconds === 0 && newTimerName === "" && newTimerAlarmCount === 3 && + newTimerLeadTime === 5 && + !newTimerOverrunEnabled && + newTimerBeepAtQuartiles && !showAddTimer; // Check if there's modified data const hasModifiedData = timers.length > 0 || showAddTimer; + const fullscreenTimer = timers.find(t => t.id === fullscreenTimerId); + + if (fullscreenTimer) { + const fsPercent = + fullscreenTimer.duration > 0 && fullscreenTimer.leadTimeRemaining === 0 + ? Math.min( + 100, + Math.round( + ((fullscreenTimer.duration - + Math.max(0, fullscreenTimer.timeLeft)) / + fullscreenTimer.duration) * + 100 + ) + ) + : 0; + const fsIsFlashing = flashingTimers.has(fullscreenTimer.id); + let fsProgressColor = theme === "dark" ? "#3b82f6" : "#2563eb"; + if (fullscreenTimer.isFinished || fsIsFlashing) fsProgressColor = "#ef4444"; + else if (fullscreenTimer.leadTimeRemaining > 0) fsProgressColor = "#f59e0b"; + const fsTrackColor = theme === "dark" ? "#334155" : "#e2e8f0"; + + return ( +
+
+ + + + + + +
+ + {fsPercent}% + +
+
+
+
+ {fullscreenTimer.timeLeft < 0 ? "-" : ""} + {formatTimerTime(Math.abs(fullscreenTimer.timeLeft))} +
+
+ {fullscreenTimer.name} +
+ +
+
+ ); + } + return (
{/* Header */} @@ -661,7 +989,7 @@ export default function Timer() { t => !t.isRunning && !t.isFinished && t.timeLeft === t.duration ); - const allFinished = timers.every(t => t.isFinished); + const allFinished = timers.every(t => t.isFinished && !t.isRunning); if (noneStarted) { return ( @@ -825,6 +1153,64 @@ export default function Timer() { {newTimerMinutes}:{newTimerSeconds.toString().padStart(2, "0")}{" "} • Alarm: {getAlarmText(newTimerAlarmCount)}
+ + + {/* Extra per-timer options */} +
+
+ + + setNewTimerLeadTime( + Math.max(0, parseInt(e.target.value, 10) || 0) + ) + } + data-testid="lead-time-input" + placeholder="0" + /> +
+ +
+ +
+ +

+ Continue as -00:01, -00:02… +

+
+
+ +
+ +
+ +

+ Beep at 25%, 50%, 75% elapsed +

+
+
+
+ +
-
- - - -
-
- {formatTimerTime(timer.timeLeft)} -
- -
- {!timer.isFinished ? ( + {timers.map(timer => { + const percentComplete = + timer.duration > 0 && timer.leadTimeRemaining === 0 + ? Math.min( + 100, + Math.round( + ((timer.duration - Math.max(0, timer.timeLeft)) / + timer.duration) * + 100 + ) + ) + : 0; + const isFlashing = flashingTimers.has(timer.id); + let progressColor = theme === "dark" ? "#3b82f6" : "#2563eb"; + if (isFlashing || (timer.isFinished && !timer.isRunning)) + progressColor = "#ef4444"; + else if (timer.leadTimeRemaining > 0) progressColor = "#f59e0b"; + const trackColor = theme === "dark" ? "#334155" : "#e2e8f0"; + + return ( + + +
+ + + {timer.name} + {(() => { + if (timer.isFinished) { + if (timer.overrunEnabled && timer.isRunning) { + return ( + + Overtime + + ); + } + return ( + + Finished + + ); + } + if (timer.isRunning && timer.leadTimeRemaining > 0) { + return ( + + Starting + + ); + } + if (timer.isRunning) { + return Running; + } + return null; + })()} + +
+
+ Alarm: {getAlarmText(timer.alarmCount)} +
- ) : ( -
- +
+
+ + +
+ {/* Donut chart with time inside */} +
+
+ - - Reset - - {alarmIntervalsRef.current.has(timer.id) && ( + + + + + +
+ {timer.isRunning && timer.leadTimeRemaining > 0 ? ( +
+ {timer.leadTimeRemaining} +
+ ) : ( +
+ {timer.timeLeft < 0 ? "-" : ""} + {formatTimerTime(Math.abs(timer.timeLeft))} +
+ )} +
+
+ + {percentComplete}% + +
+
+
+ + {/* Control buttons */} +
+ {(() => { + if (!timer.isFinished) { + return ( + + ); + } + const stopAlarmButton = alarmIntervalsRef.current.has( + timer.id + ) ? ( - )} -
- )} -
+ ) : null; + return ( +
+ + {stopAlarmButton} +
+ ); + })()} +
-
- Original duration: {formatTimerTime(timer.duration)} +
+ Original duration: {formatTimerTime(timer.duration)} + {timer.leadTime > 0 ? ` • Lead: ${timer.leadTime}s` : ""} +
-
- - - ))} + + + ); + })}
{/* Empty State */} diff --git a/tests/e2e/tools/timer.spec.ts b/tests/e2e/tools/timer.spec.ts index 80f0253c..55122544 100644 --- a/tests/e2e/tools/timer.spec.ts +++ b/tests/e2e/tools/timer.spec.ts @@ -66,9 +66,9 @@ test.describe("Timer Tool", () => { const startAll = page.getByTestId("start-all-timers"); await expect(startAll).toBeVisible(); - // Click Start All and ensure both timers start (Running badge appears) + // Click Start All and ensure both timers are active (Running or Starting due to lead time) await startAll.click(); - const runningBadges = page.locator("text=Running"); - await expect(runningBadges).toHaveCount(2); + // After starting, "Pause All" should appear (both timers now running/starting) + await expect(page.getByTestId("pause-all-timers")).toBeVisible(); }); });