merge(08-02): Stopwatch widget with config persistence
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
phase: 08-dashboard-widgets-vollimplementierung
|
||||||
|
plan: "02"
|
||||||
|
subsystem: dashboard-widgets
|
||||||
|
tags: [stopwatch, widget, tdd, dashboard, persistence, i18n]
|
||||||
|
status: complete
|
||||||
|
|
||||||
|
dependency_graph:
|
||||||
|
requires:
|
||||||
|
- 08-01 (wireStopwatchWidget, WIDGET_CONSTRAINTS.stopwatch, i18n keys)
|
||||||
|
provides:
|
||||||
|
- StopwatchWidget component (start/stop/reset/lap, reload-safe persistence)
|
||||||
|
- stopwatch-widget.tsx wired into page.tsx via wireStopwatchWidget()
|
||||||
|
- Reload reconstruction via startedAt + elapsed config shape
|
||||||
|
affects:
|
||||||
|
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns:
|
||||||
|
- TDD Red/Green cycle (stopwatch behavioral contract)
|
||||||
|
- setInterval ticker cleared on unmount and when not running (T-08-04)
|
||||||
|
- Reload reconstruction: Date.now() - new Date(startedAt).getTime() + elapsed
|
||||||
|
- updateWidgetConfig best-effort persist (AbortController, swallow errors)
|
||||||
|
- wireXWidget() lazy-loading pattern (same as Plan 01)
|
||||||
|
- Tailwind-only styling (no CSS modules)
|
||||||
|
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
|
||||||
|
- apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
|
||||||
|
modified:
|
||||||
|
- apps/web/src/app/(portal)/page.tsx
|
||||||
|
|
||||||
|
decisions:
|
||||||
|
- "Lap times stored newest-first (per RESEARCH recommendation) — simplest rendering, no reverse needed"
|
||||||
|
- "Persist errors swallowed best-effort (same pattern as note-widget) — UI never blocked by API errors"
|
||||||
|
- "Interval tick rate 100ms — smooth display without excessive re-renders"
|
||||||
|
- "AbortController per-persist-call (same as note-widget) — cancels in-flight request before new one"
|
||||||
|
|
||||||
|
metrics:
|
||||||
|
duration: "~3 minutes"
|
||||||
|
completed: "2026-07-01"
|
||||||
|
tasks_completed: 3
|
||||||
|
tests_added: 7
|
||||||
|
files_created: 2
|
||||||
|
files_modified: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 08 Plan 02: Stopwatch Widget Summary
|
||||||
|
|
||||||
|
Stoppuhr-Widget (DASH-10) via TDD Red/Green-Zyklus: Start/Stop/Pause/Reset/Runde mit persistiertem Zustand in WidgetInstance.config — laufende Stoppuhr ueberleb Seiten-Reload korrekt.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
- **Stopwatch Widget** (`stopwatch-widget.tsx`): Voll funktionsfaehige Stoppuhr. Start startet den Timer und setzt `startedAt` auf `new Date().toISOString()`. Stop/Pause friert den angezeigten Wert ein und akkumuliert `elapsed`. Reset setzt alles auf 0. Lap haengt den aktuellen Live-Elapsed vorne an `laps[]` an.
|
||||||
|
- **Reload-Rekonstruktion**: Beim Rendern mit `config.state === 'running'` wird elapsed als `Date.now() - new Date(startedAt).getTime() + config.elapsed` berechnet — die Stoppuhr zeigt damit sofort den korrekten Wert, auch nach Seiten-Reload (Pitfall 2 aus RESEARCH korrekt umgesetzt).
|
||||||
|
- **Persistenz**: Jede Aktion (Start/Stop/Reset/Lap) ruft `updateWidgetConfig(instanceId, {...})` auf. Best-effort-Muster (Fehler werden verschluckt, UI wird nie blockiert). AbortController cancelt laufende Anfragen vor neuen.
|
||||||
|
- **Timer-Safety** (T-08-04): `setInterval` (100ms) wird beim Unmount und immer wenn `state !== 'running'` gecleart. Kein unbegrenzter Timer.
|
||||||
|
- **Wiring**: `page.tsx` importiert `StopwatchWidget` und ruft `wireStopwatchWidget(StopwatchWidget)` auf.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
| Task | Description | Status | Commit |
|
||||||
|
|------|-------------|--------|--------|
|
||||||
|
| 1 | RED: Failing tests fuer Stopwatch-Verhalten und Reload-Rekonstruktion | Complete | d8d008b |
|
||||||
|
| 2 | GREEN: StopwatchWidget-Implementierung + page.tsx-Wiring | Complete | c1c7bff |
|
||||||
|
| 3 | Full Suite + TypeCheck Gate | Complete | — (no new changes needed) |
|
||||||
|
|
||||||
|
## Test Results
|
||||||
|
|
||||||
|
- **stopwatch-widget.test.tsx**: 7 Tests — initial display 00:00, Start persists state=running + startedAt, display advances after 3s, Stop persists state=paused, Reset returns 00:00, Lap appends to laps, reload reconstruction shows ~7s from 5s-ago startedAt + 2000ms elapsed
|
||||||
|
- **Full Web Suite**: 74/74 Tests bestehen (16 Test-Dateien)
|
||||||
|
- **Web TypeScript**: sauber (0 Fehler)
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Worktree Fast-Forward (auto-fix, Rule 3)
|
||||||
|
|
||||||
|
**Found during:** Pre-task setup
|
||||||
|
|
||||||
|
**Issue:** Das Worktree-Branch `worktree-agent-a1442bedc5b6b918d` war bei `88db54f` (Quick-Task-Commits) — HINTER dem erwarteten Basis-Commit `2fa5193f` (Main nach Plan-01-Merge). `wireStopwatchWidget` und die i18n-Schluesseln aus Plan 01 fehlten.
|
||||||
|
|
||||||
|
**Fix:** `git merge main --ff-only` — Fast-Forward auf `2fa5193f`. Keine Konflikte. Anschliessend node_modules-Symlinks neu erstellt.
|
||||||
|
|
||||||
|
**Impact:** Keiner. Alle Plan-01-Artefakte standen danach vollstaendig zur Verfuegung.
|
||||||
|
|
||||||
|
### Task 3: Kein separater Commit
|
||||||
|
|
||||||
|
Task 3 (Full Suite + TypeCheck) loeste keine Fehler aus — Full Suite und tsc waren nach Task 2 bereits sauber. Gemaess Protokoll: Kein Commit bei keinen Aenderungen.
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Keine Stubs. StopwatchWidget ist vollstaendig implementiert. Favoriten-Widget (Plan 03) und Link-Widget (Plan 04) bleiben als PlaceholderWidget in der Registry — das ist erwartet und wird in den Nachfolgeplaenen umgesetzt.
|
||||||
|
|
||||||
|
## Threat Surface Scan
|
||||||
|
|
||||||
|
Keine neuen Sicherheitsoberflaechen eingefuehrt.
|
||||||
|
|
||||||
|
- **T-08-03 (Tampering)**: Kein neuer API-Endpunkt. Stopwatch-Config nutzt denselben `PATCH /dashboard/widgets/:id/config`-Pfad — Eigentuemerspruefung in DashboardService gilt.
|
||||||
|
- **T-08-04 (DoS via setInterval)**: Interval wird im useEffect-Cleanup und beim Uebergang zu nicht-laufendem Zustand gecleart. Mitigiert.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| stopwatch-widget.tsx | FOUND |
|
||||||
|
| stopwatch-widget.test.tsx | FOUND |
|
||||||
|
| page.tsx: wireStopwatchWidget(StopwatchWidget) | FOUND |
|
||||||
|
| Commit d8d008b (RED) | FOUND |
|
||||||
|
| Commit c1c7bff (GREEN) | FOUND |
|
||||||
|
| Full suite 74/74 | PASSED |
|
||||||
|
| TypeScript: 0 errors | PASSED |
|
||||||
@@ -5,12 +5,13 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
import { DashboardGrid } from '@/components/dashboard/dashboard-grid';
|
||||||
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle';
|
||||||
import { WidgetCatalogModal } from '@/components/dashboard/widget-catalog-modal';
|
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 { ClockWidget } from '@/components/dashboard/widgets/clock-widget';
|
||||||
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
import { SearchWidget } from '@/components/dashboard/widgets/search-widget';
|
||||||
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget';
|
||||||
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
import { NoteWidget } from '@/components/dashboard/widgets/note-widget';
|
||||||
import { CalculatorWidget } from '@/components/dashboard/widgets/calculator-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 { useDashboardStore } from '@/lib/stores/dashboard-store';
|
||||||
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
import type { WidgetType } from '@/components/dashboard/widget-registry';
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ wireSearchWidget(SearchWidget);
|
|||||||
wireCalendarWidget(CalendarWidget);
|
wireCalendarWidget(CalendarWidget);
|
||||||
wireNoteWidget(NoteWidget);
|
wireNoteWidget(NoteWidget);
|
||||||
wireCalculatorWidget(CalculatorWidget);
|
wireCalculatorWidget(CalculatorWidget);
|
||||||
|
wireStopwatchWidget(StopwatchWidget);
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const t = useTranslations('widgets');
|
const t = useTranslations('widgets');
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock next-intl — passthrough t(key) => key
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock @/lib/dashboard-api — spy on updateWidgetConfig
|
||||||
|
vi.mock('@/lib/dashboard-api', () => ({
|
||||||
|
updateWidgetConfig: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import mocked function for assertions
|
||||||
|
import { updateWidgetConfig } from '@/lib/dashboard-api';
|
||||||
|
|
||||||
|
// Must import AFTER mocks
|
||||||
|
import { StopwatchWidget } from './stopwatch-widget';
|
||||||
|
|
||||||
|
describe('StopwatchWidget', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Use fake timers for deterministic testing
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 00:00 and Start button with empty config', () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId('stopwatch-display')).toHaveTextContent('00:00');
|
||||||
|
// Button with accessible name matching the translation key 'stopwatch.start'
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: /stopwatch\.start/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking Start persists state=running with startedAt ISO string', async () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
const startBtn = screen.getByRole('button', { name: /stopwatch\.start/i });
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(startBtn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateWidgetConfig).toHaveBeenCalledWith(
|
||||||
|
'sw-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
state: 'running',
|
||||||
|
startedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances display after 3000ms while running', async () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const display = screen.getByTestId('stopwatch-display').textContent ?? '';
|
||||||
|
// Display should show 00:03 (or similar — just not 00:00)
|
||||||
|
expect(display).not.toBe('00:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Stop/Pause freezes display and persists state=paused', async () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
// Start the stopwatch
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.stop/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
||||||
|
'sw-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
state: 'paused',
|
||||||
|
startedAt: null,
|
||||||
|
elapsed: expect.any(Number),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Reset returns display to 00:00 and persists state=stopped with elapsed=0 laps=[]', async () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1500);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.reset/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId('stopwatch-display')).toHaveTextContent('00:00');
|
||||||
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
||||||
|
'sw-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
state: 'stopped',
|
||||||
|
startedAt: null,
|
||||||
|
elapsed: 0,
|
||||||
|
laps: [],
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Lap while running appends elapsed to laps list', async () => {
|
||||||
|
render(<StopwatchWidget instanceId="sw-1" config={{}} isEditMode={false} />);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.start/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /stopwatch\.lap/i }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// At least one lap entry visible
|
||||||
|
expect(updateWidgetConfig).toHaveBeenLastCalledWith(
|
||||||
|
'sw-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
laps: expect.arrayContaining([expect.any(Number)]),
|
||||||
|
}),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lap entry rendered in the DOM
|
||||||
|
const lapItems = screen.getAllByRole('listitem');
|
||||||
|
expect(lapItems.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reload reconstruction: renders ~7000ms elapsed from config with startedAt 5s ago', async () => {
|
||||||
|
// Fix system time so Date.now() is deterministic
|
||||||
|
const fixedNow = new Date('2026-07-01T12:00:00.000Z');
|
||||||
|
vi.setSystemTime(fixedNow);
|
||||||
|
|
||||||
|
// startedAt is 5 seconds before fixedNow
|
||||||
|
const startedAt = new Date(fixedNow.getTime() - 5000).toISOString();
|
||||||
|
|
||||||
|
// Render with a running config: 2000ms already accumulated + 5s since startedAt = ~7000ms total
|
||||||
|
render(
|
||||||
|
<StopwatchWidget
|
||||||
|
instanceId="sw-reload"
|
||||||
|
config={{
|
||||||
|
state: 'running',
|
||||||
|
startedAt,
|
||||||
|
elapsed: 2000,
|
||||||
|
laps: [],
|
||||||
|
}}
|
||||||
|
isEditMode={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tick the interval once
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
const display = screen.getByTestId('stopwatch-display').textContent ?? '';
|
||||||
|
// 7000ms = 7 seconds → should show 00:07
|
||||||
|
// More precisely: display must not show 00:00, 00:01, or 00:02 (which would ignore startedAt)
|
||||||
|
// Accept any display > 5s
|
||||||
|
const parts = display.split(':');
|
||||||
|
const seconds = parseInt(parts[parts.length - 1] ?? '0', 10);
|
||||||
|
const minutes = parseInt(parts[parts.length - 2] ?? '0', 10);
|
||||||
|
const totalSeconds = minutes * 60 + seconds;
|
||||||
|
expect(totalSeconds).toBeGreaterThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user