feat(clock): add multi-stopwatch support with improved UI

- Extend stopwatch store to manage multiple stopwatches simultaneously
- Add localStorage persistence for stopwatches
- Implement focused/compact card layout with color coding
- Support lap tracking with best/worst markers per stopwatch
- Add editable labels and automatic color assignment
- Update i18n strings for all 5 languages (DE, EN, ES, FR, IT)
This commit is contained in:
Till-JS 2025-12-04 15:42:12 +01:00
parent 10f4da819b
commit 6080902444
4 changed files with 851 additions and 132 deletions

View file

@ -10,6 +10,7 @@
"stopwatch": "Stoppuhr",
"pomodoro": "Pomodoro",
"worldClock": "Weltzeituhr",
"lifeClock": "Lebensuhr",
"settings": "Einstellungen",
"feedback": "Feedback"
},
@ -125,6 +126,35 @@
"behind": "zurück",
"same": "Gleiche Zeit"
},
"lifeClock": {
"title": "Lebensuhr",
"description": "Gib dein Geburtsdatum ein, um zu sehen, wie viele Tage du bereits gelebt hast.",
"daysLived": "Tage gelebt",
"since": "seit",
"save": "Speichern",
"cancel": "Abbrechen",
"showMore": "Mehr Details",
"showLess": "Weniger anzeigen",
"nextMilestone": "Nächster Meilenstein",
"inDays": "in {days} Tagen",
"stats": {
"hours": "Stunden",
"minutes": "Minuten",
"weeks": "Wochen",
"months": "Monate"
},
"funFacts": {
"title": "Ungefähre Schätzungen",
"heartbeats": "Herzschläge",
"breaths": "Atemzüge",
"sunrises": "Sonnenaufgänge",
"sleepHours": "Stunden geschlafen"
},
"milestones": {
"title": "Meilensteine",
"days": "Tage"
}
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",

View file

@ -10,6 +10,7 @@
"stopwatch": "Stopwatch",
"pomodoro": "Pomodoro",
"worldClock": "World Clock",
"lifeClock": "Life Clock",
"settings": "Settings",
"feedback": "Feedback"
},
@ -125,6 +126,35 @@
"behind": "behind",
"same": "Same time"
},
"lifeClock": {
"title": "Life Clock",
"description": "Enter your birth date to see how many days you have lived.",
"daysLived": "days lived",
"since": "since",
"save": "Save",
"cancel": "Cancel",
"showMore": "Show more",
"showLess": "Show less",
"nextMilestone": "Next Milestone",
"inDays": "in {days} days",
"stats": {
"hours": "Hours",
"minutes": "Minutes",
"weeks": "Weeks",
"months": "Months"
},
"funFacts": {
"title": "Approximate Estimates",
"heartbeats": "Heartbeats",
"breaths": "Breaths",
"sunrises": "Sunrises",
"sleepHours": "Hours slept"
},
"milestones": {
"title": "Milestones",
"days": "days"
}
},
"settings": {
"title": "Settings",
"general": "General",

View file

@ -1,122 +1,374 @@
/**
* Stopwatch Store - Local-only stopwatch state using Svelte 5 runes
* Multi-Stopwatch Store - Manages multiple stopwatches using Svelte 5 runes
* Local-only with localStorage persistence
*/
import { browser } from '$app/environment';
export interface Lap {
number: number;
time: number; // milliseconds
splitTime: number; // total time at lap
}
export interface Stopwatch {
id: string;
label: string;
color: string;
isRunning: boolean;
elapsedTime: number; // milliseconds
laps: Lap[];
startTime: number | null;
pausedTime: number;
createdAt: Date;
}
// Available colors for stopwatches
export const STOPWATCH_COLORS = [
'#f59e0b', // amber (primary)
'#ef4444', // red
'#22c55e', // green
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#06b6d4', // cyan
'#f97316', // orange
] as const;
// Storage key
const STORAGE_KEY = 'clock-stopwatches';
// State
let isRunning = $state(false);
let elapsedTime = $state(0); // milliseconds
let laps = $state<Lap[]>([]);
let startTime = $state<number | null>(null);
let pausedTime = $state(0);
let stopwatches = $state<Stopwatch[]>([]);
let focusedId = $state<string | null>(null);
// Animation frame for updating time
let animationFrameId: number | null = null;
// Animation frames for each stopwatch
const animationFrames: Map<string, number> = new Map();
function updateTime() {
if (startTime !== null && isRunning) {
elapsedTime = pausedTime + (Date.now() - startTime);
animationFrameId = requestAnimationFrame(updateTime);
// Initialize from localStorage
function loadFromStorage(): Stopwatch[] {
if (!browser) return [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.map((sw: any) => ({
...sw,
createdAt: new Date(sw.createdAt),
isRunning: false, // Always start paused on page load
startTime: null,
}));
}
} catch (e) {
console.error('Failed to load stopwatches from storage:', e);
}
return [];
}
function saveToStorage() {
if (!browser) return;
try {
const toStore = stopwatches.map((sw) => ({
...sw,
isRunning: false, // Don't persist running state
startTime: null,
}));
localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore));
} catch (e) {
console.error('Failed to save stopwatches to storage:', e);
}
}
export const stopwatchStore = {
// Initialize
if (browser) {
stopwatches = loadFromStorage();
if (stopwatches.length > 0) {
focusedId = stopwatches[0].id;
}
}
function updateTime(id: string) {
const sw = stopwatches.find((s) => s.id === id);
if (sw && sw.isRunning && sw.startTime !== null) {
const newElapsed = sw.pausedTime + (Date.now() - sw.startTime);
stopwatches = stopwatches.map((s) => (s.id === id ? { ...s, elapsedTime: newElapsed } : s));
animationFrames.set(
id,
requestAnimationFrame(() => updateTime(id))
);
}
}
function getNextColor(): string {
const usedColors = stopwatches.map((sw) => sw.color);
const availableColor = STOPWATCH_COLORS.find((c) => !usedColors.includes(c));
return availableColor || STOPWATCH_COLORS[stopwatches.length % STOPWATCH_COLORS.length];
}
export const stopwatchesStore = {
// Getters
get isRunning() {
return isRunning;
get stopwatches() {
return stopwatches;
},
get elapsedTime() {
return elapsedTime;
get focusedId() {
return focusedId;
},
get laps() {
return laps;
get focusedStopwatch() {
return stopwatches.find((sw) => sw.id === focusedId) || null;
},
get formattedTime() {
return formatTime(elapsedTime);
},
get bestLap() {
if (laps.length < 2) return null;
return laps.reduce((best, lap) => (lap.time < best.time ? lap : best));
},
get worstLap() {
if (laps.length < 2) return null;
return laps.reduce((worst, lap) => (lap.time > worst.time ? lap : worst));
get runningCount() {
return stopwatches.filter((sw) => sw.isRunning).length;
},
/**
* Start the stopwatch
* Create a new stopwatch
*/
start() {
if (!isRunning) {
isRunning = true;
startTime = Date.now();
updateTime();
create(label: string = ''): string {
const id = crypto.randomUUID();
const newStopwatch: Stopwatch = {
id,
label: label || `Stoppuhr ${stopwatches.length + 1}`,
color: getNextColor(),
isRunning: false,
elapsedTime: 0,
laps: [],
startTime: null,
pausedTime: 0,
createdAt: new Date(),
};
stopwatches = [...stopwatches, newStopwatch];
focusedId = id;
saveToStorage();
return id;
},
/**
* Delete a stopwatch
*/
delete(id: string) {
// Stop animation if running
const frame = animationFrames.get(id);
if (frame) {
cancelAnimationFrame(frame);
animationFrames.delete(id);
}
stopwatches = stopwatches.filter((sw) => sw.id !== id);
// Update focused if needed
if (focusedId === id) {
focusedId = stopwatches.length > 0 ? stopwatches[0].id : null;
}
saveToStorage();
},
/**
* Update stopwatch label
*/
updateLabel(id: string, label: string) {
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, label } : sw));
saveToStorage();
},
/**
* Update stopwatch color
*/
updateColor(id: string, color: string) {
stopwatches = stopwatches.map((sw) => (sw.id === id ? { ...sw, color } : sw));
saveToStorage();
},
/**
* Set focused stopwatch
*/
setFocused(id: string | null) {
focusedId = id;
},
/**
* Start a stopwatch
*/
start(id: string) {
const sw = stopwatches.find((s) => s.id === id);
if (sw && !sw.isRunning) {
stopwatches = stopwatches.map((s) =>
s.id === id ? { ...s, isRunning: true, startTime: Date.now() } : s
);
updateTime(id);
}
},
/**
* Pause the stopwatch
* Pause a stopwatch
*/
pause() {
if (isRunning) {
isRunning = false;
pausedTime = elapsedTime;
startTime = null;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
pause(id: string) {
const sw = stopwatches.find((s) => s.id === id);
if (sw && sw.isRunning) {
// Cancel animation frame
const frame = animationFrames.get(id);
if (frame) {
cancelAnimationFrame(frame);
animationFrames.delete(id);
}
stopwatches = stopwatches.map((s) =>
s.id === id
? {
...s,
isRunning: false,
pausedTime: s.elapsedTime,
startTime: null,
}
: s
);
saveToStorage();
}
},
/**
* Toggle start/pause
*/
toggle() {
if (isRunning) {
this.pause();
} else {
this.start();
toggle(id: string) {
const sw = stopwatches.find((s) => s.id === id);
if (sw) {
if (sw.isRunning) {
this.pause(id);
} else {
this.start(id);
}
}
},
/**
* Record a lap
*/
lap() {
if (elapsedTime > 0) {
const lastLapTime = laps.length > 0 ? laps[laps.length - 1].splitTime : 0;
const lapTime = elapsedTime - lastLapTime;
lap(id: string) {
const sw = stopwatches.find((s) => s.id === id);
if (sw && sw.elapsedTime > 0) {
const lastLapTime = sw.laps.length > 0 ? sw.laps[sw.laps.length - 1].splitTime : 0;
const lapTime = sw.elapsedTime - lastLapTime;
laps = [
...laps,
{
number: laps.length + 1,
time: lapTime,
splitTime: elapsedTime,
},
];
const newLap: Lap = {
number: sw.laps.length + 1,
time: lapTime,
splitTime: sw.elapsedTime,
};
stopwatches = stopwatches.map((s) => (s.id === id ? { ...s, laps: [...s.laps, newLap] } : s));
saveToStorage();
}
},
/**
* Reset the stopwatch
* Reset a stopwatch
*/
reset() {
isRunning = false;
elapsedTime = 0;
laps = [];
startTime = null;
pausedTime = 0;
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
reset(id: string) {
// Cancel animation frame
const frame = animationFrames.get(id);
if (frame) {
cancelAnimationFrame(frame);
animationFrames.delete(id);
}
stopwatches = stopwatches.map((s) =>
s.id === id
? {
...s,
isRunning: false,
elapsedTime: 0,
laps: [],
startTime: null,
pausedTime: 0,
}
: s
);
saveToStorage();
},
/**
* Get best lap for a stopwatch
*/
getBestLap(id: string): Lap | null {
const sw = stopwatches.find((s) => s.id === id);
if (!sw || sw.laps.length < 2) return null;
return sw.laps.reduce((best, lap) => (lap.time < best.time ? lap : best));
},
/**
* Get worst lap for a stopwatch
*/
getWorstLap(id: string): Lap | null {
const sw = stopwatches.find((s) => s.id === id);
if (!sw || sw.laps.length < 2) return null;
return sw.laps.reduce((worst, lap) => (lap.time > worst.time ? lap : worst));
},
/**
* Clear all stopwatches
*/
clearAll() {
// Stop all animations
animationFrames.forEach((frame) => cancelAnimationFrame(frame));
animationFrames.clear();
stopwatches = [];
focusedId = null;
saveToStorage();
},
};
// Legacy single stopwatch store for backwards compatibility
export const stopwatchStore = {
get isRunning() {
const focused = stopwatchesStore.focusedStopwatch;
return focused?.isRunning || false;
},
get elapsedTime() {
const focused = stopwatchesStore.focusedStopwatch;
return focused?.elapsedTime || 0;
},
get laps() {
const focused = stopwatchesStore.focusedStopwatch;
return focused?.laps || [];
},
get formattedTime() {
return formatTime(this.elapsedTime);
},
get bestLap() {
const focused = stopwatchesStore.focusedStopwatch;
return focused ? stopwatchesStore.getBestLap(focused.id) : null;
},
get worstLap() {
const focused = stopwatchesStore.focusedStopwatch;
return focused ? stopwatchesStore.getWorstLap(focused.id) : null;
},
start() {
const id = stopwatchesStore.focusedId;
if (id) stopwatchesStore.start(id);
else {
const newId = stopwatchesStore.create();
stopwatchesStore.start(newId);
}
},
pause() {
const id = stopwatchesStore.focusedId;
if (id) stopwatchesStore.pause(id);
},
toggle() {
const id = stopwatchesStore.focusedId;
if (id) stopwatchesStore.toggle(id);
else {
const newId = stopwatchesStore.create();
stopwatchesStore.start(newId);
}
},
lap() {
const id = stopwatchesStore.focusedId;
if (id) stopwatchesStore.lap(id);
},
reset() {
const id = stopwatchesStore.focusedId;
if (id) stopwatchesStore.reset(id);
},
};

View file

@ -1,71 +1,478 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { stopwatchStore, formatTime, formatLapTime } from '$lib/stores/stopwatch.svelte';
import { PageHeader } from '@manacore/shared-ui';
import {
stopwatchesStore,
formatTime,
formatLapTime,
STOPWATCH_COLORS,
type Stopwatch,
} from '$lib/stores/stopwatch.svelte';
// Edit state
let editingLabelId = $state<string | null>(null);
let editingLabelValue = $state('');
function handleCreateNew() {
const id = stopwatchesStore.create();
stopwatchesStore.start(id);
}
function handleFocus(id: string) {
stopwatchesStore.setFocused(id);
}
function startEditLabel(sw: Stopwatch) {
editingLabelId = sw.id;
editingLabelValue = sw.label;
}
function saveLabel() {
if (editingLabelId && editingLabelValue.trim()) {
stopwatchesStore.updateLabel(editingLabelId, editingLabelValue.trim());
}
editingLabelId = null;
}
function handleLabelKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
saveLabel();
} else if (e.key === 'Escape') {
editingLabelId = null;
}
}
// Derived states
let focused = $derived(stopwatchesStore.focusedStopwatch);
let otherStopwatches = $derived(
stopwatchesStore.stopwatches.filter((sw) => sw.id !== stopwatchesStore.focusedId)
);
</script>
<div class="flex flex-col items-center space-y-8">
<!-- Header -->
<h1 class="text-2xl font-bold text-foreground">{$_('stopwatch.title')}</h1>
<div class="flex items-center justify-between mb-6">
<PageHeader title={$_('stopwatch.title')} size="md" />
<button class="btn btn-primary btn-sm" onclick={handleCreateNew}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.new')}
</button>
</div>
<!-- Time Display -->
<div class="digital-clock text-6xl font-light text-foreground sm:text-7xl">
{stopwatchStore.formattedTime}
{#if stopwatchesStore.stopwatches.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-24 h-24 mb-6 rounded-full bg-muted flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-12 w-12 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<h2 class="text-xl font-medium text-foreground mb-2">{$_('stopwatch.noStopwatches')}</h2>
<p class="text-muted-foreground mb-6 max-w-sm">{$_('stopwatch.noStopwatchesDescription')}</p>
<button class="btn btn-primary btn-lg" onclick={handleCreateNew}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.startFirst')}
</button>
</div>
{:else}
<div class="space-y-4">
<!-- Focused Stopwatch (Large) -->
{#if focused}
{@const bestLap = stopwatchesStore.getBestLap(focused.id)}
{@const worstLap = stopwatchesStore.getWorstLap(focused.id)}
<div class="stopwatch-card-focused" style="--sw-color: {focused.color}">
<!-- Header with Label and Delete -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
class:animate-pulse={focused.isRunning}
style="background-color: {focused.color}"
></div>
{#if editingLabelId === focused.id}
<input
type="text"
class="bg-transparent border-b border-primary text-lg font-medium focus:outline-none"
bind:value={editingLabelValue}
onblur={saveLabel}
onkeydown={handleLabelKeydown}
autofocus
/>
{:else}
<button
class="text-lg font-medium hover:text-primary transition-colors"
onclick={() => startEditLabel(focused)}
>
{focused.label}
</button>
{/if}
</div>
<button
class="text-muted-foreground hover:text-error transition-colors p-1"
onclick={() => stopwatchesStore.delete(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Controls -->
<div class="flex gap-4">
{#if stopwatchStore.isRunning}
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.pause()}>
{$_('stopwatch.stop')}
</button>
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.lap()}>
{$_('stopwatch.lap')}
</button>
{:else if stopwatchStore.elapsedTime > 0}
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
{$_('stopwatch.start')}
</button>
<button class="btn btn-secondary btn-xl" onclick={() => stopwatchStore.reset()}>
{$_('stopwatch.reset')}
</button>
{:else}
<button class="btn btn-primary btn-xl" onclick={() => stopwatchStore.start()}>
{$_('stopwatch.start')}
</button>
<!-- Time Display -->
<div class="flex flex-col items-center mb-6">
<div
class="digital-clock text-5xl sm:text-6xl font-light tabular-nums"
class:text-primary={focused.isRunning}
>
{formatTime(focused.elapsedTime)}
</div>
{#if focused.laps.length > 0}
<div class="text-sm text-muted-foreground mt-1">
{focused.laps.length}
{$_('stopwatch.laps')}
</div>
{/if}
</div>
<!-- Controls -->
<div class="flex justify-center gap-3 mb-6">
{#if focused.isRunning}
<button
class="btn btn-secondary btn-lg"
onclick={() => stopwatchesStore.pause(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zM7 8a1 1 0 012 0v4a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v4a1 1 0 102 0V8a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.stop')}
</button>
<button class="btn btn-primary btn-lg" onclick={() => stopwatchesStore.lap(focused.id)}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.lap')}
</button>
{:else if focused.elapsedTime > 0}
<button
class="btn btn-primary btn-lg"
onclick={() => stopwatchesStore.start(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.continue')}
</button>
<button
class="btn btn-secondary btn-lg"
onclick={() => stopwatchesStore.reset(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.reset')}
</button>
{:else}
<button
class="btn btn-primary btn-lg"
onclick={() => stopwatchesStore.start(focused.id)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 mr-2"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"
clip-rule="evenodd"
/>
</svg>
{$_('stopwatch.start')}
</button>
{/if}
</div>
<!-- Laps List -->
{#if focused.laps.length > 0}
<div class="border-t border-border pt-4">
<h3 class="text-sm font-medium text-muted-foreground mb-3">
{$_('stopwatch.laps')} ({focused.laps.length})
</h3>
<div class="max-h-48 overflow-y-auto space-y-1 scrollbar-thin">
{#each [...focused.laps].reverse() as lap (lap.number)}
{@const isBest = bestLap?.number === lap.number}
{@const isWorst = worstLap?.number === lap.number}
<div class="lap-item rounded-md" class:best={isBest} class:worst={isWorst}>
<span class="text-sm flex items-center gap-2">
<span class="text-muted-foreground">#{lap.number}</span>
{#if isBest}
<span class="text-xs px-1.5 py-0.5 rounded bg-success/20 text-success"
>{$_('stopwatch.best')}</span
>
{:else if isWorst}
<span class="text-xs px-1.5 py-0.5 rounded bg-error/20 text-error"
>{$_('stopwatch.worst')}</span
>
{/if}
</span>
<div class="text-right">
<span class="font-mono text-sm">{formatLapTime(lap.time)}</span>
<span class="font-mono text-xs text-muted-foreground ml-2">
{formatTime(lap.splitTime)}
</span>
</div>
</div>
{/each}
</div>
<div class="flex justify-between border-t border-border mt-3 pt-3">
<span class="text-sm font-medium">{$_('stopwatch.total')}</span>
<span class="font-mono text-sm font-medium">
{formatTime(focused.elapsedTime)}
</span>
</div>
</div>
{/if}
</div>
{/if}
<!-- Other Stopwatches (Compact Grid) -->
{#if otherStopwatches.length > 0}
<div>
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('stopwatch.otherStopwatches')} ({otherStopwatches.length})
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{#each otherStopwatches as sw (sw.id)}
<div
class="stopwatch-card-compact"
class:running={sw.isRunning}
style="--sw-color: {sw.color}"
onclick={() => handleFocus(sw.id)}
onkeydown={(e) => e.key === 'Enter' && handleFocus(sw.id)}
role="button"
tabindex="0"
>
<!-- Status indicator -->
<div class="flex items-center justify-between mb-2">
<div
class="w-2 h-2 rounded-full"
class:animate-pulse={sw.isRunning}
style="background-color: {sw.color}"
></div>
<button
class="text-muted-foreground hover:text-error p-0.5 -mr-1"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.delete(sw.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
/>
</svg>
</button>
</div>
<!-- Time -->
<div class="text-xl font-light tabular-nums mb-1" class:text-primary={sw.isRunning}>
{formatTime(sw.elapsedTime)}
</div>
<!-- Label -->
<div class="text-xs text-muted-foreground truncate mb-2">
{sw.label}
</div>
<!-- Quick actions -->
<div class="flex gap-1">
{#if sw.isRunning}
<button
class="btn btn-secondary btn-sm flex-1 text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.pause(sw.id);
}}
>
{$_('stopwatch.stop')}
</button>
{:else}
<button
class="btn btn-primary btn-sm flex-1 text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.start(sw.id);
}}
>
{sw.elapsedTime > 0 ? $_('stopwatch.continue') : $_('stopwatch.start')}
</button>
{/if}
{#if sw.elapsedTime > 0 && !sw.isRunning}
<button
class="btn btn-ghost btn-sm text-xs"
onclick={(e) => {
e.stopPropagation();
stopwatchesStore.reset(sw.id);
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-3.5 w-3.5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
clip-rule="evenodd"
/>
</svg>
</button>
{/if}
</div>
<!-- Lap badge -->
{#if sw.laps.length > 0}
<div class="absolute top-2 right-8 text-xs bg-muted px-1.5 py-0.5 rounded-full">
{sw.laps.length}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
<!-- Laps -->
{#if stopwatchStore.laps.length > 0}
<div class="card w-full max-w-md">
<h3 class="mb-3 text-sm font-medium text-muted-foreground">
{$_('stopwatch.laps')} ({stopwatchStore.laps.length})
</h3>
<div class="max-h-64 overflow-y-auto">
{#each [...stopwatchStore.laps].reverse() as lap (lap.number)}
{@const isBest = stopwatchStore.bestLap?.number === lap.number}
{@const isWorst = stopwatchStore.worstLap?.number === lap.number}
<div class="lap-item" class:best={isBest} class:worst={isWorst}>
<span class="text-sm">
Runde {lap.number}
{#if isBest}
<span class="ml-1 text-xs">({$_('stopwatch.best')})</span>
{:else if isWorst}
<span class="ml-1 text-xs">({$_('stopwatch.worst')})</span>
{/if}
</span>
<span class="font-mono text-sm">
{formatLapTime(lap.time)}
</span>
</div>
{/each}
</div>
<div class="mt-3 flex justify-between border-t border-border pt-3">
<span class="text-sm font-medium">{$_('stopwatch.total')}</span>
<span class="font-mono text-sm font-medium">
{formatTime(stopwatchStore.elapsedTime)}
</span>
</div>
</div>
{/if}
</div>
<style>
.stopwatch-card-focused {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 2px solid var(--sw-color, hsl(var(--color-primary)));
box-shadow: 0 0 20px
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 20%, transparent);
}
.stopwatch-card-compact {
position: relative;
background-color: hsl(var(--color-surface));
border-radius: var(--radius-md);
padding: 0.75rem;
border: 1px solid hsl(var(--color-border));
transition: all var(--transition-base);
cursor: pointer;
text-align: left;
}
.stopwatch-card-compact:hover {
border-color: var(--sw-color, hsl(var(--color-primary)));
background-color: hsl(var(--color-muted) / 0.3);
}
.stopwatch-card-compact.running {
border-color: var(--sw-color, hsl(var(--color-primary)));
box-shadow: 0 0 10px
color-mix(in srgb, var(--sw-color, hsl(var(--color-primary))) 15%, transparent);
}
.lap-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background-color: hsl(var(--color-muted) / 0.3);
}
.lap-item.best {
background-color: hsl(var(--color-success) / 0.1);
}
.lap-item.worst {
background-color: hsl(var(--color-error) / 0.1);
}
</style>