---
phase: 08-dashboard-widgets-vollimplementierung
plan: 02
type: execute
wave: 2
depends_on: [08-01]
files_modified:
- 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
autonomous: true
requirements: [DASH-10]
must_haves:
truths:
- "User can start, stop/pause and reset a Stopwatch widget"
- "User can record lap times while the stopwatch runs"
- "A running stopwatch continues correctly after a page reload (reconstructed from persisted startedAt)"
artifacts:
- "apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx"
- "apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx"
key_links:
- "stopwatch-widget.tsx persists state via updateWidgetConfig(instanceId, ...)"
- "page.tsx calls wireStopwatchWidget(StopwatchWidget)"
---
Deliver the Stopwatch widget vertical slice (DASH-10): start, stop/pause, reset, and lap recording, with state persisted into WidgetInstance.config so a running stopwatch survives a page reload.
Purpose: Add a persistent timer widget without any new backend endpoint — reuses the existing PATCH /dashboard/widgets/:id/config path (D-07).
Output: A user can add a Stopwatch widget, run it, record laps, reset it, and reload the page without losing elapsed time.
**As a** portal user, **I want to** run a stopwatch with lap times on my dashboard that keeps counting after a reload, **so that** I can time activities without a separate tool and without losing progress.
@$HOME/.claude/gsd-core/workflows/execute-plan.md
@$HOME/.claude/gsd-core/templates/summary.md
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-CONTEXT.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md
@.planning/phases/08-dashboard-widgets-vollimplementierung/08-01-SUMMARY.md
## Artifacts this phase produces (Plan 02)
- Component: `StopwatchWidget` (apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx)
- Test file: `stopwatch-widget.test.tsx`
- StopwatchConfig shape stored in WidgetInstance.config: `{ state: 'running' | 'paused' | 'stopped', startedAt: string | null, elapsed: number, laps: number[] }`
- Wiring line in page.tsx: `wireStopwatchWidget(StopwatchWidget)` (consumes the hook created in Plan 01)
Task 1: Failing tests for Stopwatch behavior and reload reconstruction (RED)
apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
- apps/web/src/components/dashboard/widgets/note-widget.test.tsx (fake timers, fetch spy, next-intl mock patterns)
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (WidgetProps, setInterval pattern)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 5 stopwatch persistence, Pitfall 2 reload drift)
Mock next-intl (passthrough). Mock updateWidgetConfig from '@/lib/dashboard-api' with vi.fn resolving. Use vi.useFakeTimers.
- Initial render with config {} shows 00:00 (elapsed 0) and a Start control.
- Clicking Start then advancing timers by 3000ms shows elapsed ~00:03; updateWidgetConfig was called with state 'running' and a startedAt ISO string.
- Clicking Stop/Pause while running freezes the display and persists state 'paused' with accumulated elapsed (number of ms).
- Clicking Reset returns display to 00:00 and persists state 'stopped', elapsed 0, laps [].
- Clicking Lap while running appends the current elapsed to laps and renders at least one lap entry.
- Reload reconstruction: rendering with config { state: 'running', startedAt: , elapsed: 2000, laps: [] } shows elapsed of roughly 7000ms (2000 + 5000) — proving Date.now() - startedAt + elapsed math (Pitfall 2).
Create stopwatch-widget.test.tsx. Mock '@/lib/dashboard-api' so updateWidgetConfig is a spy. Set a fixed system time via vi.setSystemTime for deterministic reconstruction; compute the "5s ago" startedAt from that fixed now. Query controls by their accessible name via the mocked t() keys (stopwatch.start/stop/reset/lap). Assert display text via a stable test id data-testid="stopwatch-display". Import StopwatchWidget after mocks. Tests MUST fail now (component does not exist).
pnpm --filter @tessera/web test --run stopwatch-widget 2>&1 | grep -Eq "fail|FAIL|Cannot find|error" && echo RED-OK
- stopwatch-widget.test.tsx exists and imports from './stopwatch-widget'
- Covers start, stop/pause, reset, lap, and reload reconstruction
- Test run fails (RED) due to missing implementation
The stopwatch test file exists and fails for the right reason, defining the timer contract.
Task 2: Stopwatch implementation with config persistence + wiring (GREEN)
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
apps/web/src/app/(portal)/page.tsx
- apps/web/src/components/dashboard/widgets/clock-widget.tsx (setInterval + WidgetProps pattern)
- apps/web/src/components/dashboard/widgets/note-widget.tsx (updateWidgetConfig usage, cleanup on unmount)
- apps/web/src/lib/dashboard-api.ts (updateWidgetConfig signature: (id, config, signal?))
- apps/web/src/app/(portal)/page.tsx (existing wireXWidget calls; wireStopwatchWidget already defined in Plan 01)
- .planning/phases/08-dashboard-widgets-vollimplementierung/08-RESEARCH.md (Pattern 5, Pitfall 2, Lap-Timer decision)
Create stopwatch-widget.tsx as `export function StopwatchWidget({ instanceId, config }: WidgetProps)` ('use client'; import type WidgetProps from '../widget-registry'; useTranslations('widgets')). Read initial state from config with the StopwatchConfig shape { state, startedAt, elapsed, laps }, defaulting to state 'stopped', startedAt null, elapsed 0, laps []. When state is 'running', compute live elapsed as Date.now() - new Date(startedAt).getTime() + (config.elapsed ?? 0) and tick via setInterval(…, 100) updating a display; clear the interval on unmount and when not running (Pitfall 2). Render the elapsed with a data-testid="stopwatch-display" formatted mm:ss (or hh:mm:ss when >= 1h) plus centiseconds optional.
Controls (labels via t('stopwatch.start'|'stop'|'reset'|'lap')):
- Start: if not running, set startedAt = new Date().toISOString(), keep accumulated elapsed, state 'running', persist via updateWidgetConfig(instanceId, {...}).
- Stop/Pause: freeze — compute accumulated elapsed, set state 'paused', startedAt null, persist.
- Reset: state 'stopped', startedAt null, elapsed 0, laps [], persist.
- Lap: while running, append current live elapsed (ms) to laps (newest first per research), persist; render laps as a scrollable list.
Use Tailwind only (flex h-full flex-col, muted/foreground/primary tokens). Wrap control buttons so their click handlers call stopPropagation to avoid grid-drag interference. Persist errors may be swallowed like note-widget (best-effort); do not block the UI.
Wire the widget: in page.tsx import StopwatchWidget and call wireStopwatchWidget(StopwatchWidget) next to the existing wire calls.
pnpm --filter @tessera/web test --run stopwatch-widget
- stopwatch-widget.test.tsx passes (GREEN)
- stopwatch-widget.tsx calls updateWidgetConfig with a startedAt ISO string on Start
- Reload reconstruction test passes (elapsed derived from startedAt + stored elapsed)
- page.tsx contains wireStopwatchWidget(StopwatchWidget)
- grep -c "module.css" apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx returns 0
A user can start/stop/reset/lap the stopwatch; state persists to WidgetInstance.config and a running timer reconstructs correctly after reload.
Task 3: Suite + type-check gate
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
- apps/web/package.json (scripts)
Run the full web test suite and web type-check to confirm the stopwatch slice did not regress other widgets. Fix any type errors. No new features.
pnpm --filter @tessera/web test --run && pnpm --filter @tessera/web exec tsc --noEmit
- Full @tessera/web vitest suite exits 0
- tsc --noEmit passes for @tessera/web
Stopwatch slice is complete with a green suite and clean type-check.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| client → API (PATCH /dashboard/widgets/:id/config) | Stopwatch config crosses into the API; ownership already enforced by DashboardService |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-08-03 | Tampering | stopwatch config persistence | low | mitigate | Reuses DashboardService.updateWidgetConfig ownership check (WHERE userId); no new endpoint added |
| T-08-04 | Denial of Service | setInterval tick | low | mitigate | Single interval cleared on unmount and when not running; no unbounded timers |
- pnpm --filter @tessera/web test --run passes (stopwatch suite + all others)
- tsc --noEmit clean for web
- Manual smoke (optional): start stopwatch, reload page, timer keeps counting; lap + reset work
- Stopwatch widget supports start, stop/pause, reset and lap recording (DASH-10)
- Running stopwatch reconstructs elapsed time after reload from persisted startedAt/elapsed