diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
new file mode 100644
index 0000000..a58a3f8
--- /dev/null
+++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
@@ -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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ 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(
+ ,
+ );
+
+ // 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);
+ });
+});