diff --git a/src/components/panels/MosaicDashboard.tsx b/src/components/panels/MosaicDashboard.tsx index 7cc1e8f..9b4eced 100644 --- a/src/components/panels/MosaicDashboard.tsx +++ b/src/components/panels/MosaicDashboard.tsx @@ -25,6 +25,7 @@ import EspSensorPanel from './EspSensorPanel'; import PDBRailsPanel from './PDBRails'; import ArmControlPanel from './ArmControlPanel'; import WebRTCClient from './WebRTCClient'; +import TimerPanel from './TimerPanel'; import { ROVER_IP } from '@/constants'; @@ -45,6 +46,7 @@ type TileType = | 'pdbRails' | 'armControlPanel' | 'webRTCClient' + | 'timerPanel' ; type TileId = `${TileType}:${number}`; @@ -66,6 +68,7 @@ const TILE_DISPLAY_NAMES: Record = { pdbRails: 'PDB Rails', armControlPanel: 'Arm Control', webRTCClient: 'WebRTC Client', + timerPanel: 'Multi-Timer', }; const ALL_TILE_TYPES: TileType[] = [ @@ -85,6 +88,7 @@ const ALL_TILE_TYPES: TileType[] = [ 'pdbRails', 'armControlPanel', 'webRTCClient', + 'timerPanel', ]; function tileTypeOf(id: TileId): TileType { @@ -446,6 +450,12 @@ const MosaicDashboard: React.FC = () => { ); + case 'timerPanel': + return ( + + + + ); default: return
Unknown tile
; } diff --git a/src/components/panels/TimerCard.tsx b/src/components/panels/TimerCard.tsx new file mode 100644 index 0000000..f0cea50 --- /dev/null +++ b/src/components/panels/TimerCard.tsx @@ -0,0 +1,237 @@ +'use client'; + +import React, { useState } from 'react'; +import { useCountdown } from '@/hooks/useCountdown'; + +const DEFAULT_PRESET_MS = 4 * 60 * 1000; + +function formatMmSs(ms: number): string { + const totalSeconds = Math.max(0, Math.round(ms / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} + +function parseMmSs(text: string): number | null { + const match = /^(\d{1,2}):(\d{2})$/.exec(text.trim()); + if (!match) return null; + + const minutes = Number(match[1]); + const seconds = Number(match[2]); + if (seconds > 59) return null; + + const totalMs = (minutes * 60 + seconds) * 1000; + return totalMs > 0 ? totalMs : null; +} + +interface TimerCardProps { + label: string; + onRemove?: () => void; +} + +const TimerCard: React.FC = ({ label, onRemove }) => { + const { status, remainingMs, start, reset } = useCountdown(); + + const [presetMs, setPresetMs] = useState(DEFAULT_PRESET_MS); + const [inputText, setInputText] = useState(formatMmSs(DEFAULT_PRESET_MS)); + const [inputInvalid, setInputInvalid] = useState(false); + + const isIdle = status === 'idle'; + const isFinished = status === 'finished'; + + const countdownColor = isFinished + ? '#ff5566' + : remainingMs <= 30 * 1000 + ? '#ff5566' + : remainingMs <= 60 * 1000 + ? '#ffcc00' + : '#00ffcc'; + + const handleGo = () => { + const parsedMs = parseMmSs(inputText); + if (parsedMs === null) { + setInputInvalid(true); + return; + } + + setInputInvalid(false); + setPresetMs(parsedMs); + start(parsedMs); + }; + + const handleReset = () => { + reset(); + setInputText(formatMmSs(presetMs)); + setInputInvalid(false); + }; + + return ( +
+
+

{label}

+ {onRemove && ( + + )} +
+ + {!isIdle &&
{formatMmSs(remainingMs)}
} + + { + setInputText(e.target.value); + setInputInvalid(false); + }} + /> + +
+ {isIdle ? ( + + ) : ( + + )} +
+ + +
+ ); +}; + +export default TimerCard; diff --git a/src/components/panels/TimerPanel.tsx b/src/components/panels/TimerPanel.tsx new file mode 100644 index 0000000..6151929 --- /dev/null +++ b/src/components/panels/TimerPanel.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React, { useState } from 'react'; +import TimerCard from './TimerCard'; + +const DEFAULT_TIMER_COUNT = 5; +const MIN_TIMERS = 1; +const MAX_TIMERS = 8; + +function smallestFreeId(ids: number[]): number { + const used = new Set(ids); + let id = 1; + while (used.has(id)) id += 1; + return id; +} + +const TimerPanel: React.FC = () => { + const [timerIds, setTimerIds] = useState( + Array.from({ length: DEFAULT_TIMER_COUNT }, (_, i) => i + 1), + ); + + const addTimer = () => { + setTimerIds((prev) => { + if (prev.length >= MAX_TIMERS) return prev; + const id = smallestFreeId(prev); + return [...prev, id].sort((a, b) => a - b); + }); + }; + + const removeTimer = (id: number) => { + setTimerIds((prev) => (prev.length > MIN_TIMERS ? prev.filter((t) => t !== id) : prev)); + }; + + return ( +
+
+

Multi-Timer

+ +
+ +
+ {timerIds.map((id) => ( + MIN_TIMERS ? () => removeTimer(id) : undefined} + /> + ))} +
+ + +
+ ); +}; + +export default TimerPanel; diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..225f323 --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,53 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +export type CountdownStatus = 'idle' | 'running' | 'finished'; + +const TICK_INTERVAL_MS = 200; + +export interface UseCountdownResult { + status: CountdownStatus; + remainingMs: number; + start: (durationMs: number) => void; + reset: () => void; +} + +//Avoid inaccurate countdowns when switching to the background +export function useCountdown(): UseCountdownResult { + const [status, setStatus] = useState('idle'); + const [remainingMs, setRemainingMs] = useState(0); + const endTimeRef = useRef(null); + + useEffect(() => { + if (status !== 'running') return; + + const tick = () => { + const remaining = Math.max(0, (endTimeRef.current ?? 0) - Date.now()); + setRemainingMs(remaining); + + if (remaining <= 0) { + setStatus('finished'); + } + }; + + tick(); + const intervalId = window.setInterval(tick, TICK_INTERVAL_MS); + + return () => window.clearInterval(intervalId); + }, [status]); + + const start = (durationMs: number) => { + endTimeRef.current = Date.now() + durationMs; + setRemainingMs(durationMs); + setStatus('running'); + }; + + const reset = () => { + endTimeRef.current = null; + setRemainingMs(0); + setStatus('idle'); + }; + + return { status, remainingMs, start, reset }; +}