mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
✨ 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:
parent
10f4da819b
commit
6080902444
4 changed files with 851 additions and 132 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue