diff --git a/.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md new file mode 100644 index 0000000..eaac7d2 --- /dev/null +++ b/.planning/phases/08-dashboard-widgets-vollimplementierung/08-02-SUMMARY.md @@ -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 | diff --git a/apps/web/src/app/(portal)/page.tsx b/apps/web/src/app/(portal)/page.tsx index 829ccf5..d371c93 100644 --- a/apps/web/src/app/(portal)/page.tsx +++ b/apps/web/src/app/(portal)/page.tsx @@ -5,12 +5,13 @@ import { useTranslations } from 'next-intl'; import { DashboardGrid } from '@/components/dashboard/dashboard-grid'; import { EditModeToggle } from '@/components/dashboard/edit-mode-toggle'; 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 { SearchWidget } from '@/components/dashboard/widgets/search-widget'; import { CalendarWidget } from '@/components/dashboard/widgets/calendar-widget'; import { NoteWidget } from '@/components/dashboard/widgets/note-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 type { WidgetType } from '@/components/dashboard/widget-registry'; @@ -20,6 +21,7 @@ wireSearchWidget(SearchWidget); wireCalendarWidget(CalendarWidget); wireNoteWidget(NoteWidget); wireCalculatorWidget(CalculatorWidget); +wireStopwatchWidget(StopwatchWidget); export default function DashboardPage() { const t = useTranslations('widgets'); 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); + }); +}); diff --git a/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx new file mode 100644 index 0000000..8bac4e3 --- /dev/null +++ b/apps/web/src/components/dashboard/widgets/stopwatch-widget.tsx @@ -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): 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(initial); + // Display-only live elapsed (updated by interval) + const [displayMs, setDisplayMs] = useState(() => computeElapsed(initial)); + + // AbortController for best-effort config persistence + const abortRef = useRef(undefined); + const intervalRef = useRef | 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 ( +
+ {/* Timer display */} +
+ + {formatMs(displayMs)} + +
+ + {/* Controls */} +
+ {!isRunning ? ( + + ) : ( + <> + + + + )} + +
+ + {/* Lap list (scrollable, newest first) */} + {sw.laps.length > 0 && ( +
+
    + {sw.laps.map((lapMs, idx) => ( +
  • + Runde {sw.laps.length - idx} + {formatMs(lapMs)} +
  • + ))} +
+
+ )} +
+ ); +}