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 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-06-20 11:58:40 +02:00
parent ca76bf315c
commit 04d14b3aea
6 changed files with 279 additions and 1 deletions
+1
View File
@@ -11,6 +11,7 @@ import "./calendar-scale.css";
import "./widget-density.css"; import "./widget-density.css";
import "./clock-widget.css"; import "./clock-widget.css";
import "./search-widget.css"; import "./search-widget.css";
import "./stopwatch-widget.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Personal Dashboard", title: "Personal Dashboard",
+9
View File
@@ -13,6 +13,7 @@ import ClockWidget from "@/components/ClockWidget";
import DomainCheckWidget from "@/components/DomainCheckWidget"; import DomainCheckWidget from "@/components/DomainCheckWidget";
import FavoritesWidget from "@/components/FavoritesWidget"; import FavoritesWidget from "@/components/FavoritesWidget";
import NoteWidget from "@/components/NoteWidget"; import NoteWidget from "@/components/NoteWidget";
import StopwatchWidget from "@/components/StopwatchWidget";
const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), { const DashboardGrid = dynamic<DashboardGridProps>(() => import("@/components/DashboardGrid"), {
ssr: false, ssr: false,
@@ -80,6 +81,10 @@ const widgetCatalog: Array<{
{ {
type: "domain-check", type: "domain-check",
title: "Domainprüfung" title: "Domainprüfung"
},
{
type: "stopwatch",
title: "Stoppuhr"
} }
]; ];
@@ -1194,6 +1199,10 @@ export default function DashboardPage() {
return <DomainCheckWidget />; return <DomainCheckWidget />;
} }
if (widget.type === "stopwatch") {
return <StopwatchWidget />;
}
return <p className="muted">Dieses Widget wird noch nicht unterstützt.</p>; return <p className="muted">Dieses Widget wird noch nicht unterstützt.</p>;
} }
+130
View File
@@ -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);
}
+4
View File
@@ -43,6 +43,10 @@ function getWidgetMinimumSize(widget: DashboardGridWidget): { minW: number; minH
return { minW: 4, minH: 5 }; return { minW: 4, minH: 5 };
} }
if (widget.type === "stopwatch") {
return { minW: 6, minH: 8 };
}
return { minW: 4, minH: 4 }; return { minW: 4, minH: 4 };
} }
+134
View File
@@ -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<Lap[]>([]);
const startTimeRef = useRef(0);
const accumulatedRef = useRef(0);
const frameRef = useRef(0);
const lapsEndRef = useRef<HTMLDivElement | null>(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 (
<div className="stopwatchWidget">
<div className="stopwatchDisplay">{formatMs(elapsedMs)}</div>
<div className="stopwatchControls widgetNoDrag">
{!running && !hasTime ? (
<button type="button" className="stopwatchButton stopwatchStart" onClick={start}>
Start
</button>
) : null}
{running ? (
<>
<button type="button" className="stopwatchButton stopwatchLap" onClick={lap}>
Runde
</button>
<button type="button" className="stopwatchButton stopwatchStop" onClick={stop}>
Stopp
</button>
</>
) : null}
{!running && hasTime ? (
<>
<button type="button" className="stopwatchButton stopwatchReset" onClick={reset}>
Reset
</button>
<button type="button" className="stopwatchButton stopwatchStart" onClick={start}>
Weiter
</button>
</>
) : null}
</div>
{laps.length > 0 ? (
<div className="stopwatchLaps" ref={lapsEndRef}>
<table className="stopwatchLapTable">
<thead>
<tr>
<th>#</th>
<th>Runde</th>
<th>Gesamt</th>
</tr>
</thead>
<tbody>
{laps.map((l) => (
<tr key={l.number}>
<td className="stopwatchLapNumber">{l.number}</td>
<td className="stopwatchLapTime">{formatMs(l.splitMs)}</td>
<td className="stopwatchLapTotal">{formatMs(l.totalMs)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
);
}
+1 -1
View File
@@ -32,7 +32,7 @@ export type DashboardTab = {
position: number; 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 & { export type Widget = DashboardGridWidget & {
userId: string; userId: string;