From 04d14b3aeaa8c1c6807eb420e6ea3766f9bcbf93 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:58:40 +0200 Subject: [PATCH] Add stopwatch widget with lap functionality - New StopwatchWidget component with start/stop/reset and lap tracking - Display shows MM:SS.cc with tabular-nums for stable layout - Lap table shows split time and cumulative total per lap - Uses requestAnimationFrame for smooth centisecond updates - Registered as 'stopwatch' widget type, min grid size 6x8 Co-Authored-By: Claude Sonnet 4.6 --- src/app/layout.tsx | 1 + src/app/page.tsx | 9 ++ src/app/stopwatch-widget.css | 130 ++++++++++++++++++++++++++++ src/components/DashboardGrid.tsx | 4 + src/components/StopwatchWidget.tsx | 134 +++++++++++++++++++++++++++++ src/types/dashboard.ts | 2 +- 6 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/app/stopwatch-widget.css create mode 100644 src/components/StopwatchWidget.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a76100d..6b202fc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,6 +11,7 @@ import "./calendar-scale.css"; import "./widget-density.css"; import "./clock-widget.css"; import "./search-widget.css"; +import "./stopwatch-widget.css"; export const metadata: Metadata = { title: "Personal Dashboard", diff --git a/src/app/page.tsx b/src/app/page.tsx index 4a5e733..60afeca 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -13,6 +13,7 @@ import ClockWidget from "@/components/ClockWidget"; import DomainCheckWidget from "@/components/DomainCheckWidget"; import FavoritesWidget from "@/components/FavoritesWidget"; import NoteWidget from "@/components/NoteWidget"; +import StopwatchWidget from "@/components/StopwatchWidget"; const DashboardGrid = dynamic(() => import("@/components/DashboardGrid"), { ssr: false, @@ -80,6 +81,10 @@ const widgetCatalog: Array<{ { type: "domain-check", title: "Domainprüfung" + }, + { + type: "stopwatch", + title: "Stoppuhr" } ]; @@ -1194,6 +1199,10 @@ export default function DashboardPage() { return ; } + if (widget.type === "stopwatch") { + return ; + } + return

Dieses Widget wird noch nicht unterstützt.

; } diff --git a/src/app/stopwatch-widget.css b/src/app/stopwatch-widget.css new file mode 100644 index 0000000..201a871 --- /dev/null +++ b/src/app/stopwatch-widget.css @@ -0,0 +1,130 @@ +/* Stoppuhr-Widget */ +.stopwatchWidget { + height: 100%; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 8px; + padding: 4px 0; +} + +.stopwatchDisplay { + text-align: center; + font-size: clamp(28px, 8cqw, 52px); + font-weight: 800; + font-variant-numeric: tabular-nums; + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--text); + padding: 4px 0; +} + +.stopwatchControls { + display: flex; + justify-content: center; + gap: 8px; +} + +.stopwatchButton { + min-height: 32px; + min-width: 64px; + padding: 0 14px; + border: 1px solid var(--border); + border-radius: 9px; + cursor: pointer; + font-weight: 700; + font-size: 13px; +} + +.stopwatchStart { + color: #fff; + background: #16a34a; + border-color: #16a34a; +} + +.stopwatchStart:hover { + background: #15803d; +} + +.stopwatchStop { + color: #fff; + background: #dc2626; + border-color: #dc2626; +} + +.stopwatchStop:hover { + background: #b91c1c; +} + +.stopwatchLap { + color: var(--text); + background: var(--surface-strong); +} + +.stopwatchLap:hover { + border-color: var(--accent); +} + +.stopwatchReset { + color: var(--text); + background: var(--surface-strong); +} + +.stopwatchReset:hover { + border-color: var(--accent); +} + +.stopwatchLaps { + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; +} + +.stopwatchLapTable { + width: 100%; + border-collapse: collapse; + font-variant-numeric: tabular-nums; +} + +.stopwatchLapTable thead { + position: sticky; + top: 0; + z-index: 1; +} + +.stopwatchLapTable th { + padding: 4px 8px; + color: var(--muted); + background: var(--surface); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.03em; + text-align: right; + border-bottom: 1px solid var(--border); +} + +.stopwatchLapTable th:first-child { + text-align: left; +} + +.stopwatchLapTable td { + padding: 4px 8px; + font-size: 13px; + text-align: right; + border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.stopwatchLapNumber { + text-align: left !important; + color: var(--muted); + font-weight: 700; +} + +.stopwatchLapTime { + font-weight: 700; +} + +.stopwatchLapTotal { + color: var(--muted); +} diff --git a/src/components/DashboardGrid.tsx b/src/components/DashboardGrid.tsx index fc6bf62..6757ab8 100644 --- a/src/components/DashboardGrid.tsx +++ b/src/components/DashboardGrid.tsx @@ -43,6 +43,10 @@ function getWidgetMinimumSize(widget: DashboardGridWidget): { minW: number; minH return { minW: 4, minH: 5 }; } + if (widget.type === "stopwatch") { + return { minW: 6, minH: 8 }; + } + return { minW: 4, minH: 4 }; } diff --git a/src/components/StopwatchWidget.tsx b/src/components/StopwatchWidget.tsx new file mode 100644 index 0000000..f9ecbb6 --- /dev/null +++ b/src/components/StopwatchWidget.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +type Lap = { + number: number; + splitMs: number; + totalMs: number; +}; + +function formatMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((ms % 1000) / 10); + + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}.${String(centiseconds).padStart(2, "0")}`; +} + +export default function StopwatchWidget() { + const [elapsedMs, setElapsedMs] = useState(0); + const [running, setRunning] = useState(false); + const [laps, setLaps] = useState([]); + const startTimeRef = useRef(0); + const accumulatedRef = useRef(0); + const frameRef = useRef(0); + const lapsEndRef = useRef(null); + + const tick = useCallback(() => { + setElapsedMs(accumulatedRef.current + (performance.now() - startTimeRef.current)); + frameRef.current = requestAnimationFrame(tick); + }, []); + + useEffect(() => { + if (running) { + startTimeRef.current = performance.now(); + frameRef.current = requestAnimationFrame(tick); + } + + return () => cancelAnimationFrame(frameRef.current); + }, [running, tick]); + + function start() { + setRunning(true); + } + + function stop() { + accumulatedRef.current += performance.now() - startTimeRef.current; + setElapsedMs(accumulatedRef.current); + setRunning(false); + } + + function reset() { + cancelAnimationFrame(frameRef.current); + accumulatedRef.current = 0; + startTimeRef.current = 0; + setElapsedMs(0); + setRunning(false); + setLaps([]); + } + + function lap() { + const currentMs = accumulatedRef.current + (performance.now() - startTimeRef.current); + const previousTotal = laps.length > 0 ? laps[0].totalMs : 0; + + setLaps((prev) => [ + { number: prev.length + 1, splitMs: currentMs - previousTotal, totalMs: currentMs }, + ...prev + ]); + + setTimeout(() => lapsEndRef.current?.scrollTo({ top: 0 }), 0); + } + + const hasTime = elapsedMs > 0; + + return ( +
+
{formatMs(elapsedMs)}
+ +
+ {!running && !hasTime ? ( + + ) : null} + + {running ? ( + <> + + + + ) : null} + + {!running && hasTime ? ( + <> + + + + ) : null} +
+ + {laps.length > 0 ? ( +
+ + + + + + + + + + {laps.map((l) => ( + + + + + + ))} + +
#RundeGesamt
{l.number}{formatMs(l.splitMs)}{formatMs(l.totalMs)}
+
+ ) : null} +
+ ); +} diff --git a/src/types/dashboard.ts b/src/types/dashboard.ts index c75245d..ea0ab09 100644 --- a/src/types/dashboard.ts +++ b/src/types/dashboard.ts @@ -32,7 +32,7 @@ export type DashboardTab = { position: number; }; -export type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check"; +export type WidgetType = "favorites" | "note" | "search" | "calendar" | "calculator" | "clock" | "domain-check" | "stopwatch"; export type Widget = DashboardGridWidget & { userId: string;