'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslations } from 'next-intl'; import type { WidgetProps } from '../widget-registry'; import { updateWidgetConfig } from '@/lib/dashboard-api'; /** * StopwatchConfig shape stored in WidgetInstance.config (D-07). * - state: 'running' | 'paused' | 'stopped' * - startedAt: ISO string when the stopwatch was last started (null if not running) * - elapsed: accumulated ms before the last start (carries over pause/resume) * - laps: recorded lap times in ms (newest first) */ interface StopwatchConfig { state: 'running' | 'paused' | 'stopped'; startedAt: string | null; elapsed: number; laps: number[]; } function readConfig(config: Record): StopwatchConfig { return { state: (config.state as StopwatchConfig['state']) ?? 'stopped', startedAt: (config.startedAt as string | null) ?? null, elapsed: typeof config.elapsed === 'number' ? config.elapsed : 0, laps: Array.isArray(config.laps) ? (config.laps as number[]) : [], }; } /** Format milliseconds as mm:ss or hh:mm:ss */ function formatMs(ms: number): string { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; const mm = String(minutes).padStart(2, '0'); const ss = String(seconds).padStart(2, '0'); if (hours > 0) { const hh = String(hours).padStart(2, '0'); return `${hh}:${mm}:${ss}`; } return `${mm}:${ss}`; } /** * Compute live elapsed ms from persisted config. * If running: Date.now() - startedAt + accumulated elapsed (Pitfall 2 — reload reconstruction). * If paused/stopped: just the accumulated elapsed. */ function computeElapsed(sw: StopwatchConfig): number { if (sw.state === 'running' && sw.startedAt) { return Date.now() - new Date(sw.startedAt).getTime() + sw.elapsed; } return sw.elapsed; } /** * Stopwatch widget (DASH-10, D-07, D-08). * Persists state to WidgetInstance.config via PATCH /dashboard/widgets/:id/config. * State persists across reloads: startedAt ISO string + accumulated elapsed enables * correct reconstruction of running time after a page reload (Pitfall 2). */ export function StopwatchWidget({ instanceId, config, isEditMode: _isEditMode }: WidgetProps) { const t = useTranslations('widgets'); // Read initial state from persisted config const initial = readConfig(config); const [sw, setSw] = useState(initial); // Display-only live elapsed (updated by interval) const [displayMs, setDisplayMs] = useState(() => computeElapsed(initial)); // AbortController for best-effort config persistence const abortRef = useRef(undefined); const intervalRef = useRef | undefined>(undefined); // Tick interval — only active while running useEffect(() => { if (sw.state === 'running' && sw.startedAt) { intervalRef.current = setInterval(() => { setDisplayMs(computeElapsed(sw)); }, 100); } else { setDisplayMs(sw.elapsed); } return () => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = undefined; } }; // Re-run when state/startedAt/elapsed changes (handles reload reconstruction) // eslint-disable-next-line react-hooks/exhaustive-deps }, [sw.state, sw.startedAt, sw.elapsed]); // Cleanup on unmount useEffect(() => { return () => { if (intervalRef.current) clearInterval(intervalRef.current); abortRef.current?.abort(); }; }, []); /** Persist config (best-effort: errors are swallowed) */ const persist = useCallback( (next: StopwatchConfig) => { abortRef.current?.abort(); abortRef.current = new AbortController(); updateWidgetConfig(instanceId, { ...next }, abortRef.current.signal).catch( (err: unknown) => { if (err instanceof DOMException && err.name === 'AbortError') return; // Best-effort: swallow persist errors (same pattern as note-widget) }, ); }, [instanceId], ); const handleStart = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (sw.state === 'running') return; const next: StopwatchConfig = { ...sw, state: 'running', startedAt: new Date().toISOString(), // elapsed keeps accumulated time from before (supports pause/resume) }; setSw(next); persist(next); }, [sw, persist], ); const handleStop = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (sw.state !== 'running') return; const accumulated = computeElapsed(sw); const next: StopwatchConfig = { ...sw, state: 'paused', startedAt: null, elapsed: accumulated, }; setSw(next); setDisplayMs(accumulated); persist(next); }, [sw, persist], ); const handleReset = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); const next: StopwatchConfig = { state: 'stopped', startedAt: null, elapsed: 0, laps: [], }; setSw(next); setDisplayMs(0); persist(next); }, [persist], ); const handleLap = useCallback( (e: React.MouseEvent) => { e.stopPropagation(); if (sw.state !== 'running') return; const currentMs = computeElapsed(sw); // Newest lap first (per RESEARCH recommendation) const next: StopwatchConfig = { ...sw, laps: [currentMs, ...sw.laps], }; setSw(next); persist(next); }, [sw, persist], ); const isRunning = sw.state === 'running'; return (
{/* Timer display — quick-260916-bwo: skaliert per Container-Query mit der Kachel */}
{formatMs(displayMs)}
{/* Controls quick-260916-dyv: kompakte Bedienleiste, damit die laufende Stoppuhr (Stop + Runde + Reset) in 4 Spalten passt (ca. 151 px statt ca. 222 px) und die Kachel auf 4x3 schrumpfen kann. */}
{!isRunning ? ( ) : ( <> )}
{/* Lap list (scrollable, newest first) */} {sw.laps.length > 0 && (
    {sw.laps.map((lapMs, idx) => (
  • Runde {sw.laps.length - idx} {formatMs(lapMs)}
  • ))}
)}
); }