--- 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 Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md` when done