diff --git a/apps/clock/apps/web/src/lib/i18n/locales/de.json b/apps/clock/apps/web/src/lib/i18n/locales/de.json index d892c7aba..41ce2d317 100644 --- a/apps/clock/apps/web/src/lib/i18n/locales/de.json +++ b/apps/clock/apps/web/src/lib/i18n/locales/de.json @@ -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", diff --git a/apps/clock/apps/web/src/lib/i18n/locales/en.json b/apps/clock/apps/web/src/lib/i18n/locales/en.json index 651a8e254..c9944f2c0 100644 --- a/apps/clock/apps/web/src/lib/i18n/locales/en.json +++ b/apps/clock/apps/web/src/lib/i18n/locales/en.json @@ -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", diff --git a/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts b/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts index bae846b05..aaf5dabf9 100644 --- a/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts +++ b/apps/clock/apps/web/src/lib/stores/stopwatch.svelte.ts @@ -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([]); -let startTime = $state(null); -let pausedTime = $state(0); +let stopwatches = $state([]); +let focusedId = $state(null); -// Animation frame for updating time -let animationFrameId: number | null = null; +// Animation frames for each stopwatch +const animationFrames: Map = 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); }, }; diff --git a/apps/clock/apps/web/src/routes/stopwatch/+page.svelte b/apps/clock/apps/web/src/routes/stopwatch/+page.svelte index 499bd2a95..a1df14fff 100644 --- a/apps/clock/apps/web/src/routes/stopwatch/+page.svelte +++ b/apps/clock/apps/web/src/routes/stopwatch/+page.svelte @@ -1,71 +1,478 @@ -
- -

{$_('stopwatch.title')}

+
+ + +
- -
- {stopwatchStore.formattedTime} +{#if stopwatchesStore.stopwatches.length === 0} + +
+
+ + + +
+

{$_('stopwatch.noStopwatches')}

+

{$_('stopwatch.noStopwatchesDescription')}

+
+{:else} +
+ + {#if focused} + {@const bestLap = stopwatchesStore.getBestLap(focused.id)} + {@const worstLap = stopwatchesStore.getWorstLap(focused.id)} +
+ +
+
+
+ {#if editingLabelId === focused.id} + + {:else} + + {/if} +
+ +
- -
- {#if stopwatchStore.isRunning} - - - {:else if stopwatchStore.elapsedTime > 0} - - - {:else} - + +
+
+ {formatTime(focused.elapsedTime)} +
+ {#if focused.laps.length > 0} +
+ {focused.laps.length} + {$_('stopwatch.laps')} +
+ {/if} +
+ + +
+ {#if focused.isRunning} + + + {:else if focused.elapsedTime > 0} + + + {:else} + + {/if} +
+ + + {#if focused.laps.length > 0} +
+

+ {$_('stopwatch.laps')} ({focused.laps.length}) +

+
+ {#each [...focused.laps].reverse() as lap (lap.number)} + {@const isBest = bestLap?.number === lap.number} + {@const isWorst = worstLap?.number === lap.number} +
+ + #{lap.number} + {#if isBest} + {$_('stopwatch.best')} + {:else if isWorst} + {$_('stopwatch.worst')} + {/if} + +
+ {formatLapTime(lap.time)} + + {formatTime(lap.splitTime)} + +
+
+ {/each} +
+
+ {$_('stopwatch.total')} + + {formatTime(focused.elapsedTime)} + +
+
+ {/if} +
+ {/if} + + + {#if otherStopwatches.length > 0} +
+

+ {$_('stopwatch.otherStopwatches')} ({otherStopwatches.length}) +

+
+ {#each otherStopwatches as sw (sw.id)} +
handleFocus(sw.id)} + onkeydown={(e) => e.key === 'Enter' && handleFocus(sw.id)} + role="button" + tabindex="0" + > + +
+
+ +
+ + +
+ {formatTime(sw.elapsedTime)} +
+ + +
+ {sw.label} +
+ + +
+ {#if sw.isRunning} + + {:else} + + {/if} + {#if sw.elapsedTime > 0 && !sw.isRunning} + + {/if} +
+ + + {#if sw.laps.length > 0} +
+ {sw.laps.length} +
+ {/if} +
+ {/each} +
+
{/if}
+{/if} - - {#if stopwatchStore.laps.length > 0} -
-

- {$_('stopwatch.laps')} ({stopwatchStore.laps.length}) -

-
- {#each [...stopwatchStore.laps].reverse() as lap (lap.number)} - {@const isBest = stopwatchStore.bestLap?.number === lap.number} - {@const isWorst = stopwatchStore.worstLap?.number === lap.number} -
- - Runde {lap.number} - {#if isBest} - ({$_('stopwatch.best')}) - {:else if isWorst} - ({$_('stopwatch.worst')}) - {/if} - - - {formatLapTime(lap.time)} - -
- {/each} -
-
- {$_('stopwatch.total')} - - {formatTime(stopwatchStore.elapsedTime)} - -
-
- {/if} -
+