mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 20:29:42 +02:00
✨ 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:
parent
6080902444
commit
03b77eec46
8 changed files with 1864 additions and 124 deletions
|
|
@ -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>
|
||||
333
apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte
Normal file
333
apps/clock/apps/web/src/lib/components/life-clock/DotGrid.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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';
|
||||
221
apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts
Normal file
221
apps/clock/apps/web/src/lib/stores/life-clock.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
698
apps/clock/apps/web/src/routes/life/+page.svelte
Normal file
698
apps/clock/apps/web/src/routes/life/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue