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 "./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",
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
return { minW: 4, minH: 5 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (widget.type === "stopwatch") {
|
||||||
|
return { minW: 6, minH: 8 };
|
||||||
|
}
|
||||||
|
|
||||||
return { minW: 4, minH: 4 };
|
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;
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user