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:
@@ -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",
|
||||
|
||||
@@ -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<DashboardGridProps>(() => 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 <DomainCheckWidget />;
|
||||
}
|
||||
|
||||
if (widget.type === "stopwatch") {
|
||||
return <StopwatchWidget />;
|
||||
}
|
||||
|
||||
return <p className="muted">Dieses Widget wird noch nicht unterstützt.</p>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user