feat(clock): add life clock page with minimal homepage redesign

- 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 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-04 16:00:54 +01:00
parent 6080902444
commit 03b77eec46
8 changed files with 1864 additions and 124 deletions

View file

@ -0,0 +1,204 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Props {
daysLived: number;
lifeExpectancyYears?: number;
size?: number;
}
let { daysLived, lifeExpectancyYears = 80, size = 280 }: Props = $props();
// Calculate progress
let totalDays = $derived(Math.ceil(lifeExpectancyYears * 365.25));
let percentage = $derived(Math.min((daysLived / totalDays) * 100, 100));
let remainingDays = $derived(Math.max(totalDays - daysLived, 0));
// SVG calculations
let strokeWidth = 12;
let radius = $derived((size - strokeWidth) / 2);
let circumference = $derived(2 * Math.PI * radius);
let dashOffset = $derived(circumference - (percentage / 100) * circumference);
// Animation
let animatedOffset = $state(circumference);
let mounted = $state(false);
onMount(() => {
mounted = true;
// Animate on mount
requestAnimationFrame(() => {
animatedOffset = dashOffset;
});
});
// Update animation when values change
$effect(() => {
if (mounted) {
animatedOffset = dashOffset;
}
});
</script>
<div class="circular-container">
<div class="circular-wrapper" style="width: {size}px; height: {size}px;">
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="circular-svg">
<!-- Background circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-muted-foreground) / 0.15)"
stroke-width={strokeWidth}
/>
<!-- Progress circle -->
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="hsl(var(--color-primary))"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-dasharray={circumference}
stroke-dashoffset={animatedOffset}
transform="rotate(-90 {size / 2} {size / 2})"
class="progress-circle"
/>
<!-- Markers for decades -->
{#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)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="decade-marker">
{i * 10}
</text>
{/each}
</svg>
<!-- Center content -->
<div class="center-content">
<span class="percentage">{percentage.toFixed(1)}%</span>
<span class="label">gelebt</span>
</div>
</div>
<div class="circular-stats">
<div class="stat-row">
<div class="stat">
<span class="stat-value lived">{daysLived.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage gelebt</span>
</div>
<div class="stat-divider"></div>
<div class="stat">
<span class="stat-value remaining">{remainingDays.toLocaleString('de-DE')}</span>
<span class="stat-label">Tage verbleibend</span>
</div>
</div>
<p class="expectancy-note">Basierend auf {lifeExpectancyYears} Jahren Lebenserwartung</p>
</div>
</div>
<style>
.circular-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
}
.circular-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.circular-svg {
transform: rotate(0deg);
}
.progress-circle {
transition: stroke-dashoffset 1.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.decade-marker {
font-size: 0.625rem;
fill: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.center-content {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.percentage {
font-size: 2.5rem;
font-weight: 200;
color: hsl(var(--color-foreground));
line-height: 1;
}
.label {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.circular-stats {
text-align: center;
}
.stat-row {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-divider {
width: 1px;
height: 2.5rem;
background: hsl(var(--color-border));
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.stat-value.lived {
color: hsl(var(--color-primary));
}
.stat-value.remaining {
color: hsl(var(--color-muted-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.expectancy-note {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground) / 0.7);
margin-top: 0.75rem;
}
</style>

View file

@ -0,0 +1,333 @@
<script lang="ts">
interface Props {
weeksLived: number;
lifeExpectancyYears?: number;
}
let { weeksLived, lifeExpectancyYears = 80 }: Props = $props();
// Calculate total weeks in expected lifetime
let totalWeeks = $derived(Math.ceil(lifeExpectancyYears * 52.1775));
let weeksPerRow = 52; // One year per row
let totalRows = $derived(Math.ceil(totalWeeks / weeksPerRow));
// Generate rows with decade markers
let rows = $derived(() => {
const result: {
year: number;
weeks: { index: number; lived: boolean }[];
isDecade: boolean;
}[] = [];
for (let year = 0; year < totalRows; year++) {
const weeks: { index: number; lived: boolean }[] = [];
for (let week = 0; week < weeksPerRow; week++) {
const weekIndex = year * weeksPerRow + week;
if (weekIndex < totalWeeks) {
weeks.push({
index: weekIndex,
lived: weekIndex < weeksLived,
});
}
}
result.push({
year: year + 1,
weeks,
isDecade: (year + 1) % 10 === 0,
});
}
return result;
});
let percentageLived = $derived(Math.min((weeksLived / totalWeeks) * 100, 100).toFixed(1));
let yearsLived = $derived(Math.floor(weeksLived / 52));
</script>
<div class="dot-grid-container">
<!-- Header Stats -->
<div class="dot-grid-header">
<div class="header-main">
<span class="header-weeks">{weeksLived.toLocaleString('de-DE')}</span>
<span class="header-label">Wochen gelebt</span>
</div>
<div class="header-secondary">
<span>{percentageLived}% von {totalWeeks.toLocaleString('de-DE')} Wochen</span>
</div>
</div>
<!-- Grid -->
<div class="dot-grid-wrapper">
<div class="dot-grid">
{#each rows() as row}
<div class="dot-row" class:decade-row={row.isDecade}>
<span class="year-label" class:decade-label={row.isDecade}>
{row.year}
</span>
<div class="dots">
{#each row.weeks as week, i}
{#if i === 26}
<div class="half-year-marker"></div>
{/if}
<div
class="dot"
class:lived={week.lived}
class:current={week.index === weeksLived}
></div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<!-- Legend -->
<div class="dot-grid-legend">
<div class="legend-item">
<div class="legend-dot lived"></div>
<span>Gelebt ({yearsLived} Jahre)</span>
</div>
<div class="legend-item">
<div class="legend-dot current"></div>
<span>Aktuelle Woche</span>
</div>
<div class="legend-item">
<div class="legend-dot"></div>
<span>Verbleibend</span>
</div>
</div>
</div>
<style>
.dot-grid-container {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
/* Header */
.dot-grid-header {
text-align: center;
margin-bottom: 1.5rem;
}
.header-main {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.header-weeks {
font-size: 2.5rem;
font-weight: 200;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.header-label {
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
}
.header-secondary {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground) / 0.8);
}
/* Grid Wrapper */
.dot-grid-wrapper {
overflow-x: auto;
overflow-y: auto;
max-height: 50vh;
padding: 1rem;
background: hsl(var(--color-muted) / 0.03);
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border) / 0.5);
}
.dot-grid {
display: flex;
flex-direction: column;
gap: 2px;
min-width: fit-content;
}
/* Rows */
.dot-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1px 0;
}
.dot-row.decade-row {
padding-bottom: 6px;
margin-bottom: 4px;
border-bottom: 1px solid hsl(var(--color-border) / 0.3);
}
/* Year Labels */
.year-label {
font-size: 0.5rem;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-muted-foreground) / 0.5);
width: 1.25rem;
text-align: right;
flex-shrink: 0;
}
.year-label.decade-label {
font-size: 0.625rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
}
/* Dots Container */
.dots {
display: flex;
gap: 2px;
flex-wrap: nowrap;
align-items: center;
}
/* Half Year Marker */
.half-year-marker {
width: 1px;
height: 6px;
background: hsl(var(--color-border) / 0.3);
margin: 0 1px;
}
/* Individual Dots */
.dot {
width: 8px;
height: 8px;
border-radius: 2px;
background: hsl(var(--color-muted-foreground) / 0.12);
transition: all 0.15s ease;
flex-shrink: 0;
}
.dot.lived {
background: hsl(var(--color-primary));
}
.dot.current {
background: hsl(var(--color-primary));
box-shadow:
0 0 0 2px hsl(var(--color-background)),
0 0 0 4px hsl(var(--color-primary));
position: relative;
z-index: 1;
}
.dot:hover {
transform: scale(1.4);
z-index: 2;
}
/* Legend */
.dot-grid-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 1.5rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--color-border) / 0.3);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 3px;
background: hsl(var(--color-muted-foreground) / 0.12);
}
.legend-dot.lived {
background: hsl(var(--color-primary));
}
.legend-dot.current {
background: hsl(var(--color-primary));
box-shadow:
0 0 0 2px hsl(var(--color-background)),
0 0 0 3px hsl(var(--color-primary));
}
/* Responsive */
@media (min-width: 640px) {
.dot-grid-container {
max-width: 800px;
}
.dot {
width: 10px;
height: 10px;
}
.dots {
gap: 3px;
}
.dot-grid {
gap: 3px;
}
.half-year-marker {
height: 8px;
margin: 0 2px;
}
.dot-grid-wrapper {
max-height: 55vh;
padding: 1.5rem;
}
.header-weeks {
font-size: 3rem;
}
}
@media (min-width: 1024px) {
.dot-grid-container {
max-width: 900px;
}
.dot {
width: 11px;
height: 11px;
border-radius: 2px;
}
.dots {
gap: 4px;
}
.dot-grid {
gap: 4px;
}
.dot-grid-wrapper {
max-height: 60vh;
padding: 2rem;
}
.year-label {
font-size: 0.625rem;
width: 1.5rem;
}
.year-label.decade-label {
font-size: 0.75rem;
}
}
</style>

View file

@ -0,0 +1,262 @@
<script lang="ts">
interface Props {
yearsLived: number;
exactAge: { years: number; months: number; days: number };
lifeExpectancyYears?: number;
size?: number;
}
let { yearsLived, exactAge, lifeExpectancyYears = 80, size = 300 }: Props = $props();
// Calculate rings
let totalYears = lifeExpectancyYears;
let ringWidth = $derived(Math.max(2, (size / 2 - 20) / totalYears));
let centerX = $derived(size / 2);
let centerY = $derived(size / 2);
// Generate rings data
let rings = $derived(() => {
const result: {
year: number;
radius: number;
lived: boolean;
current: boolean;
decade: boolean;
}[] = [];
for (let year = 1; year <= totalYears; year++) {
const radius = 15 + year * ringWidth;
result.push({
year,
radius,
lived: year <= yearsLived,
current: year === yearsLived + 1,
decade: year % 10 === 0,
});
}
return result;
});
// Current year progress (partial ring)
let currentYearProgress = $derived(() => {
const monthProgress = exactAge.months / 12;
const dayProgress = exactAge.days / 365;
return (monthProgress + dayProgress) * 100;
});
</script>
<div class="year-rings-container">
<div class="rings-header">
<span class="header-label">Jeder Ring = 1 Jahr deines Lebens</span>
</div>
<div class="rings-wrapper" style="width: {size}px; height: {size}px;">
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="rings-svg">
<!-- Background rings (future years) -->
{#each rings() as ring}
{#if !ring.lived && !ring.current}
<circle
cx={centerX}
cy={centerY}
r={ring.radius}
fill="none"
stroke="hsl(var(--color-muted-foreground) / 0.1)"
stroke-width={ringWidth - 1}
class:decade-ring={ring.decade}
/>
{/if}
{/each}
<!-- Lived years -->
{#each rings() as ring}
{#if ring.lived}
<circle
cx={centerX}
cy={centerY}
r={ring.radius}
fill="none"
stroke="hsl(var(--color-primary) / {0.3 + (ring.year / totalYears) * 0.7})"
stroke-width={ringWidth - 1}
/>
{/if}
{/each}
<!-- Current year (partial) -->
{#each rings() as ring}
{#if ring.current}
{@const circumference = 2 * Math.PI * ring.radius}
{@const dashOffset = circumference - (currentYearProgress() / 100) * circumference}
<circle
cx={centerX}
cy={centerY}
r={ring.radius}
fill="none"
stroke="hsl(var(--color-primary))"
stroke-width={ringWidth - 1}
stroke-dasharray={circumference}
stroke-dashoffset={dashOffset}
stroke-linecap="round"
transform="rotate(-90 {centerX} {centerY})"
/>
{/if}
{/each}
<!-- Decade markers -->
{#each [10, 20, 30, 40, 50, 60, 70, 80] as decade}
{@const markerRadius = 15 + decade * ringWidth + ringWidth / 2 + 2}
{#if decade <= totalYears}
<text
x={centerX + markerRadius}
y={centerY}
text-anchor="start"
dominant-baseline="middle"
class="decade-label"
>
{decade}
</text>
{/if}
{/each}
<!-- Center dot -->
<circle cx={centerX} cy={centerY} r="8" fill="hsl(var(--color-primary))" />
<text
x={centerX}
y={centerY}
text-anchor="middle"
dominant-baseline="middle"
class="birth-label"
>
0
</text>
</svg>
</div>
<div class="rings-info">
<div class="age-display">
<span class="age-years">{exactAge.years}</span>
<span class="age-unit">Jahre</span>
<span class="age-detail">+ {exactAge.months} Monate, {exactAge.days} Tage</span>
</div>
</div>
<div class="rings-legend">
<div class="legend-item">
<div class="legend-ring lived"></div>
<span>Gelebte Jahre</span>
</div>
<div class="legend-item">
<div class="legend-ring current"></div>
<span>Aktuelles Jahr</span>
</div>
<div class="legend-item">
<div class="legend-ring future"></div>
<span>Zukünftige Jahre</span>
</div>
</div>
</div>
<style>
.year-rings-container {
display: flex;
flex-direction: column;
align-items: center;
}
.rings-header {
margin-bottom: 1rem;
}
.header-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.rings-wrapper {
position: relative;
}
.rings-svg {
display: block;
}
.decade-ring {
stroke: hsl(var(--color-muted-foreground) / 0.2) !important;
}
.decade-label {
font-size: 0.5rem;
fill: hsl(var(--color-muted-foreground) / 0.6);
font-weight: 500;
}
.birth-label {
font-size: 0.5rem;
fill: hsl(var(--color-primary-foreground));
font-weight: 600;
}
.rings-info {
margin-top: 1rem;
text-align: center;
}
.age-display {
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.375rem;
}
.age-years {
font-size: 2rem;
font-weight: 300;
color: hsl(var(--color-foreground));
}
.age-unit {
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
}
.age-detail {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-left: 0.25rem;
}
.rings-legend {
display: flex;
justify-content: center;
gap: 1.25rem;
margin-top: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
.legend-ring {
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid;
}
.legend-ring.lived {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.3);
}
.legend-ring.current {
border-color: hsl(var(--color-primary));
background: transparent;
}
.legend-ring.future {
border-color: hsl(var(--color-muted-foreground) / 0.2);
background: transparent;
}
</style>

View file

@ -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';

View file

@ -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<string | null>(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);
},
};

View file

@ -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') {

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { ClockFace } from '$lib/components/clock-faces';
import { clockFaceStore } from '$lib/stores/clock-face.svelte';
// Current time state
let currentTime = $state(new Date());
@ -13,23 +14,39 @@
// Formatted time strings
let timeString = $derived(
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
`${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
);
let dateString = $derived(
currentTime.toLocaleDateString('de-DE', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
})
);
// Clock hand rotations
let secondRotation = $derived((seconds / 60) * 360);
let minuteRotation = $derived(((minutes + seconds / 60) / 60) * 360);
let hourRotation = $derived((((hours % 12) + minutes / 60) / 12) * 360);
// Days remaining calculations
let daysLeftInMonth = $derived(() => {
const year = currentTime.getFullYear();
const month = currentTime.getMonth();
const lastDay = new Date(year, month + 1, 0).getDate();
const today = currentTime.getDate();
return lastDay - today;
});
let daysLeftInYear = $derived(() => {
const year = currentTime.getFullYear();
const endOfYear = new Date(year, 11, 31);
const today = new Date(year, currentTime.getMonth(), currentTime.getDate());
const diffTime = endOfYear.getTime() - today.getTime();
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
});
// Selected clock face
let selectedFace = $derived(clockFaceStore.selectedFace);
onMount(() => {
clockFaceStore.initialize();
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
@ -42,126 +59,117 @@
});
</script>
<div class="space-y-8">
<!-- Header -->
<div class="text-center">
<h1 class="text-3xl font-bold text-foreground">{$_('dashboard.title')}</h1>
<p class="mt-2 text-muted-foreground">{dateString}</p>
</div>
<!-- Main Clock Display -->
<div class="flex flex-col items-center gap-8 lg:flex-row lg:justify-center lg:gap-16">
<!-- Analog Clock -->
<div class="clock-face">
<!-- Hour markers -->
{#each Array(12) as _, i}
<div
class="clock-marker hour-marker"
style="transform: translateX(-50%) rotate({i * 30}deg) translateY(-130px)"
></div>
{/each}
<!-- Minute markers -->
{#each Array(60) as _, i}
{#if i % 5 !== 0}
<div
class="clock-marker minute-marker"
style="transform: translateX(-50%) rotate({i * 6}deg) translateY(-134px)"
></div>
{/if}
{/each}
<!-- Clock hands -->
<div
class="clock-hand hour"
style="transform: translateX(-50%) rotate({hourRotation}deg)"
></div>
<div
class="clock-hand minute"
style="transform: translateX(-50%) rotate({minuteRotation}deg)"
></div>
<div
class="clock-hand second"
style="transform: translateX(-50%) rotate({secondRotation}deg)"
></div>
<!-- Center dot -->
<div class="clock-center"></div>
<div class="home-container">
<!-- Center: Clock Face and Info -->
<div class="clock-center">
<div class="clock-display">
<ClockFace type={selectedFace} {hours} {minutes} {seconds} size={320} />
</div>
<!-- Digital Clock -->
<div class="text-center">
<div class="digital-clock digital-clock-large text-foreground">
{timeString}
<!-- Info below clock -->
<div class="bottom-info">
<p class="time-display">{timeString}</p>
<p class="date-display">{dateString}</p>
<div class="countdown-info">
<span class="countdown-item">
<span class="countdown-value">{daysLeftInMonth()}</span>
<span class="countdown-label">Tage bis Monatsende</span>
</span>
<span class="countdown-divider">·</span>
<span class="countdown-item">
<span class="countdown-value">{daysLeftInYear()}</span>
<span class="countdown-label">Tage bis Jahresende</span>
</span>
</div>
</div>
</div>
<!-- Quick Access Cards -->
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<!-- Next Alarm Card -->
<a href="/alarms" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-amber-500/10">
<span class="text-xl">🔔</span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.nextAlarm')}</p>
<p class="font-medium text-foreground">Nicht eingestellt</p>
</div>
</div>
</a>
<!-- Active Timers Card -->
<a href="/timers" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<span class="text-xl"></span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.activeTimers')}</p>
<p class="font-medium text-foreground">0 aktiv</p>
</div>
</div>
</a>
<!-- Stopwatch Card -->
<a href="/stopwatch" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<span class="text-xl"></span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('nav.stopwatch')}</p>
<p class="font-medium text-foreground">Bereit</p>
</div>
</div>
</a>
<!-- World Clock Card -->
<a href="/world-clock" class="card hover:border-primary/50 transition-colors">
<div class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-purple-500/10">
<span class="text-xl">🌍</span>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('dashboard.worldClocks')}</p>
<p class="font-medium text-foreground">0 Städte</p>
</div>
</div>
</a>
</div>
<!-- Pomodoro Quick Start -->
<div class="card mt-6">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
<div>
<h3 class="text-lg font-semibold text-foreground">{$_('pomodoro.title')}</h3>
<p class="text-sm text-muted-foreground">Starte eine fokussierte Arbeitssitzung</p>
</div>
<a href="/pomodoro" class="btn btn-primary btn-lg">
{$_('pomodoro.start')}
</a>
</div>
</div>
<!-- Bottom Right: Customize link -->
<a href="/clock-faces" class="customize-link"> Zifferblatt anpassen </a>
</div>
<style>
.home-container {
position: relative;
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
min-height: 400px;
}
.bottom-info {
text-align: center;
margin-top: 1.5rem;
}
.time-display {
font-size: 2.5rem;
font-weight: 300;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
letter-spacing: 0.02em;
}
.date-display {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
.countdown-info {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-top: 1rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.countdown-item {
display: flex;
align-items: baseline;
gap: 0.25rem;
}
.countdown-value {
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.countdown-label {
opacity: 0.8;
}
.countdown-divider {
opacity: 0.4;
}
.clock-center {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.clock-display {
display: flex;
align-items: center;
justify-content: center;
}
.customize-link {
position: absolute;
bottom: 1rem;
right: 1rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
transition: color 0.15s ease;
}
.customize-link:hover {
color: hsl(var(--color-foreground));
}
</style>

View file

@ -0,0 +1,698 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import {
lifeClockStore,
type LifeStats,
type MilestoneInfo,
} from '$lib/stores/life-clock.svelte';
import { DotGrid, CircularProgress, YearRings } from '$lib/components/life-clock';
// Visualization types
type VisualizationType = 'circular' | 'dotgrid' | 'rings';
const visualizations: { id: VisualizationType; label: string; icon: string }[] = [
{ id: 'circular', label: 'Kreis', icon: '◐' },
{ id: 'dotgrid', label: 'Raster', icon: '▦' },
{ id: 'rings', label: 'Ringe', icon: '◎' },
];
// Current time for live updates
let now = $state(new Date());
let interval: ReturnType<typeof setInterval> | null = null;
// UI state
let birthdateInput = $state('');
let isEditing = $state(false);
let selectedViz = $state<VisualizationType>('circular');
// Derived values
let hasBirthdate = $derived(lifeClockStore.hasBirthdate);
let stats = $derived(lifeClockStore.getStats(now));
let nextMilestone = $derived(lifeClockStore.getNextMilestone(now));
let milestones = $derived(lifeClockStore.getMilestones(now));
// Format large numbers with dots
function formatNumber(num: number): string {
return num.toLocaleString('de-DE');
}
// Format date for display
function formatDate(date: Date): string {
return date.toLocaleDateString('de-DE', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
function handleSetBirthdate() {
if (birthdateInput) {
lifeClockStore.setBirthdate(birthdateInput);
isEditing = false;
}
}
function handleEdit() {
birthdateInput = lifeClockStore.birthdate || '';
isEditing = true;
}
function handleCancel() {
isEditing = false;
birthdateInput = '';
}
function selectVisualization(viz: VisualizationType) {
selectedViz = viz;
if (browser) {
localStorage.setItem('life-clock-viz', viz);
}
}
onMount(() => {
lifeClockStore.initialize();
if (lifeClockStore.birthdate) {
birthdateInput = lifeClockStore.birthdate;
}
// Load saved visualization preference
const savedViz = localStorage.getItem('life-clock-viz') as VisualizationType | null;
if (savedViz && visualizations.some((v) => v.id === savedViz)) {
selectedViz = savedViz;
}
// Update every second for live counter
interval = setInterval(() => {
now = new Date();
}, 1000);
});
onDestroy(() => {
if (interval) {
clearInterval(interval);
}
});
</script>
<div class="life-page">
{#if !hasBirthdate || isEditing}
<!-- Birthdate Input -->
<div class="setup-container">
<div class="setup-card">
<h2 class="setup-title">Lebensuhr</h2>
<p class="setup-description">
Gib dein Geburtsdatum ein, um zu sehen, wie viele Tage du bereits gelebt hast.
</p>
<div class="input-group">
<input
type="date"
bind:value={birthdateInput}
class="date-input"
max={new Date().toISOString().split('T')[0]}
/>
<div class="button-row">
<button class="btn-primary" onclick={handleSetBirthdate} disabled={!birthdateInput}>
Speichern
</button>
{#if isEditing}
<button class="btn-secondary" onclick={handleCancel}> Abbrechen </button>
{/if}
</div>
</div>
</div>
</div>
{:else if stats}
<!-- Hero Visualization Section -->
<div class="hero-section">
<!-- Visualization Switcher (top right) -->
<div class="viz-switcher">
{#each visualizations as viz}
<button
class="viz-btn"
class:active={selectedViz === viz.id}
onclick={() => selectVisualization(viz.id)}
title={viz.label}
>
<span class="viz-icon">{viz.icon}</span>
</button>
{/each}
</div>
<!-- Large Visualization -->
<div class="visualization-hero">
{#if selectedViz === 'circular'}
<CircularProgress daysLived={stats.daysLived} size={450} />
{:else if selectedViz === 'dotgrid'}
<DotGrid weeksLived={stats.weeksLived} />
{:else if selectedViz === 'rings'}
<YearRings yearsLived={stats.yearsLived} exactAge={stats.exactAge} size={450} />
{/if}
</div>
<!-- Days Counter Overlay -->
<div class="days-overlay">
<span class="days-number">{formatNumber(stats.daysLived)}</span>
<span class="days-label">Tage gelebt</span>
<p class="exact-age">
{stats.exactAge.years} Jahre, {stats.exactAge.months} Monate, {stats.exactAge.days} Tage
</p>
<button class="birthdate-display" onclick={handleEdit}>
seit {formatDate(new Date(lifeClockStore.birthdate!))}
</button>
</div>
</div>
<!-- Content Section -->
<div class="content-section">
<!-- Next Milestone -->
{#if nextMilestone}
<div class="next-milestone">
<span class="milestone-label">Nächster Meilenstein</span>
<span class="milestone-value">{formatNumber(nextMilestone.days)} Tage</span>
<span class="milestone-countdown">in {formatNumber(nextMilestone.daysUntil)} Tagen</span>
</div>
{/if}
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{formatNumber(stats.hoursLived)}</span>
<span class="stat-label">Stunden</span>
</div>
<div class="stat-item">
<span class="stat-value">{formatNumber(stats.minutesLived)}</span>
<span class="stat-label">Minuten</span>
</div>
<div class="stat-item">
<span class="stat-value">{formatNumber(stats.weeksLived)}</span>
<span class="stat-label">Wochen</span>
</div>
<div class="stat-item">
<span class="stat-value">{formatNumber(stats.monthsLived)}</span>
<span class="stat-label">Monate</span>
</div>
</div>
<!-- Two Column Layout -->
<div class="two-columns">
<!-- Fun Facts -->
<div class="fun-facts">
<h3 class="section-title">Ungefähre Schätzungen</h3>
<div class="facts-list">
<div class="fact-item">
<span class="fact-icon">❤️</span>
<div class="fact-content">
<span class="fact-value">~{formatNumber(stats.heartbeats)}</span>
<span class="fact-label">Herzschläge</span>
</div>
</div>
<div class="fact-item">
<span class="fact-icon">🌬️</span>
<div class="fact-content">
<span class="fact-value">~{formatNumber(stats.breaths)}</span>
<span class="fact-label">Atemzüge</span>
</div>
</div>
<div class="fact-item">
<span class="fact-icon">🌅</span>
<div class="fact-content">
<span class="fact-value">{formatNumber(stats.sunrises)}</span>
<span class="fact-label">Sonnenaufgänge</span>
</div>
</div>
<div class="fact-item">
<span class="fact-icon">😴</span>
<div class="fact-content">
<span class="fact-value">~{formatNumber(stats.sleepHours)}</span>
<span class="fact-label">Stunden geschlafen</span>
</div>
</div>
</div>
</div>
<!-- Milestones -->
<div class="milestones-section">
<h3 class="section-title">Meilensteine</h3>
<div class="milestones-list">
{#each milestones as milestone}
<div class="milestone-item" class:reached={milestone.reached}>
<span class="milestone-check">{milestone.reached ? '✓' : '○'}</span>
<span class="milestone-days">{formatNumber(milestone.days)} Tage</span>
{#if !milestone.reached}
<span class="milestone-until">in {formatNumber(milestone.daysUntil)} Tagen</span>
{/if}
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
.life-page {
width: 100%;
margin: -1rem;
margin-bottom: 0;
}
@media (min-width: 640px) {
.life-page {
margin: -1.5rem;
margin-bottom: 0;
}
}
@media (min-width: 1024px) {
.life-page {
margin: -2rem;
margin-bottom: 0;
}
}
/* Setup */
.setup-container {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 200px);
}
.setup-card {
text-align: center;
max-width: 400px;
padding: 2rem;
}
.setup-title {
font-size: 1.5rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin-bottom: 0.5rem;
}
.setup-description {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-bottom: 1.5rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.date-input {
padding: 0.75rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 1rem;
text-align: center;
}
.date-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: center;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.5rem;
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s ease;
}
.btn-secondary:hover {
opacity: 0.8;
}
/* Hero Section */
.hero-section {
position: relative;
width: 100vw;
min-height: 70vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
180deg,
hsl(var(--color-muted) / 0.08) 0%,
hsl(var(--color-muted) / 0.03) 50%,
transparent 100%
);
border-bottom: 1px solid hsl(var(--color-border));
padding: 3rem 2rem 5rem;
}
/* Viz Switcher */
.viz-switcher {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
gap: 0.25rem;
background: hsl(var(--color-background) / 0.8);
backdrop-filter: blur(8px);
padding: 0.25rem;
border-radius: var(--radius-md);
border: 1px solid hsl(var(--color-border));
}
.viz-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s ease;
}
.viz-btn:hover {
background: hsl(var(--color-muted) / 0.5);
color: hsl(var(--color-foreground));
}
.viz-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.viz-icon {
font-size: 1.25rem;
}
/* Visualization Hero */
.visualization-hero {
width: 100%;
max-width: 900px;
display: flex;
justify-content: center;
padding: 0 1rem;
}
/* Days Overlay - positioned below viz for circular/rings, hidden for dotgrid */
.days-overlay {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
text-align: center;
background: hsl(var(--color-background) / 0.95);
backdrop-filter: blur(12px);
padding: 1.25rem 2.5rem;
border-radius: var(--radius-lg);
border: 1px solid hsl(var(--color-border));
box-shadow: 0 4px 20px hsl(var(--color-foreground) / 0.05);
}
.days-number {
display: block;
font-size: 3rem;
font-weight: 200;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
line-height: 1;
}
.days-label {
display: block;
font-size: 1rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.375rem;
}
.exact-age {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.5rem;
}
.birthdate-display {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
margin-top: 0.25rem;
border-radius: var(--radius-sm);
transition: color 0.15s ease;
}
.birthdate-display:hover {
color: hsl(var(--color-foreground));
}
/* Content Section */
.content-section {
max-width: 900px;
margin: 0 auto;
padding: 2rem 1rem 3rem;
}
/* Next Milestone */
.next-milestone {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 2rem;
padding: 1.25rem;
background: hsl(var(--color-primary) / 0.1);
border: 1px solid hsl(var(--color-primary) / 0.2);
border-radius: var(--radius-lg);
}
.next-milestone .milestone-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
}
.next-milestone .milestone-value {
font-size: 1.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
margin-top: 0.25rem;
}
.next-milestone .milestone-countdown {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
margin-bottom: 2rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: var(--radius-md);
}
.stat-value {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.stat-label {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.25rem;
}
/* Two Columns */
.two-columns {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
}
/* Fun Facts */
.facts-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.fact-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: var(--radius-md);
}
.fact-icon {
font-size: 1.5rem;
}
.fact-content {
display: flex;
flex-direction: column;
}
.fact-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.fact-label {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
/* Milestones */
.milestones-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.milestone-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--color-muted) / 0.2);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.milestone-item.reached {
opacity: 0.5;
}
.milestone-check {
color: hsl(var(--color-primary));
font-weight: 600;
}
.milestone-days {
font-weight: 500;
color: hsl(var(--color-foreground));
}
.milestone-until {
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* Responsive */
@media (min-width: 640px) {
.hero-section {
min-height: 75vh;
padding: 4rem 3rem 6rem;
}
.days-number {
font-size: 3.5rem;
}
.days-overlay {
padding: 1.5rem 3rem;
}
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.two-columns {
grid-template-columns: 1fr 1fr;
}
.content-section {
padding: 3rem 2rem 4rem;
}
}
@media (min-width: 1024px) {
.hero-section {
min-height: 80vh;
padding: 5rem 4rem 7rem;
}
.days-number {
font-size: 4rem;
}
.visualization-hero {
max-width: 1000px;
}
}
@media (min-width: 1280px) {
.hero-section {
min-height: 85vh;
}
.visualization-hero {
max-width: 1100px;
}
}
</style>