dc992c9bc6
- WIDGET_CONSTRAINTS: Minima aus dem Innenaufbau gerechnet (clock 2/2, search 6/2, calendar 3/3, note 4/4, calculator 3/9, favorites 3/3, link 3/2, stopwatch 4/3), Vorgaben unveraendert; Test A pinnt alle 32 Werte per toEqual - dashboard-grid.tsx: effectiveLayouts (useMemo) ueberschreibt minW/minH jedes gespeicherten Eintrags in jedem Breakpoint aus den Konstanten und hebt zu kleine w/h auf das Minimum an — RGL 2.2.3 nimmt gespeicherte Eintraege woertlich (synchronizeLayoutWithChildren) und liest data-grid nur ohne Eintrag - dashboard-grid.test.tsx: Test 5 angepasst, Test 9 (Ueberschreibung je Breakpoint, unbekannter Typ unveraendert) und Test 9b (h 8 -> 9, w >= minW bleibt) - stopwatch-widget.tsx: Bedienleiste px-2 py-1 text-xs, Zeile gap-1 py-1, damit Stop + Runde + Reset in 4 Spalten passen und die Kachel auf 4x3 schrumpfen kann Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
267 lines
8.6 KiB
TypeScript
267 lines
8.6 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-1.5">
|
|
{/* Timer display — quick-260916-bwo: skaliert per Container-Query mit der Kachel */}
|
|
<div className="flex flex-1 items-center justify-center">
|
|
<span
|
|
data-testid="stopwatch-display"
|
|
className="font-mono font-semibold tabular-nums leading-none text-foreground text-[clamp(14px,min(16cqw,35cqh),400px)]"
|
|
>
|
|
{formatMs(displayMs)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="flex items-center justify-center gap-1 py-1">
|
|
{!isRunning ? (
|
|
<button
|
|
type="button"
|
|
aria-label={t('stopwatch.start')}
|
|
onClick={handleStart}
|
|
className="rounded-md bg-primary px-2 py-1 text-xs 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-2 py-1 text-xs 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-2 py-1 text-xs 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-2 py-1 text-xs 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>
|
|
);
|
|
}
|