diff --git a/src/components/dashboard/PomodoroWidget.tsx b/src/components/dashboard/PomodoroWidget.tsx new file mode 100644 index 000000000..3c9e4ac3b --- /dev/null +++ b/src/components/dashboard/PomodoroWidget.tsx @@ -0,0 +1,215 @@ +import { useState, useEffect, useRef } from "react"; + +type TimerMode = "WORK" | "BREAK"; + +export default function PomodoroWidget() { + const [mode, setMode] = useState("WORK"); + const [secondsLeft, setSecondsLeft] = useState(25 * 60); + const [isActive, setIsActive] = useState(false); + const [customWorkMin, setCustomWorkMin] = useState(25); + const [customBreakMin, setCustomBreakMin] = useState(5); + const [showSettings, setShowSettings] = useState(false); + + const totalSeconds = mode === "WORK" ? customWorkMin * 60 : customBreakMin * 60; + const timerRef = useRef(null); + + // Synchronize timer duration changes cleanly when values or modes alter + useEffect(() => { + if (!isActive) { + setSecondsLeft(mode === "WORK" ? customWorkMin * 60 : customBreakMin * 60); + } + }, [customWorkMin, customBreakMin, mode]); + + // Unified core ticker countdown loops mechanism + useEffect(() => { + if (isActive) { + timerRef.current = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + clearInterval(timerRef.current!); + setIsActive(false); + triggerCompletionNotification(); + // Automatically switch modes upon completion state transition + setMode((oldMode) => (oldMode === "WORK" ? "BREAK" : "WORK")); + return 0; + } + return prev - 1; + }); + }, 1000); + } else if (timerRef.current) { + clearInterval(timerRef.current); + } + + return () => { + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isActive]); + + const toggleTimer = () => setIsActive(!isActive); + + const resetTimer = () => { + setIsActive(false); + setSecondsLeft(mode === "WORK" ? customWorkMin * 60 : customBreakMin * 60); + }; + + const switchMode = (newMode: TimerMode) => { + setIsActive(false); + setMode(newMode); + setSecondsLeft(newMode === "WORK" ? customWorkMin * 60 : customBreakMin * 60); + }; + + const triggerCompletionNotification = () => { + // Subtle non-intrusive HTML5 Web Audio API synthesization mapping context without forbidden 'any' + try { + const AudioContextClass = window.AudioContext || (window as unknown as Record).webkitAudioContext; + const audioCtx = new AudioContextClass(); + const oscillator = audioCtx.createOscillator(); + const gainNode = audioCtx.createGain(); + oscillator.connect(gainNode); + gainNode.connect(audioCtx.destination); + oscillator.type = "sine"; + oscillator.frequency.setValueAtTime(587.33, audioCtx.currentTime); // D5 high tone hint note + gainNode.gain.setValueAtTime(0.1, audioCtx.currentTime); + oscillator.start(); + oscillator.stop(audioCtx.currentTime + 0.3); + } catch (e) { + console.warn("Web Audio alert synthesized hint skipped block:", e); + } + + if (Notification.permission === "granted") { + new Notification(`${mode === "WORK" ? "Work Session" : "Break Session"} Concluded!`, { + body: mode === "WORK" ? "Time to take a well-deserved short break!" : "Ready to jump back into hyper-focus work session?", + }); + } + }; + + useEffect(() => { + if (typeof window !== "undefined" && Notification.permission === "default") { + Notification.requestPermission(); + } + }, []); + + // Structural SVG circle stroke computation mapping properties + const radius = 80; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = totalSeconds > 0 ? circumference - (secondsLeft / totalSeconds) * circumference : circumference; + + const formatTime = (totalSecs: number): string => { + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; + }; + + return ( +
+
+

Focus Session

+ +
+ + {showSettings ? ( +
+
+ Work Duration (m): + setCustomWorkMin(Math.max(1, parseInt(e.target.value) || 1))} + className="w-16 px-2 py-1 border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 rounded text-center focus:outline-none" + /> +
+
+ Break Duration (m): + setCustomBreakMin(Math.max(1, parseInt(e.target.value) || 1))} + className="w-16 px-2 py-1 border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 rounded text-center focus:outline-none" + /> +
+
+ ) : ( +
+ + +
+ )} + + {/* Real-time Circular SVG Progression Indicator Rings */} +
+ + + + +
+ + {formatTime(secondsLeft)} + + + {mode === "WORK" ? "Focusing" : "Resting"} + +
+
+ +
+ + +
+
+ ); +} +