Files
homepage/src/routes/fitness/[stats=fitnessStats]/+page.svelte
Alexander Bocken 27a29b6f69
All checks were successful
CI / update (push) Successful in 2m23s
fitness: use time-scale x-axis for weight chart to handle date gaps
Weight chart now spaces data points proportionally to actual dates
instead of evenly. Days without a weight log no longer compress adjacent
points together. Uses Chart.js time scale with chartjs-adapter-date-fns.
2026-03-30 09:00:17 +02:00

523 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { page } from '$app/stores';
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
import { Dumbbell, Route, Flame, Weight } from 'lucide-svelte';
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
let { data } = $props();
function checkDark() {
if (typeof document === 'undefined') return false;
const t = document.documentElement.dataset.theme;
if (t === 'dark') return true;
if (t === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
let dark = $state(checkDark());
onMount(() => {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onMql = () => { dark = checkDark(); };
mql.addEventListener('change', onMql);
const obs = new MutationObserver(() => { dark = checkDark(); });
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => { mql.removeEventListener('change', onMql); obs.disconnect(); };
});
const primary = $derived(dark ? '#88C0D0' : '#5E81AC');
const primaryFill = $derived(dark ? 'rgba(136, 192, 208, 0.15)' : 'rgba(94, 129, 172, 0.15)');
const stats = $derived(data.stats ?? {});
let goalStreak = $state(data.goal?.streak ?? 0);
let goalWeekly = $state(data.goal?.weeklyWorkouts ?? null);
let goalEditing = $state(false);
let goalInput = $state(4);
let goalSaving = $state(false);
const hasDemographics = $derived(data.goal?.sex != null && data.goal?.heightCm != null);
function startGoalEdit() {
goalInput = goalWeekly ?? 4;
goalEditing = true;
}
async function saveGoal() {
goalSaving = true;
try {
const res = await fetch('/api/fitness/goal', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ weeklyWorkouts: goalInput })
});
if (res.ok) {
const d = await res.json();
goalWeekly = d.weeklyWorkouts;
goalStreak = d.streak;
goalEditing = false;
} else {
const err = await res.json().catch(() => null);
toast.error(err?.error ?? 'Failed to save goal');
}
} catch { toast.error('Failed to save goal'); } finally {
goalSaving = false;
}
}
const workoutsChartData = $derived({
labels: stats.workoutsChart?.labels ?? [],
datasets: [{
label: 'Workouts',
data: stats.workoutsChart?.data ?? [],
backgroundColor: primary
}]
});
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
const weightChartData = $derived({
labels: stats.weightChart?.labels ?? [],
dates: stats.weightChart?.dates,
datasets: [
...(hasSma ? [
{
label: '± 1σ',
data: stats.weightChart.upper,
borderColor: 'transparent',
backgroundColor: primaryFill,
fill: '+1',
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: '± 1σ (lower)',
data: stats.weightChart.lower,
borderColor: 'transparent',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: 'Trend',
data: stats.weightChart.sma,
borderColor: primary,
pointRadius: 0,
borderWidth: 3,
tension: 0.3,
order: 1
}
] : []),
{
label: 'Weight (kg)',
data: stats.weightChart?.data ?? [],
borderColor: '#A3BE8C',
borderWidth: hasSma ? 1 : 2,
pointRadius: 3,
order: 0
}
]
});
</script>
<svelte:head><title>{t('stats_title', lang)} - Bocken</title></svelte:head>
<div class="stats-page">
<h1>{t('stats_title', lang)}</h1>
<div class="lifetime-cards">
<div class="lifetime-card workouts">
<div class="card-icon"><Dumbbell size={24} /></div>
<div class="card-value">{stats.totalWorkouts ?? 0}</div>
<div class="card-label">{(stats.totalWorkouts ?? 0) === 1 ? t('workout_singular', lang) : t('workouts_plural', lang)}</div>
</div>
<div class="lifetime-card tonnage">
<div class="card-icon"><Weight size={24} /></div>
<div class="card-value">{stats.totalTonnage ?? 0}<span class="card-unit">t</span></div>
<div class="card-label">{t('lifted', lang)}</div>
</div>
{#if stats.kcalEstimate}
<div class="lifetime-card kcal">
<div class="card-icon"><Flame size={24} /></div>
<div class="card-value">~{stats.kcalEstimate.kcal.toLocaleString()}<span class="card-unit">kcal</span></div>
<div class="card-label">{t('burned', lang)}</div>
{#if !hasDemographics}
<div class="card-hint">{t('kcal_set_profile', lang)} <a href="/fitness/{fitnessSlugs(lang).measure}">{t('measure_title', lang)}</a></div>
{/if}
</div>
{/if}
<div class="lifetime-card cardio">
<div class="card-icon"><Route size={24} /></div>
<div class="card-value">{stats.totalCardioKm ?? 0}<span class="card-unit">km</span></div>
<div class="card-label">{t('covered', lang)}</div>
</div>
</div>
{#if goalEditing}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="goal-editor-overlay" onkeydown={(e) => { if (e.key === 'Escape') goalEditing = false; }} role="dialog" tabindex="-1">
<div class="goal-editor-backdrop" onclick={() => goalEditing = false} onkeydown={(e) => { if (e.key === 'Escape') goalEditing = false; }} role="presentation"></div>
<div class="goal-editor-panel">
<h3>{t('weekly_goal', lang)}</h3>
<div class="goal-input-row">
<button class="adj-btn" onclick={() => { if (goalInput > 1) goalInput--; }} disabled={goalInput <= 1}>-</button>
<span class="goal-value">{goalInput}</span>
<button class="adj-btn" onclick={() => { if (goalInput < 14) goalInput++; }} disabled={goalInput >= 14}>+</button>
</div>
<span class="goal-unit">{t('workouts_per_week_goal', lang)}</span>
<div class="goal-actions">
<button class="goal-save" onclick={saveGoal} disabled={goalSaving}>
{goalSaving ? t('saving', lang) : t('save', lang)}
</button>
<button class="goal-cancel" onclick={() => goalEditing = false}>{t('cancel', lang)}</button>
</div>
</div>
</div>
{/if}
<div class="chart-streak-row">
<div class="chart-streak-chart">
{#if (stats.workoutsChart?.data?.length ?? 0) > 0}
<FitnessChart
type="bar"
data={workoutsChartData}
title={t('workouts_per_week', lang)}
height="220px"
goalLine={goalWeekly ?? undefined}
/>
{:else}
<p class="empty-chart">{t('no_workout_data', lang)}</p>
{/if}
</div>
<button class="streak-section" onclick={startGoalEdit}>
<FitnessStreakAura value={goalStreak} />
<div class="streak-meta">
<span class="streak-unit">{goalStreak === 1 ? t('streak_week', lang) : t('streak_weeks', lang)}</span>
<span class="streak-label">{t('streak', lang)}</span>
{#if goalWeekly !== null}
<span class="streak-goal">{goalWeekly}x / {t('streak_week', lang).toLowerCase()}</span>
{:else}
<span class="streak-goal">{t('set_goal', lang)}</span>
{/if}
</div>
</button>
</div>
{#if (stats.weightChart?.data?.length ?? 0) > 1}
<FitnessChart
data={weightChartData}
title={t('weight', lang)}
yUnit=" kg"
height="220px"
/>
{/if}
</div>
<style>
.stats-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
.lifetime-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.6rem;
}
.lifetime-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
padding: 1rem 0.5rem;
border-radius: 12px;
background: var(--color-surface);
box-shadow: var(--shadow-sm);
text-align: center;
position: relative;
}
.lifetime-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: 12px;
opacity: 0.08;
}
.lifetime-card.workouts::before {
background: var(--color-primary);
}
.lifetime-card.tonnage::before {
background: var(--nord10);
}
.lifetime-card.kcal::before {
background: var(--nord12);
}
.lifetime-card.cardio::before {
background: var(--nord14);
}
.card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
margin-bottom: 0.15rem;
}
.workouts .card-icon {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.tonnage .card-icon {
color: var(--nord10);
background: color-mix(in srgb, var(--nord10) 15%, transparent);
}
.kcal .card-icon {
color: var(--nord12);
background: color-mix(in srgb, var(--nord12) 15%, transparent);
}
.cardio .card-icon {
color: var(--nord14);
background: color-mix(in srgb, var(--nord14) 15%, transparent);
}
.card-value {
font-size: 1.4rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.card-unit {
font-size: 0.7rem;
font-weight: 600;
opacity: 0.6;
margin-left: 0.15rem;
}
.card-label {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.card-hint {
font-size: 0.55rem;
color: var(--color-text-secondary);
opacity: 0.7;
margin-top: 0.1rem;
line-height: 1.3;
}
.card-hint a {
color: var(--nord12);
text-decoration: underline;
}
/* Chart + Streak row */
.chart-streak-row {
display: flex;
gap: 1rem;
align-items: stretch;
}
.chart-streak-chart {
flex: 1;
min-width: 0;
}
/* Streak section */
.streak-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: var(--color-surface);
border: none;
border-radius: 12px;
box-shadow: var(--shadow-sm);
cursor: pointer;
font-family: inherit;
color: inherit;
transition: box-shadow 0.15s;
}
.streak-section:hover {
box-shadow: var(--shadow-sm), 0 0 0 2px var(--nord13);
}
.streak-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
}
.streak-unit {
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.streak-label {
font-size: 1rem;
font-weight: 700;
color: var(--nord13);
}
.streak-goal {
font-size: 0.7rem;
color: var(--color-text-secondary);
opacity: 0.7;
}
@media (max-width: 600px) {
.lifetime-cards {
grid-template-columns: repeat(2, 1fr);
}
.chart-streak-row {
flex-direction: column-reverse;
}
.streak-section {
flex-direction: row;
gap: 1rem;
}
.streak-meta {
align-items: flex-start;
}
}
@media (max-width: 400px) {
.lifetime-cards {
grid-template-columns: 1fr;
}
.lifetime-card {
flex-direction: row;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
text-align: left;
}
.card-icon {
margin-bottom: 0;
}
}
/* Goal editor overlay */
.goal-editor-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
}
.goal-editor-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.goal-editor-panel {
position: relative;
background: var(--color-surface);
border-radius: 16px;
padding: 1.5rem 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
min-width: 240px;
}
.goal-editor-panel h3 {
margin: 0;
font-size: 1.1rem;
}
.goal-input-row {
display: flex;
align-items: center;
gap: 1rem;
}
.adj-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: 1.5px solid var(--color-border, var(--nord3));
background: transparent;
color: inherit;
font-size: 1.3rem;
font-weight: 600;
cursor: pointer;
display: grid;
place-items: center;
font-family: inherit;
transition: background 0.15s;
}
.adj-btn:hover:not(:disabled) {
background: var(--nord13);
color: var(--nord0);
border-color: var(--nord13);
}
.adj-btn:disabled {
opacity: 0.3;
cursor: default;
}
.goal-value {
font-size: 2rem;
font-weight: 700;
min-width: 2ch;
text-align: center;
}
.goal-unit {
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.goal-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.goal-save {
padding: 0.4rem 1rem;
border: none;
border-radius: 8px;
background: var(--nord13);
color: var(--nord0);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
.goal-save:disabled {
opacity: 0.6;
cursor: default;
}
.goal-cancel {
padding: 0.4rem 1rem;
border: none;
border-radius: 8px;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.04em;
font-family: inherit;
}
.empty-chart {
text-align: center;
color: var(--color-text-secondary);
padding: 2rem 0;
}
</style>