Files
tessera-ctl/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
T
schalli c1c7bff929 feat(08-02): StopwatchWidget with config persistence + reload reconstruction (GREEN)
- stopwatch-widget.tsx: start/stop/reset/lap controls, setInterval tick (100ms)
- Reload reconstruction: Date.now() - startedAt + elapsed (Pitfall 2 fix)
- State persisted via updateWidgetConfig(instanceId, {...}) on each action
- Single interval cleared on unmount and when not running (T-08-04 mitigated)
- Lap times stored newest-first per RESEARCH recommendation
- No CSS modules — Tailwind only (grep -c module.css = 0)
- page.tsx: added wireStopwatchWidget(StopwatchWidget) import + call
- All 7 stopwatch tests pass (GREEN)
2026-07-01 10:10:03 +02:00

265 lines
8.3 KiB
TypeScript

'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<string, unknown>): 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<StopwatchConfig>(initial);
// Display-only live elapsed (updated by interval)
const [displayMs, setDisplayMs] = useState(() => computeElapsed(initial));
// AbortController for best-effort config persistence
const abortRef = useRef<AbortController | undefined>(undefined);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<div className="flex h-full flex-col overflow-hidden p-3">
{/* Timer display */}
<div className="flex flex-1 items-center justify-center">
<span
data-testid="stopwatch-display"
className="font-mono font-semibold tabular-nums text-foreground"
style={{ fontSize: 'clamp(24px, 5vw, 48px)' }}
>
{formatMs(displayMs)}
</span>
</div>
{/* Controls */}
<div className="flex items-center justify-center gap-2 py-2">
{!isRunning ? (
<button
type="button"
aria-label={t('stopwatch.start')}
onClick={handleStart}
className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.start')}
</button>
) : (
<>
<button
type="button"
aria-label={t('stopwatch.stop')}
onClick={handleStop}
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.stop')}
</button>
<button
type="button"
aria-label={t('stopwatch.lap')}
onClick={handleLap}
className="rounded-md bg-muted px-4 py-1.5 text-sm font-medium text-foreground hover:opacity-90 transition-opacity"
>
{t('stopwatch.lap')}
</button>
</>
)}
<button
type="button"
aria-label={t('stopwatch.reset')}
onClick={handleReset}
className="rounded-md border border-border px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{t('stopwatch.reset')}
</button>
</div>
{/* Lap list (scrollable, newest first) */}
{sw.laps.length > 0 && (
<div className="mt-1 max-h-32 overflow-y-auto border-t border-border pt-2">
<ul className="space-y-0.5">
{sw.laps.map((lapMs, idx) => (
<li
// eslint-disable-next-line react/no-array-index-key
key={idx}
className="flex justify-between px-1 py-0.5 text-xs text-muted-foreground"
>
<span className="text-foreground/60">Runde {sw.laps.length - idx}</span>
<span className="font-mono tabular-nums">{formatMs(lapMs)}</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}