feat: add nutrition statistics to fitness stats page
All checks were successful
CI / update (push) Successful in 3m37s
All checks were successful
CI / update (push) Successful in 3m37s
Add protein g/kg (7-day avg using trend weight), calorie balance (surplus/deficit vs goal), diet adherence (since first tracked day), and macro split rings with target markers to the stats dashboard.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -338,6 +338,23 @@ const translations: Translations = {
|
||||
secondary_muscles: { en: 'Secondary', de: 'Sekundär' },
|
||||
play_video: { en: 'Play Video', de: 'Video abspielen' },
|
||||
|
||||
// Nutrition stats
|
||||
nutrition_stats: { en: 'Nutrition', de: 'Ernährung' },
|
||||
protein_per_kg: { en: 'Protein', de: 'Protein' },
|
||||
protein_per_kg_unit: { en: 'g/kg', de: 'g/kg' },
|
||||
calorie_balance: { en: 'Calorie Balance', de: 'Kalorienbilanz' },
|
||||
calorie_balance_unit: { en: 'kcal/day', de: 'kcal/Tag' },
|
||||
diet_adherence: { en: 'Adherence', de: 'Einhaltung' },
|
||||
seven_day_avg: { en: '7-day avg', de: '7-Tage-Ø' },
|
||||
thirty_day: { en: '30 days', de: '30 Tage' },
|
||||
macro_split: { en: 'Macro Split', de: 'Makroverteilung' },
|
||||
no_nutrition_data: { en: 'No nutrition data yet. Start logging food to see stats.', de: 'Noch keine Ernährungsdaten. Beginne mit dem Tracking.' },
|
||||
target: { en: 'Target', de: 'Ziel' },
|
||||
days_tracked: { en: 'days tracked', de: 'Tage erfasst' },
|
||||
since_start: { en: 'Since start', de: 'Seit Beginn' },
|
||||
no_weight_data: { en: 'Log weight to enable', de: 'Gewicht eintragen' },
|
||||
no_calorie_goal: { en: 'Set calorie goal', de: 'Kalorienziel setzen' },
|
||||
|
||||
// Muscle heatmap
|
||||
muscle_balance: { en: 'Muscle Balance', de: 'Muskelbalance' },
|
||||
weekly_sets: { en: 'Sets per week', de: 'Sätze pro Woche' },
|
||||
|
||||
160
src/routes/api/fitness/stats/nutrition/+server.ts
Normal file
160
src/routes/api/fitness/stats/nutrition/+server.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { requireAuth } from '$lib/server/middleware/auth';
|
||||
import { dbConnect } from '$utils/db';
|
||||
import { FoodLogEntry } from '$models/FoodLogEntry';
|
||||
import { FitnessGoal } from '$models/FitnessGoal';
|
||||
import { BodyMeasurement } from '$models/BodyMeasurement';
|
||||
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
const user = await requireAuth(locals);
|
||||
await dbConnect();
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now);
|
||||
sevenDaysAgo.setUTCDate(sevenDaysAgo.getUTCDate() - 6);
|
||||
sevenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const thirtyDaysAgo = new Date(now);
|
||||
thirtyDaysAgo.setUTCDate(thirtyDaysAgo.getUTCDate() - 29);
|
||||
thirtyDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const todayEnd = new Date(now);
|
||||
todayEnd.setUTCDate(todayEnd.getUTCDate() + 1);
|
||||
todayEnd.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const [entries30d, goal, weightMeasurements] = await Promise.all([
|
||||
FoodLogEntry.find({
|
||||
createdBy: user.nickname,
|
||||
date: { $gte: thirtyDaysAgo, $lt: todayEnd },
|
||||
mealType: { $ne: 'water' },
|
||||
}).lean() as any,
|
||||
FitnessGoal.findOne({ username: user.nickname }).lean() as any,
|
||||
BodyMeasurement.find(
|
||||
{ createdBy: user.nickname, weight: { $ne: null } },
|
||||
{ date: 1, weight: 1, _id: 0 }
|
||||
).sort({ date: -1 }).limit(14).lean() as any[],
|
||||
]);
|
||||
|
||||
// Compute trend weight (SMA of last measurements, same algo as overview)
|
||||
let trendWeight: number | null = null;
|
||||
if (weightMeasurements.length > 0) {
|
||||
const weights = weightMeasurements.slice().reverse().map((m: any) => m.weight as number);
|
||||
const w = Math.min(7, Math.max(2, Math.floor(weights.length / 2)));
|
||||
const lastIdx = weights.length - 1;
|
||||
const k = Math.min(w, lastIdx + 1);
|
||||
let sum = 0;
|
||||
for (let j = lastIdx - k + 1; j <= lastIdx; j++) sum += weights[j];
|
||||
trendWeight = Math.round((sum / k) * 100) / 100;
|
||||
}
|
||||
|
||||
// Group entries by date string
|
||||
const byDate = new Map<string, typeof entries30d>();
|
||||
for (const entry of entries30d) {
|
||||
const key = new Date(entry.date).toISOString().slice(0, 10);
|
||||
if (!byDate.has(key)) byDate.set(key, []);
|
||||
byDate.get(key)!.push(entry);
|
||||
}
|
||||
|
||||
// Compute daily totals
|
||||
const dailyTotals: { date: string; calories: number; protein: number; fat: number; carbs: number }[] = [];
|
||||
for (const [date, dayEntries] of byDate) {
|
||||
let calories = 0, protein = 0, fat = 0, carbs = 0;
|
||||
for (const e of dayEntries) {
|
||||
const mult = (e.amountGrams ?? 0) / 100;
|
||||
calories += (e.per100g?.calories ?? 0) * mult;
|
||||
protein += (e.per100g?.protein ?? 0) * mult;
|
||||
fat += (e.per100g?.fat ?? 0) * mult;
|
||||
carbs += (e.per100g?.carbs ?? 0) * mult;
|
||||
}
|
||||
dailyTotals.push({ date, calories, protein, fat, carbs });
|
||||
}
|
||||
|
||||
const dailyCalorieGoal = goal?.dailyCalories ?? null;
|
||||
|
||||
// 7-day averages (only days with logged entries)
|
||||
const sevenDayStr = sevenDaysAgo.toISOString().slice(0, 10);
|
||||
const recent7 = dailyTotals.filter(d => d.date >= sevenDayStr);
|
||||
|
||||
let avgProteinPerKg: number | null = null;
|
||||
let avgCalorieBalance: number | null = null;
|
||||
let macroSplit: { protein: number; fat: number; carbs: number } | null = null;
|
||||
|
||||
if (recent7.length > 0) {
|
||||
const avgProtein = recent7.reduce((s, d) => s + d.protein, 0) / recent7.length;
|
||||
const avgCalories = recent7.reduce((s, d) => s + d.calories, 0) / recent7.length;
|
||||
const avgFat = recent7.reduce((s, d) => s + d.fat, 0) / recent7.length;
|
||||
const avgCarbs = recent7.reduce((s, d) => s + d.carbs, 0) / recent7.length;
|
||||
|
||||
if (trendWeight) {
|
||||
avgProteinPerKg = Math.round((avgProtein / trendWeight) * 100) / 100;
|
||||
}
|
||||
|
||||
if (dailyCalorieGoal) {
|
||||
avgCalorieBalance = Math.round(avgCalories - dailyCalorieGoal);
|
||||
}
|
||||
|
||||
// Macro split by calorie contribution
|
||||
const proteinCal = avgProtein * 4;
|
||||
const fatCal = avgFat * 9;
|
||||
const carbsCal = avgCarbs * 4;
|
||||
const totalCal = proteinCal + fatCal + carbsCal;
|
||||
if (totalCal > 0) {
|
||||
macroSplit = {
|
||||
protein: Math.round(proteinCal / totalCal * 100),
|
||||
fat: Math.round(fatCal / totalCal * 100),
|
||||
carbs: 100 - Math.round(proteinCal / totalCal * 100) - Math.round(fatCal / totalCal * 100),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Adherence: % of days within ±10% of calorie goal.
|
||||
// Range: from first tracked day (within 30-day window) to today.
|
||||
// Untracked days between first tracked day and today count as misses.
|
||||
let adherencePercent: number | null = null;
|
||||
let adherenceDays: number | null = null;
|
||||
if (dailyCalorieGoal && dailyTotals.length > 0) {
|
||||
const sortedDates = dailyTotals.map(d => d.date).sort();
|
||||
const firstTracked = sortedDates[0];
|
||||
const todayStr = now.toISOString().slice(0, 10);
|
||||
// Count calendar days from first tracked day to today (inclusive)
|
||||
const firstDate = new Date(firstTracked + 'T00:00:00Z');
|
||||
const todayDate = new Date(todayStr + 'T00:00:00Z');
|
||||
const totalDays = Math.round((todayDate.getTime() - firstDate.getTime()) / 86400000) + 1;
|
||||
|
||||
const lower = dailyCalorieGoal * 0.9;
|
||||
const upper = dailyCalorieGoal * 1.1;
|
||||
const withinRange = dailyTotals.filter(d => d.calories >= lower && d.calories <= upper).length;
|
||||
adherenceDays = totalDays;
|
||||
adherencePercent = Math.round(withinRange / totalDays * 100);
|
||||
}
|
||||
|
||||
// Macro targets from goal
|
||||
let macroTargets: { protein: number | null; fat: number | null; carbs: number | null } = {
|
||||
protein: null, fat: null, carbs: null
|
||||
};
|
||||
if (goal) {
|
||||
// Compute protein percent of calories
|
||||
if (goal.proteinTarget && dailyCalorieGoal) {
|
||||
let proteinGrams = goal.proteinTarget;
|
||||
if (goal.proteinMode === 'per_kg' && trendWeight) {
|
||||
proteinGrams = goal.proteinTarget * trendWeight;
|
||||
}
|
||||
macroTargets.protein = Math.round((proteinGrams * 4) / dailyCalorieGoal * 100);
|
||||
}
|
||||
if (goal.fatPercent != null) macroTargets.fat = goal.fatPercent;
|
||||
if (goal.carbPercent != null) macroTargets.carbs = goal.carbPercent;
|
||||
}
|
||||
|
||||
return json({
|
||||
avgProteinPerKg,
|
||||
avgCalorieBalance,
|
||||
adherencePercent,
|
||||
adherenceDays,
|
||||
macroSplit,
|
||||
macroTargets,
|
||||
trendWeight,
|
||||
daysTracked7: recent7.length,
|
||||
daysTracked30: dailyTotals.length,
|
||||
});
|
||||
};
|
||||
@@ -2,13 +2,15 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const session = await locals.auth();
|
||||
const [res, goalRes, heatmapRes] = await Promise.all([
|
||||
const [res, goalRes, heatmapRes, nutritionRes] = await Promise.all([
|
||||
fetch('/api/fitness/stats/overview'),
|
||||
fetch('/api/fitness/goal'),
|
||||
fetch('/api/fitness/stats/muscle-heatmap?weeks=8')
|
||||
fetch('/api/fitness/stats/muscle-heatmap?weeks=8'),
|
||||
fetch('/api/fitness/stats/nutrition')
|
||||
]);
|
||||
const stats = await res.json();
|
||||
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
|
||||
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
|
||||
return { session, stats, goal, muscleHeatmap };
|
||||
const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null;
|
||||
return { session, stats, goal, muscleHeatmap, nutritionStats };
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import FitnessChart from '$lib/components/fitness/FitnessChart.svelte';
|
||||
import MuscleHeatmap from '$lib/components/fitness/MuscleHeatmap.svelte';
|
||||
import { Dumbbell, Route, Flame, Weight } from '@lucide/svelte';
|
||||
import { Dumbbell, Route, Flame, Weight, Beef, Scale, Target } from '@lucide/svelte';
|
||||
import FitnessStreakAura from '$lib/components/fitness/FitnessStreakAura.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
|
||||
@@ -79,6 +79,47 @@
|
||||
}]
|
||||
});
|
||||
|
||||
const ns = $derived(data.nutritionStats);
|
||||
|
||||
// Macro ring SVG parameters — 300° arc with 60° gap at bottom
|
||||
const RADIUS = 28;
|
||||
const ARC_DEGREES = 300;
|
||||
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
|
||||
const ARC_ROTATE = 120; // gap centered at bottom
|
||||
|
||||
/** @param {number} percent */
|
||||
function strokeOffset(percent) {
|
||||
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SVG coordinates for a triangle marker at a given percentage on the arc.
|
||||
* @param {number} percent
|
||||
*/
|
||||
function targetMarkerPos(percent) {
|
||||
// Arc starts at ARC_ROTATE degrees (120° = 7 o'clock in SVG coords) and sweeps 300° clockwise
|
||||
const startAngle = ARC_ROTATE;
|
||||
const angleDeg = startAngle + (percent / 100) * ARC_DEGREES;
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
const outerR = RADIUS + 7;
|
||||
const cx = 35 + outerR * Math.cos(angleRad);
|
||||
const cy = 35 + outerR * Math.sin(angleRad);
|
||||
// Label: primarily radial (along center→marker line), with tangential
|
||||
// nudge only near 50% where the label would sit right at the top
|
||||
const closeness = 1 - Math.abs(percent - 50) / 50; // 0 at edges, 1 at 50%
|
||||
// Base radial distance: extra +4 for >50% values outside the close-to-50 zone
|
||||
const highBonus = percent > 50 && closeness < 0.4 ? 4 : 0;
|
||||
// Bump for the 30-70% zone (peaks at 40% and 60%)
|
||||
const midBump = Math.max(0, 1 - Math.abs(closeness - 0.2) / 0.3) * 4;
|
||||
const labelR = outerR + 17 + highBonus + midBump - closeness * closeness * 14;
|
||||
const tOff = closeness * closeness * 14; // quadratic: stronger nudge near 50%
|
||||
const dir = percent < 50 ? -1 : 1;
|
||||
const tangentRad = angleRad + dir * Math.PI / 2;
|
||||
const lx = 35 + labelR * Math.cos(angleRad) + tOff * Math.cos(tangentRad);
|
||||
const ly = 35 + labelR * Math.sin(angleRad) + tOff * Math.sin(tangentRad);
|
||||
return { cx, cy, lx, ly, angleDeg };
|
||||
}
|
||||
|
||||
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
|
||||
|
||||
const weightChartData = $derived({
|
||||
@@ -223,6 +264,118 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if ns}
|
||||
<div class="nutrition-grid">
|
||||
<div class="lifetime-card protein-card">
|
||||
<div class="card-icon"><Beef size={24} /></div>
|
||||
{#if ns.avgProteinPerKg != null}
|
||||
<div class="card-value">{ns.avgProteinPerKg.toFixed(1)}<span class="card-unit">{t('protein_per_kg_unit', lang)}</span></div>
|
||||
{:else}
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label">{t('protein_per_kg', lang)}</div>
|
||||
<div class="card-hint">
|
||||
{#if ns.avgProteinPerKg != null}
|
||||
{t('seven_day_avg', lang)}
|
||||
{:else if !ns.trendWeight}
|
||||
{t('no_weight_data', lang)}
|
||||
{:else}
|
||||
{t('no_nutrition_data', lang)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lifetime-card balance-card" class:surplus={ns.avgCalorieBalance > 0} class:deficit={ns.avgCalorieBalance < 0}>
|
||||
<div class="card-icon"><Scale size={24} /></div>
|
||||
{#if ns.avgCalorieBalance != null}
|
||||
<div class="card-value" class:positive={ns.avgCalorieBalance > 0} class:negative={ns.avgCalorieBalance < 0}>
|
||||
{ns.avgCalorieBalance > 0 ? '+' : ''}{ns.avgCalorieBalance}<span class="card-unit">{t('calorie_balance_unit', lang)}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label">{t('calorie_balance', lang)}</div>
|
||||
<div class="card-hint">
|
||||
{#if ns.avgCalorieBalance != null}
|
||||
{t('seven_day_avg', lang)}
|
||||
{:else}
|
||||
{t('no_calorie_goal', lang)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lifetime-card adherence-card">
|
||||
<div class="card-icon"><Target size={24} /></div>
|
||||
{#if ns.adherencePercent != null}
|
||||
<div class="card-value">{ns.adherencePercent}<span class="card-unit">%</span></div>
|
||||
{:else}
|
||||
<div class="card-value card-value-na">—</div>
|
||||
{/if}
|
||||
<div class="card-label">{t('diet_adherence', lang)}</div>
|
||||
<div class="card-hint">
|
||||
{#if ns.adherencePercent != null}
|
||||
{t('since_start', lang)} ({ns.adherenceDays} {t('days', lang)})
|
||||
{:else}
|
||||
{t('no_calorie_goal', lang)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if ns.macroSplit}
|
||||
<div class="lifetime-card macro-card">
|
||||
<div class="macro-header">{t('macro_split', lang)} <span class="macro-subtitle">({t('seven_day_avg', lang)})</span></div>
|
||||
<div class="macro-rings">
|
||||
{#each [
|
||||
{ pct: ns.macroSplit.protein, target: ns.macroTargets?.protein, label: t('protein', lang), cls: 'ring-protein', fill: '#a3be8c' },
|
||||
{ pct: ns.macroSplit.fat, target: ns.macroTargets?.fat, label: t('fat', lang), cls: 'ring-fat', fill: '#d08770' },
|
||||
{ pct: ns.macroSplit.carbs, target: ns.macroTargets?.carbs, label: t('carbs', lang), cls: 'ring-carbs', fill: '#81a1c1' },
|
||||
] as macro (macro.cls)}
|
||||
<div class="macro-ring">
|
||||
<svg class="macro-ring-svg" viewBox="0 0 70 70">
|
||||
<circle
|
||||
class="ring-bg"
|
||||
cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
transform="rotate({ARC_ROTATE} 35 35)"
|
||||
/>
|
||||
<circle
|
||||
class="ring-fill {macro.cls}"
|
||||
cx="35" cy="35" r={RADIUS}
|
||||
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
|
||||
stroke-dashoffset={strokeOffset(macro.pct)}
|
||||
transform="rotate({ARC_ROTATE} 35 35)"
|
||||
/>
|
||||
{#if macro.target != null}
|
||||
{@const pos = targetMarkerPos(macro.target)}
|
||||
<path
|
||||
fill={macro.fill}
|
||||
opacity="0.85"
|
||||
stroke={macro.fill}
|
||||
stroke-width="0.8"
|
||||
stroke-linejoin="round"
|
||||
d="M{pos.cx},{pos.cy - 3.5}L{pos.cx - 3},{pos.cy + 2.5}L{pos.cx + 3},{pos.cy + 2.5}Z"
|
||||
transform="rotate({pos.angleDeg - 90} {pos.cx} {pos.cy})"
|
||||
/>
|
||||
<text
|
||||
class="target-label"
|
||||
fill={macro.fill}
|
||||
x={pos.lx}
|
||||
y={pos.ly}
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>{macro.target}%</text>
|
||||
{/if}
|
||||
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
|
||||
</svg>
|
||||
<span class="macro-label">{macro.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section-block">
|
||||
<h2 class="section-title">{t('muscle_balance', lang)}</h2>
|
||||
<MuscleHeatmap data={data.muscleHeatmap} />
|
||||
@@ -520,6 +673,158 @@
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Nutrition masonry grid */
|
||||
.nutrition-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.protein-card::before { background: var(--nord14); }
|
||||
.balance-card::before { background: var(--color-text-secondary); }
|
||||
.balance-card.surplus::before { background: var(--nord14); }
|
||||
.balance-card.deficit::before { background: var(--nord11); }
|
||||
.adherence-card::before { background: var(--nord13); }
|
||||
.macro-card::before { background: var(--color-primary); }
|
||||
.protein-card .card-icon {
|
||||
color: var(--nord14);
|
||||
background: color-mix(in srgb, var(--nord14) 15%, transparent);
|
||||
}
|
||||
.balance-card .card-icon {
|
||||
color: var(--color-text-secondary);
|
||||
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
|
||||
}
|
||||
.balance-card.surplus .card-icon {
|
||||
color: var(--nord14);
|
||||
background: color-mix(in srgb, var(--nord14) 15%, transparent);
|
||||
}
|
||||
.balance-card.deficit .card-icon {
|
||||
color: var(--nord11);
|
||||
background: color-mix(in srgb, var(--nord11) 15%, transparent);
|
||||
}
|
||||
.adherence-card .card-icon {
|
||||
color: var(--nord13);
|
||||
background: color-mix(in srgb, var(--nord13) 15%, transparent);
|
||||
}
|
||||
.nutrition-grid .card-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nutrition-grid .card-hint {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.card-value.positive { color: var(--nord14); }
|
||||
.card-value.negative { color: var(--nord11); }
|
||||
.card-value-na {
|
||||
color: var(--color-text-secondary);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Macro split card — spans full row, horizontal layout */
|
||||
.macro-card {
|
||||
grid-column: 1 / -1;
|
||||
padding: 1rem 1.25rem;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: 1.25rem !important;
|
||||
}
|
||||
.macro-header {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.macro-subtitle {
|
||||
display: block;
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.macro-rings {
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
.macro-ring {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
flex: 1;
|
||||
max-width: 130px;
|
||||
}
|
||||
.macro-ring-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 110px;
|
||||
overflow: visible;
|
||||
}
|
||||
.ring-bg {
|
||||
fill: none;
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.ring-fill {
|
||||
fill: none;
|
||||
stroke-width: 5;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dashoffset 0.4s ease;
|
||||
}
|
||||
.ring-text {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
fill: currentColor;
|
||||
text-anchor: middle;
|
||||
dominant-baseline: central;
|
||||
}
|
||||
.ring-protein { stroke: var(--nord14, #a3be8c); }
|
||||
.ring-fat { stroke: var(--nord12, #d08770); }
|
||||
.ring-carbs { stroke: var(--nord9, #81a1c1); }
|
||||
.macro-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
.target-label {
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.nutrition-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.macro-card {
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.macro-header {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@media (max-width: 400px) {
|
||||
.nutrition-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.macro-card {
|
||||
grid-column: 1;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.macro-header {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@media (max-width: 357px) {
|
||||
.macro-rings {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
Reference in New Issue
Block a user