From 03b77eec46f4d7f78605443d427691a954132be3 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 4 Dec 2025 16:00:54 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(clock):=20add=20life=20clock?= =?UTF-8?q?=20page=20with=20minimal=20homepage=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Simplify homepage by removing Quick Access section - Add date/time info below clock with countdown counters - Create new Life Clock (/life) page showing days lived - Add 3 switchable visualizations: Circular Progress, Dot Grid, Year Rings - DotGrid shows weeks as grid with decade markers and current week highlight - Store birthdate in localStorage for life statistics calculation - Add navigation entry for Life Clock 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../life-clock/CircularProgress.svelte | 204 +++++ .../lib/components/life-clock/DotGrid.svelte | 333 +++++++++ .../components/life-clock/YearRings.svelte | 262 +++++++ .../src/lib/components/life-clock/index.ts | 3 + .../web/src/lib/stores/life-clock.svelte.ts | 221 ++++++ apps/clock/apps/web/src/routes/+layout.svelte | 11 + apps/clock/apps/web/src/routes/+page.svelte | 256 +++---- .../apps/web/src/routes/life/+page.svelte | 698 ++++++++++++++++++ 8 files changed, 1864 insertions(+), 124 deletions(-) create mode 100644 apps/clock/apps/web/src/lib/components/life-clock/CircularProgress.svelte create mode 100644 apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte create mode 100644 apps/clock/apps/web/src/lib/components/life-clock/YearRings.svelte create mode 100644 apps/clock/apps/web/src/lib/components/life-clock/index.ts create mode 100644 apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts create mode 100644 apps/clock/apps/web/src/routes/life/+page.svelte diff --git a/apps/clock/apps/web/src/lib/components/life-clock/CircularProgress.svelte b/apps/clock/apps/web/src/lib/components/life-clock/CircularProgress.svelte new file mode 100644 index 000000000..8327c6928 --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/life-clock/CircularProgress.svelte @@ -0,0 +1,204 @@ + + +
+
+ + + + + + + + + {#each Array(8) as _, i} + {@const angle = (i / 8) * 360 - 90} + {@const markerRadius = radius + strokeWidth / 2 + 8} + {@const x = size / 2 + markerRadius * Math.cos((angle * Math.PI) / 180)} + {@const y = size / 2 + markerRadius * Math.sin((angle * Math.PI) / 180)} + + {i * 10} + + {/each} + + + +
+ {percentage.toFixed(1)}% + gelebt +
+
+ +
+
+
+ {daysLived.toLocaleString('de-DE')} + Tage gelebt +
+
+
+ {remainingDays.toLocaleString('de-DE')} + Tage verbleibend +
+
+

Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung

+
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte b/apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte new file mode 100644 index 000000000..e1559c500 --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte @@ -0,0 +1,333 @@ + + +
+ +
+
+ {weeksLived.toLocaleString('de-DE')} + Wochen gelebt +
+
+ {percentageLived}% von {totalWeeks.toLocaleString('de-DE')} Wochen +
+
+ + +
+
+ {#each rows() as row} +
+ + {row.year} + +
+ {#each row.weeks as week, i} + {#if i === 26} +
+ {/if} +
+ {/each} +
+
+ {/each} +
+
+ + +
+
+
+ Gelebt ({yearsLived} Jahre) +
+
+
+ Aktuelle Woche +
+
+
+ Verbleibend +
+
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/life-clock/YearRings.svelte b/apps/clock/apps/web/src/lib/components/life-clock/YearRings.svelte new file mode 100644 index 000000000..3ed49c2b6 --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/life-clock/YearRings.svelte @@ -0,0 +1,262 @@ + + +
+
+ Jeder Ring = 1 Jahr deines Lebens +
+ +
+ + + {#each rings() as ring} + {#if !ring.lived && !ring.current} + + {/if} + {/each} + + + {#each rings() as ring} + {#if ring.lived} + + {/if} + {/each} + + + {#each rings() as ring} + {#if ring.current} + {@const circumference = 2 * Math.PI * ring.radius} + {@const dashOffset = circumference - (currentYearProgress() / 100) * circumference} + + {/if} + {/each} + + + {#each [10, 20, 30, 40, 50, 60, 70, 80] as decade} + {@const markerRadius = 15 + decade * ringWidth + ringWidth / 2 + 2} + {#if decade <= totalYears} + + {decade} + + {/if} + {/each} + + + + + 0 + + +
+ +
+
+ {exactAge.years} + Jahre + + {exactAge.months} Monate, {exactAge.days} Tage +
+
+ +
+
+
+ Gelebte Jahre +
+
+
+ Aktuelles Jahr +
+
+
+ ZukĂźnftige Jahre +
+
+
+ + diff --git a/apps/clock/apps/web/src/lib/components/life-clock/index.ts b/apps/clock/apps/web/src/lib/components/life-clock/index.ts new file mode 100644 index 000000000..5de78b1f3 --- /dev/null +++ b/apps/clock/apps/web/src/lib/components/life-clock/index.ts @@ -0,0 +1,3 @@ +export { default as DotGrid } from './DotGrid.svelte'; +export { default as CircularProgress } from './CircularProgress.svelte'; +export { default as YearRings } from './YearRings.svelte'; diff --git a/apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts b/apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts new file mode 100644 index 000000000..9612ae0c5 --- /dev/null +++ b/apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts @@ -0,0 +1,221 @@ +/** + * Life Clock store for Clock app + * Manages the user's birthdate and calculates life statistics + * SSR-safe implementation with localStorage persistence + */ + +import { browser } from '$app/environment'; + +// Storage key +const BIRTHDATE_KEY = 'clock-birthdate'; + +// Milestones in days +export const MILESTONES = [ + 1000, 2000, 3000, 4000, 5000, 7500, 10000, 12500, 15000, 17500, 20000, 25000, 30000, 35000, 40000, +]; + +// Average life expectancy in years (can be customized) +const DEFAULT_LIFE_EXPECTANCY = 82; + +// State +let birthdate = $state(null); +let initialized = $state(false); + +export interface LifeStats { + daysLived: number; + hoursLived: number; + minutesLived: number; + secondsLived: number; + weeksLived: number; + monthsLived: number; + yearsLived: number; + exactAge: { years: number; months: number; days: number }; + heartbeats: number; + breaths: number; + sleepHours: number; + mealsEaten: number; + sunrises: number; +} + +export interface MilestoneInfo { + days: number; + reached: boolean; + daysUntil: number; + date: Date | null; +} + +function calculateStats(birthDate: Date, now: Date): LifeStats { + const diffMs = now.getTime() - birthDate.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + const diffSeconds = Math.floor(diffMs / 1000); + const diffWeeks = Math.floor(diffDays / 7); + + // Calculate exact age + let years = now.getFullYear() - birthDate.getFullYear(); + let months = now.getMonth() - birthDate.getMonth(); + let days = now.getDate() - birthDate.getDate(); + + if (days < 0) { + months--; + const prevMonth = new Date(now.getFullYear(), now.getMonth(), 0); + days += prevMonth.getDate(); + } + if (months < 0) { + years--; + months += 12; + } + + const totalMonths = years * 12 + months; + + // Fun facts calculations (approximate averages) + const heartbeats = Math.floor(diffMinutes * 70); // ~70 bpm average + const breaths = Math.floor(diffMinutes * 15); // ~15 breaths per minute + const sleepHours = Math.floor(diffDays * 8); // ~8 hours sleep per day + const mealsEaten = Math.floor(diffDays * 3); // 3 meals per day + const sunrises = diffDays; + + return { + daysLived: diffDays, + hoursLived: diffHours, + minutesLived: diffMinutes, + secondsLived: diffSeconds, + weeksLived: diffWeeks, + monthsLived: totalMonths, + yearsLived: years, + exactAge: { years, months, days }, + heartbeats, + breaths, + sleepHours, + mealsEaten, + sunrises, + }; +} + +function getMilestones(daysLived: number, birthDate: Date): MilestoneInfo[] { + return MILESTONES.map((days) => { + const reached = daysLived >= days; + const daysUntil = reached ? 0 : days - daysLived; + const date = reached ? null : new Date(birthDate.getTime() + days * 24 * 60 * 60 * 1000); + return { days, reached, daysUntil, date }; + }); +} + +function getNextMilestone(daysLived: number, birthDate: Date): MilestoneInfo | null { + const milestones = getMilestones(daysLived, birthDate); + return milestones.find((m) => !m.reached) || null; +} + +function getLifeProgress( + daysLived: number, + lifeExpectancy: number = DEFAULT_LIFE_EXPECTANCY +): number { + const expectedDays = lifeExpectancy * 365.25; + return Math.min((daysLived / expectedDays) * 100, 100); +} + +function getRemainingDays( + daysLived: number, + lifeExpectancy: number = DEFAULT_LIFE_EXPECTANCY +): number { + const expectedDays = Math.floor(lifeExpectancy * 365.25); + return Math.max(expectedDays - daysLived, 0); +} + +export const lifeClockStore = { + // Getters + get birthdate(): string | null { + return birthdate; + }, + get initialized(): boolean { + return initialized; + }, + get hasBirthdate(): boolean { + return birthdate !== null; + }, + + /** + * Initialize from localStorage (client-side only) + */ + initialize() { + if (!browser) return; + if (initialized) return; + + const saved = localStorage.getItem(BIRTHDATE_KEY); + if (saved) { + birthdate = saved; + } + + initialized = true; + }, + + /** + * Set the user's birthdate + */ + setBirthdate(date: string) { + birthdate = date; + if (browser) { + localStorage.setItem(BIRTHDATE_KEY, date); + } + }, + + /** + * Clear the birthdate + */ + clearBirthdate() { + birthdate = null; + if (browser) { + localStorage.removeItem(BIRTHDATE_KEY); + } + }, + + /** + * Get life statistics + */ + getStats(now: Date = new Date()): LifeStats | null { + if (!birthdate) return null; + const birthDate = new Date(birthdate); + return calculateStats(birthDate, now); + }, + + /** + * Get all milestones + */ + getMilestones(now: Date = new Date()): MilestoneInfo[] { + if (!birthdate) return []; + const birthDate = new Date(birthdate); + const stats = calculateStats(birthDate, now); + return getMilestones(stats.daysLived, birthDate); + }, + + /** + * Get next upcoming milestone + */ + getNextMilestone(now: Date = new Date()): MilestoneInfo | null { + if (!birthdate) return null; + const birthDate = new Date(birthdate); + const stats = calculateStats(birthDate, now); + return getNextMilestone(stats.daysLived, birthDate); + }, + + /** + * Get life progress percentage + */ + getLifeProgress(now: Date = new Date(), lifeExpectancy?: number): number { + if (!birthdate) return 0; + const birthDate = new Date(birthdate); + const stats = calculateStats(birthDate, now); + return getLifeProgress(stats.daysLived, lifeExpectancy); + }, + + /** + * Get remaining days based on life expectancy + */ + getRemainingDays(now: Date = new Date(), lifeExpectancy?: number): number { + if (!birthdate) return 0; + const birthDate = new Date(birthdate); + const stats = calculateStats(birthDate, now); + return getRemainingDays(stats.daysLived, lifeExpectancy); + }, +}; diff --git a/apps/clock/apps/web/src/routes/+layout.svelte b/apps/clock/apps/web/src/routes/+layout.svelte index 8fee02a2d..2623bd768 100644 --- a/apps/clock/apps/web/src/routes/+layout.svelte +++ b/apps/clock/apps/web/src/routes/+layout.svelte @@ -7,6 +7,7 @@ import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui'; import { theme } from '$lib/stores/theme.svelte'; import { authStore } from '$lib/stores/auth.svelte'; + import { userSettings } from '$lib/stores/user-settings.svelte'; import { THEME_DEFINITIONS } from '@manacore/shared-theme'; import { isSidebarMode as sidebarModeStore, @@ -81,6 +82,7 @@ { href: '/stopwatch', label: 'Stoppuhr', icon: 'stopwatch' }, { href: '/pomodoro', label: 'Pomodoro', icon: 'target' }, { href: '/world-clock', label: 'Weltzeituhr', icon: 'globe' }, + { href: '/life', label: 'Lebensuhr', icon: 'heart' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/feedback', label: 'Feedback', icon: 'chat' }, ]; @@ -146,6 +148,15 @@ // Initialize auth await authStore.initialize(); + // Load user settings (includes start page preference) + await userSettings.load(); + + // Redirect to start page if on root and a custom start page is set + const currentPath = window.location.pathname; + if (currentPath === '/' && userSettings.startPage && userSettings.startPage !== '/') { + goto(userSettings.startPage, { replaceState: true }); + } + // Initialize sidebar mode from localStorage const savedSidebar = localStorage.getItem('clock-nav-sidebar'); if (savedSidebar === 'true') { diff --git a/apps/clock/apps/web/src/routes/+page.svelte b/apps/clock/apps/web/src/routes/+page.svelte index 4af6729b6..5c275c8bf 100644 --- a/apps/clock/apps/web/src/routes/+page.svelte +++ b/apps/clock/apps/web/src/routes/+page.svelte @@ -1,6 +1,7 @@ -
- -
-

{$_('dashboard.title')}

-

{dateString}

-
- - -
- -
- - {#each Array(12) as _, i} -
- {/each} - - - {#each Array(60) as _, i} - {#if i % 5 !== 0} -
- {/if} - {/each} - - -
-
-
- - -
+
+ +
+
+
- -
-
- {timeString} + +
+

{timeString}

+

{dateString}

+
+ + {daysLeftInMonth()} + Tage bis Monatsende + + ¡ + + {daysLeftInYear()} + Tage bis Jahresende +
- - - - -
-
-
-

{$_('pomodoro.title')}

-

Starte eine fokussierte Arbeitssitzung

-
- - {$_('pomodoro.start')} - -
-
+ + Zifferblatt anpassen
+ + diff --git a/apps/clock/apps/web/src/routes/life/+page.svelte b/apps/clock/apps/web/src/routes/life/+page.svelte new file mode 100644 index 000000000..ce177aa20 --- /dev/null +++ b/apps/clock/apps/web/src/routes/life/+page.svelte @@ -0,0 +1,698 @@ + + +
+ {#if !hasBirthdate || isEditing} + +
+
+

Lebensuhr

+

+ Gib dein Geburtsdatum ein, um zu sehen, wie viele Tage du bereits gelebt hast. +

+ +
+ +
+ + {#if isEditing} + + {/if} +
+
+
+
+ {:else if stats} + +
+ +
+ {#each visualizations as viz} + + {/each} +
+ + +
+ {#if selectedViz === 'circular'} + + {:else if selectedViz === 'dotgrid'} + + {:else if selectedViz === 'rings'} + + {/if} +
+ + +
+ {formatNumber(stats.daysLived)} + Tage gelebt +

+ {stats.exactAge.years} Jahre, {stats.exactAge.months} Monate, {stats.exactAge.days} Tage +

+ +
+
+ + +
+ + {#if nextMilestone} +
+ Nächster Meilenstein + {formatNumber(nextMilestone.days)} Tage + in {formatNumber(nextMilestone.daysUntil)} Tagen +
+ {/if} + + +
+
+ {formatNumber(stats.hoursLived)} + Stunden +
+
+ {formatNumber(stats.minutesLived)} + Minuten +
+
+ {formatNumber(stats.weeksLived)} + Wochen +
+
+ {formatNumber(stats.monthsLived)} + Monate +
+
+ + +
+ +
+

Ungefähre Schätzungen

+
+
+ ❤️ +
+ ~{formatNumber(stats.heartbeats)} + Herzschläge +
+
+
+ 🌬️ +
+ ~{formatNumber(stats.breaths)} + AtemzĂźge +
+
+
+ 🌅 +
+ {formatNumber(stats.sunrises)} + Sonnenaufgänge +
+
+
+ 😴 +
+ ~{formatNumber(stats.sleepHours)} + Stunden geschlafen +
+
+
+
+ + +
+

Meilensteine

+
+ {#each milestones as milestone} +
+ {milestone.reached ? '✓' : '○'} + {formatNumber(milestone.days)} Tage + {#if !milestone.reached} + in {formatNumber(milestone.daysUntil)} Tagen + {/if} +
+ {/each} +
+
+
+
+ {/if} +
+ +