diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 829ccf5..d371c93 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,12 +5,13 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal'; -import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget } from '@/components/dashboard/widget-registry'; +import { wireClockWidget, wireSearchWidget, wireCalendarWidget, wireNoteWidget, wireCalculatorWidget, wireStopwatchWidget } from '@/components/dashboard/widget-registry'; import { ClockWidget } from '@/components/dashboard/widgets/clock-widget'; import { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { NoteWidget } from '@/components/dashboard/widgets/note-widget'; import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-widget'; +import { StopwatchWidget } from '@/components/dashboard/widgets/stopwatch-widget'; import { useDashboardStore } from '@/lib/stores/dashboard-store'; import type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -20,6 +21,7 @@ wireSearchWidget(SearchWidget); wireCalendarWidget(CalendarWidget); wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); +wireStopwatchWidget(StopwatchWidget); export default function DashboardPage() { const t = useTranslations('widgets'); diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx new file mode 100644 index 0000000..8bac4e3 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx @@ -0,0 +1,264 @@ +'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 */} +
+ + {formatMs(displayMs)} + +
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + <> + + + + )} + +
+ + {/* Lap list (scrollable, newest first) */} + {sw.laps.length > 0 && ( +
+
    + {sw.laps.map((lapMs, idx) => ( +
  • + Runde {sw.laps.length - idx} + {formatMs(lapMs)} +
  • + ))} +
+
+ )} +
+ ); +}