Files
tessera-ctl/.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-PLAN.md
T

10 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
08-dashboard-widgets-vollimplementierung 02 execute 2
08-01
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
true
DASH-10
truths artifacts key_links
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)
apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx
apps/web/src/components/dashboard/widgets/stopwatch-widget.test.tsx
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.

<phase_goal> 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. </phase_goal>

<execution_context> @$HOME/.claude/gsd-core/workflows/execute-plan.md @$HOME/.claude/gsd-core/templates/summary.md </execution_context>

@.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_produced>

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) </artifacts_produced>
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.

<threat_model>

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
</threat_model>
- 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

<success_criteria>

  • Stopwatch widget supports start, stop/pause, reset and lap recording (DASH-10)
  • Running stopwatch reconstructs elapsed time after reload from persisted startedAt/elapsed </success_criteria>
Create `.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md` when done