refactor: extract RingGraph, StatsRingGraph, MacroBreakdown components
CI / update (push) Successful in 3m28s

Consolidate 6 duplicated instances of the SVG arc ring pattern into a
composable component hierarchy: RingGraph (base ring), StatsRingGraph
(ring with target markers), and MacroBreakdown (3 rings + kcal + detail
table). Removes ~400 lines of duplicated SVG/CSS from FoodSearch,
nutrition page, meals page, stats page, food detail page, and recipe
NutritionSummary.
This commit is contained in:
2026-04-08 22:21:21 +02:00
parent dff8bccae1
commit 9d8d1ec41f
10 changed files with 420 additions and 602 deletions
+11 -172
View File
@@ -3,6 +3,7 @@
import { browser } from '$app/environment';
import { Heart, ExternalLink, ScanBarcode, X } from '@lucide/svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
/**
* @type {{
@@ -30,15 +31,6 @@
const isEn = $derived(lang === 'en');
const btnLabel = $derived(confirmLabel ?? t('log_food', lang));
// SVG ring constants
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
const ARC_ROTATE = 120;
function strokeOffset(percent) {
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
}
// --- Search state ---
let query = $state('');
let results = $state(initialResults ?? []);
@@ -126,21 +118,6 @@
};
});
const macroPercent = $derived.by(() => {
if (!selected?.per100g) return { protein: 0, fat: 0, carbs: 0 };
const n = selected.per100g;
const proteinCal = (n.protein ?? 0) * 4;
const fatCal = (n.fat ?? 0) * 9;
const carbsCal = (n.carbs ?? 0) * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
function confirm() {
if (!selected) return;
const grams = resolveGrams();
@@ -195,11 +172,7 @@
}
}
function fmt(v) {
if (v >= 100) return Math.round(v).toString();
if (v >= 10) return v.toFixed(1);
return v.toFixed(1);
}
function sourceLabel(source) {
if (source === 'bls') return 'BLS';
@@ -518,63 +491,15 @@
{/if}
{#if previewNutrients}
<!-- Calorie headline -->
<div class="fs-detail-cal">
<span class="fs-detail-cal-num">{previewNutrients.calories}</span>
<span class="fs-detail-cal-unit">kcal</span>
</div>
<!-- Macro rings -->
<div class="fs-detail-macros">
{#each [
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'fs-ring-protein', grams: previewNutrients.protein },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'fs-ring-fat', grams: previewNutrients.fat },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'fs-ring-carbs', grams: previewNutrients.carbs },
] as macro (macro.cls)}
<div class="fs-detail-macro">
<svg width="72" height="72" viewBox="0 0 70 70">
<circle class="fs-ring-bg" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
transform="rotate({ARC_ROTATE} 35 35)" />
<circle class="fs-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)" />
<text class="fs-ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="fs-detail-macro-label">{macro.label}</span>
<span class="fs-detail-macro-val">{fmt(macro.grams)}g</span>
</div>
{/each}
</div>
<!-- Macro detail rows -->
<div class="fs-detail-rows">
<div class="fs-detail-row">
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{fmt(previewNutrients.protein)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{fmt(previewNutrients.fat)} g</span>
</div>
<div class="fs-detail-row sub">
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span>{fmt(previewNutrients.saturatedFat)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span>{fmt(previewNutrients.carbs)} g</span>
</div>
<div class="fs-detail-row sub">
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
<span>{fmt(previewNutrients.sugars)} g</span>
</div>
<div class="fs-detail-row">
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span>{fmt(previewNutrients.fiber)} g</span>
</div>
</div>
<MacroBreakdown
calories={previewNutrients.calories}
protein={previewNutrients.protein}
fat={previewNutrients.fat}
carbs={previewNutrients.carbs}
saturatedFat={previewNutrients.saturatedFat}
sugars={previewNutrients.sugars}
fiber={previewNutrients.fiber}
/>
{/if}
<div class="fs-actions">
@@ -923,92 +848,6 @@
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
.fs-detail-cal {
text-align: center;
margin: 0.25rem 0 0.25rem;
}
.fs-detail-cal-num {
font-size: 2.2rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.fs-detail-cal-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.2rem;
}
.fs-detail-macros {
display: flex;
justify-content: space-around;
margin: 0.25rem 0 0.5rem;
}
.fs-detail-macro {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
flex: 1;
}
.fs-detail-macro-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.fs-detail-macro-val {
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
.fs-ring-bg {
fill: none;
stroke: var(--color-border);
stroke-width: 5;
stroke-linecap: round;
}
.fs-ring-fill {
fill: none;
stroke-width: 5;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
}
.fs-ring-text {
font-size: 14px;
font-weight: 700;
fill: currentColor;
text-anchor: middle;
dominant-baseline: central;
}
.fs-ring-protein { stroke: var(--nord14); }
.fs-ring-fat { stroke: var(--nord12); }
.fs-ring-carbs { stroke: var(--nord9); }
.fs-detail-rows {
background: var(--color-surface);
border-radius: 10px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
}
.fs-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-primary);
}
.fs-detail-row:last-child {
border-bottom: none;
}
.fs-detail-row.sub span:first-child {
padding-left: 0.75rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
}
.fs-detail-row span:last-child {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
/* ── Buttons ── */
.fs-actions {
@@ -0,0 +1,161 @@
<script>
import { detectFitnessLang } from '$lib/js/fitnessI18n';
import { page } from '$app/stores';
import RingGraph from './RingGraph.svelte';
/**
* @type {{
* calories?: number,
* protein: number,
* fat: number,
* carbs: number,
* saturatedFat?: number,
* sugars?: number,
* fiber?: number,
* showCalories?: boolean,
* showDetailRows?: boolean,
* }}
*/
let {
calories = 0,
protein = 0,
fat = 0,
carbs = 0,
saturatedFat = 0,
sugars = 0,
fiber = 0,
showCalories = true,
showDetailRows = true,
} = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
const macroPercent = $derived.by(() => {
const proteinCal = protein * 4;
const fatCal = fat * 9;
const carbsCal = carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
});
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
const macros = $derived([
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', color: 'var(--nord14)', grams: protein },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', color: 'var(--nord12)', grams: fat },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', color: 'var(--nord9)', grams: carbs },
]);
</script>
<div class="macro-breakdown">
{#if showCalories}
<div class="mb-cal">
<span class="mb-cal-num">{Math.round(calories)}</span>
<span class="mb-cal-unit">kcal</span>
</div>
{/if}
<div class="mb-rings">
{#each macros as macro (macro.color)}
<RingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
sublabel="{fmt(macro.grams)}g"
/>
{/each}
</div>
{#if showDetailRows}
<div class="mb-rows">
<div class="mb-row">
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{fmt(protein)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{fmt(fat)} g</span>
</div>
<div class="mb-row sub">
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span>{fmt(saturatedFat)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span>{fmt(carbs)} g</span>
</div>
<div class="mb-row sub">
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
<span>{fmt(sugars)} g</span>
</div>
<div class="mb-row">
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span>{fmt(fiber)} g</span>
</div>
</div>
{/if}
</div>
<style>
.macro-breakdown {
display: flex;
flex-direction: column;
}
.mb-cal {
text-align: center;
margin-bottom: 0.25rem;
}
.mb-cal-num {
font-size: 2.2rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.mb-cal-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.2rem;
}
.mb-rings {
display: flex;
justify-content: space-around;
margin: 0.25rem 0 0.5rem;
}
.mb-rows {
background: var(--color-surface);
border-radius: 10px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
}
.mb-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-primary);
}
.mb-row:last-child {
border-bottom: none;
}
.mb-row.sub span:first-child {
padding-left: 0.75rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
}
.mb-row span:last-child {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,95 @@
<script module>
export const RADIUS = 28;
export const ARC_DEGREES = 300;
export const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
export const ARC_ROTATE = 120;
/** @param {number} percent */
export function strokeOffset(percent) {
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
}
</script>
<script>
/**
* @type {{
* percent: number,
* color: string,
* label?: string,
* sublabel?: string,
* extra?: import('svelte').Snippet,
* }}
*/
let {
percent = 0,
color = 'currentColor',
label = '',
sublabel = '',
extra = undefined,
} = $props();
</script>
<div class="ring-wrap">
<svg class="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" cx="35" cy="35" r={RADIUS}
stroke-dasharray="{ARC_LENGTH} {2 * Math.PI * RADIUS}"
stroke-dashoffset={strokeOffset(percent)}
transform="rotate({ARC_ROTATE} 35 35)"
stroke={color} />
{@render extra?.()}
<text class="ring-text" x="35" y="35">{percent}%</text>
</svg>
{#if label}
<span class="ring-label">{label}</span>
{/if}
{#if sublabel}
<span class="ring-sublabel">{sublabel}</span>
{/if}
</div>
<style>
.ring-wrap {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
flex: 1;
}
.ring-svg {
width: 72px;
height: 72px;
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-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.ring-sublabel {
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
</style>
@@ -0,0 +1,78 @@
<script>
import RingGraph from './RingGraph.svelte';
import { RADIUS, ARC_DEGREES, ARC_ROTATE } from './RingGraph.svelte';
/**
* @type {{
* percent: number,
* color: string,
* label?: string,
* sublabel?: string,
* target?: number,
* markerColor?: string,
* }}
*/
let {
percent = 0,
color = 'currentColor',
label = '',
sublabel = '',
target = undefined,
markerColor = 'var(--color-text-secondary)',
} = $props();
/**
* Get SVG coordinates for a triangle marker + label at a given percentage on the arc.
* @param {number} pct
*/
function targetMarkerPos(pct) {
const angleDeg = ARC_ROTATE + (pct / 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 positioning: primarily radial, with tangential nudge near 50%
const closeness = 1 - Math.abs(pct - 50) / 50;
const highBonus = pct > 50 && closeness < 0.4 ? 4 : 0;
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;
const dir = pct < 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 };
}
</script>
<RingGraph {percent} {color} {label} {sublabel}>
{#snippet extra()}
{#if target != null}
{@const pos = targetMarkerPos(target)}
<path
fill={markerColor}
opacity="0.85"
stroke={markerColor}
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={markerColor}
x={pos.lx}
y={pos.ly}
text-anchor="middle"
dominant-baseline="central"
>{target}%</text>
{/if}
{/snippet}
</RingGraph>
<style>
.target-label {
font-size: 7px;
font-weight: 700;
}
</style>
@@ -1,5 +1,6 @@
<script>
import { createNutritionCalculator } from '$lib/js/nutrition.svelte';
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
let { flatIngredients, nutritionMappings, sectionNames, referencedNutrition, multiplier, portions, isEnglish, actions } = $props();
@@ -70,19 +71,7 @@
return value.toFixed(1);
}
// SVG arc 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;
// Arc starts at the left side: rotate so the gap is centered at the bottom
// 0° in SVG circle = 3 o'clock. We want the arc to start at ~210° (7 o'clock)
// and end at ~150° (5 o'clock), leaving a 60° gap at bottom center.
const ARC_ROTATE = 120; // rotate the starting point: -90 (top) + 210 offset → start at left
/** @param {number} percent */
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
</script>
<style>
@@ -101,40 +90,12 @@
justify-content: space-around;
margin: 0.5rem 0;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
flex: 1;
.macro-rings :global(.ring-svg) {
width: 90px;
height: 90px;
}
.ring-bg {
fill: none;
stroke: var(--color-border, #e5e5e5);
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 {
.macro-rings :global(.ring-label) {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.details-toggle-row {
@@ -194,29 +155,15 @@
<div class="nutrition-summary">
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: labels.protein, cls: 'ring-protein' },
{ pct: macroPercent.fat, label: labels.fat, cls: 'ring-fat' },
{ pct: macroPercent.carbs, label: labels.carbs, cls: 'ring-carbs' },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" 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)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
</div>
{ pct: macroPercent.protein, label: labels.protein, color: 'var(--nord14)' },
{ pct: macroPercent.fat, label: labels.fat, color: 'var(--nord12)' },
{ pct: macroPercent.carbs, label: labels.carbs, color: 'var(--nord9)' },
] as macro (macro.color)}
<RingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
/>
{/each}
</div>
@@ -5,6 +5,7 @@
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import AddButton from '$lib/components/AddButton.svelte';
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
import { getDRI, NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
@@ -626,25 +627,7 @@
};
}
function cmMacroPercent(meal) {
const { per100g } = aggregateMealPer100g(meal);
const proteinCal = per100g.protein * 4;
const fatCal = per100g.fat * 9;
const carbsCal = per100g.carbs * 4;
const total = proteinCal + fatCal + carbsCal;
if (total === 0) return { protein: 0, fat: 0, carbs: 0 };
return {
protein: Math.round(proteinCal / total * 100),
fat: Math.round(fatCal / total * 100),
carbs: 100 - Math.round(proteinCal / total * 100) - Math.round(fatCal / total * 100),
};
}
function fmtNutrient(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
async function loadCustomMeals() {
if (customMealsLoaded) return;
@@ -993,7 +976,6 @@
{#snippet cmDetailScreen(meal, logFn)}
{@const preview = cmPreview(meal)}
{@const mp = cmMacroPercent(meal)}
<div class="cm-detail">
<div class="cm-detail-header">
<span class="cm-detail-name">{meal.name}</span>
@@ -1019,63 +1001,15 @@
<span class="cm-detail-hint">= {Math.round(preview.grams)}g</span>
{/if}
<!-- Calorie headline -->
<div class="cm-detail-cal">
<span class="cm-detail-cal-num">{preview.calories}</span>
<span class="cm-detail-cal-unit">kcal</span>
</div>
<!-- Macro rings -->
<div class="cm-detail-macros">
{#each [
{ pct: mp.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: preview.protein },
{ pct: mp.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: preview.fat },
{ pct: mp.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: preview.carbs },
] as macro}
<div class="cm-detail-macro">
<svg width="72" height="72" 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)" />
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="cm-detail-macro-label">{macro.label}</span>
<span class="cm-detail-macro-val">{fmtNutrient(macro.grams)}g</span>
</div>
{/each}
</div>
<!-- Macro detail rows -->
<div class="cm-detail-rows">
<div class="cm-detail-row">
<span>{isEn ? 'Protein' : 'Eiweiß'}</span>
<span>{fmtNutrient(preview.protein)} g</span>
</div>
<div class="cm-detail-row">
<span>{isEn ? 'Fat' : 'Fett'}</span>
<span>{fmtNutrient(preview.fat)} g</span>
</div>
<div class="cm-detail-row sub">
<span>{isEn ? 'Saturated Fat' : 'Ges. Fettsäuren'}</span>
<span>{fmtNutrient(preview.saturatedFat)} g</span>
</div>
<div class="cm-detail-row">
<span>{isEn ? 'Carbohydrates' : 'Kohlenhydrate'}</span>
<span>{fmtNutrient(preview.carbs)} g</span>
</div>
<div class="cm-detail-row sub">
<span>{isEn ? 'Sugars' : 'Zucker'}</span>
<span>{fmtNutrient(preview.sugars)} g</span>
</div>
<div class="cm-detail-row">
<span>{isEn ? 'Fiber' : 'Ballaststoffe'}</span>
<span>{fmtNutrient(preview.fiber)} g</span>
</div>
</div>
<MacroBreakdown
calories={preview.calories}
protein={preview.protein}
fat={preview.fat}
carbs={preview.carbs}
saturatedFat={preview.saturatedFat}
sugars={preview.sugars}
fiber={preview.fiber}
/>
<!-- Ingredients list -->
<details class="cm-detail-ingredients">
@@ -2200,9 +2134,6 @@
letter-spacing: 0.04em;
}
/* Macro rings (custom meal detail + food detail) */
.ring-protein { stroke: var(--nord14); }
.ring-fat { stroke: var(--nord12); }
.ring-carbs { stroke: var(--nord9); }
.ring-text {
font-size: 14px;
font-weight: 700;
@@ -3467,71 +3398,6 @@
color: var(--color-text-tertiary);
margin-bottom: 0.5rem;
}
.cm-detail-cal {
text-align: center;
margin: 0.75rem 0 0.5rem;
}
.cm-detail-cal-num {
font-size: 2.2rem;
font-weight: 800;
color: var(--color-text-primary);
line-height: 1;
}
.cm-detail-cal-unit {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-secondary);
margin-left: 0.2rem;
}
.cm-detail-macros {
display: flex;
justify-content: space-around;
margin: 0.5rem 0 0.75rem;
}
.cm-detail-macro {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
flex: 1;
}
.cm-detail-macro-label {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.cm-detail-macro-val {
font-size: 0.72rem;
color: var(--color-text-tertiary);
}
.cm-detail-rows {
background: var(--color-surface);
border-radius: 10px;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
border: 1px solid var(--color-border);
}
.cm-detail-row {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
color: var(--color-text-primary);
}
.cm-detail-row:last-child {
border-bottom: none;
}
.cm-detail-row.sub span:first-child {
padding-left: 0.75rem;
color: var(--color-text-tertiary);
font-size: 0.8rem;
}
.cm-detail-row span:last-child {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.cm-detail-ingredients {
margin-bottom: 0.75rem;
font-size: 0.8rem;
@@ -4,6 +4,7 @@
import { toast } from '$lib/js/toast.svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
import RingGraph from '$lib/components/fitness/RingGraph.svelte';
let { data } = $props();
@@ -48,16 +49,6 @@
};
});
// --- SVG ring constants (same as NutritionSummary) ---
const RADIUS = 28;
const ARC_DEGREES = 300;
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
const ARC_ROTATE = 120;
function strokeOffset(percent) {
return ARC_LENGTH - (percent / 100) * ARC_LENGTH;
}
// --- Formatting ---
function fmt(v) {
if (v == null || isNaN(v)) return '0';
@@ -212,30 +203,16 @@
<!-- Macro rings -->
<div class="macro-rings">
{#each [
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', cls: 'ring-protein', grams: scaled(n.protein) },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', cls: 'ring-fat', grams: scaled(n.fat) },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', cls: 'ring-carbs', grams: scaled(n.carbs) },
] as macro}
<div class="macro-ring">
<svg width="90" height="90" 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)"
/>
<text class="ring-text" x="35" y="35">{macro.pct}%</text>
</svg>
<span class="macro-label">{macro.label}</span>
<span class="macro-grams">{fmt(macro.grams)}g</span>
</div>
{ pct: macroPercent.protein, label: isEn ? 'Protein' : 'Eiweiß', color: 'var(--nord14)', grams: scaled(n.protein) },
{ pct: macroPercent.fat, label: isEn ? 'Fat' : 'Fett', color: 'var(--nord12)', grams: scaled(n.fat) },
{ pct: macroPercent.carbs, label: isEn ? 'Carbs' : 'Kohlenh.', color: 'var(--nord9)', grams: scaled(n.carbs) },
] as macro (macro.color)}
<RingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
sublabel="{fmt(macro.grams)}g"
/>
{/each}
</div>
@@ -464,45 +441,9 @@
justify-content: space-around;
margin: 0 0 1.25rem;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
flex: 1;
}
.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); }
.ring-fat { stroke: var(--nord12); }
.ring-carbs { stroke: var(--nord9); }
.macro-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
text-align: center;
}
.macro-grams {
font-size: 0.78rem;
color: var(--color-text-tertiary);
.macro-rings :global(.ring-svg) {
width: 90px;
height: 90px;
}
/* Macro detail card */
@@ -6,6 +6,7 @@
import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
@@ -51,14 +52,18 @@
function ingredientsTotalNutrition(ings) {
let calories = 0, protein = 0, fat = 0, carbs = 0;
let saturatedFat = 0, sugars = 0, fiber = 0;
for (const ing of ings) {
const f = ing.amountGrams / 100;
calories += (ing.per100g?.calories ?? 0) * f;
protein += (ing.per100g?.protein ?? 0) * f;
fat += (ing.per100g?.fat ?? 0) * f;
carbs += (ing.per100g?.carbs ?? 0) * f;
saturatedFat += (ing.per100g?.saturatedFat ?? 0) * f;
sugars += (ing.per100g?.sugars ?? 0) * f;
fiber += (ing.per100g?.fiber ?? 0) * f;
}
return { calories, protein, fat, carbs };
return { calories, protein, fat, carbs, saturatedFat, sugars, fiber };
}
const formTotals = $derived(ingredientsTotalNutrition(ingredients));
@@ -246,14 +251,18 @@
</div>
{/if}
<!-- Totals -->
<!-- Nutrition breakdown -->
{#if ingredients.length > 0}
<div class="totals-bar">
<span class="total-label">{t('total', lang)}</span>
<span class="total-macro">{Math.round(formTotals.calories)} {t('kcal', lang)}</span>
<span class="total-macro protein">{fmt(formTotals.protein)}g P</span>
<span class="total-macro fat">{fmt(formTotals.fat)}g F</span>
<span class="total-macro carbs">{fmt(formTotals.carbs)}g C</span>
<div class="nutrition-breakdown">
<MacroBreakdown
calories={formTotals.calories}
protein={formTotals.protein}
fat={formTotals.fat}
carbs={formTotals.carbs}
saturatedFat={formTotals.saturatedFat}
sugars={formTotals.sugars}
fiber={formTotals.fiber}
/>
</div>
{/if}
@@ -621,30 +630,13 @@
margin-left: auto;
}
/* ── Totals bar ── */
.totals-bar {
display: flex;
align-items: center;
gap: 0.6rem;
background: color-mix(in srgb, var(--nord8) 8%, transparent);
padding: 0.55rem 0.75rem;
border-radius: 8px;
font-size: 0.78rem;
font-weight: 600;
/* ── Nutrition breakdown ── */
.nutrition-breakdown {
background: var(--color-bg-tertiary);
border-radius: 10px;
padding: 0.75rem;
}
.total-label {
color: var(--color-text-secondary);
margin-right: auto;
}
.total-macro {
color: var(--color-text-primary);
}
.total-macro.protein { color: var(--nord14); }
.total-macro.fat { color: var(--nord12); }
.total-macro.carbs { color: var(--nord9); }
/* ── Add ingredient button ── */
.add-ingredient-btn {
display: flex;
@@ -8,6 +8,7 @@
import { onMount } from 'svelte';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte';
import StatsRingGraph from '$lib/components/fitness/StatsRingGraph.svelte';
const lang = $derived(detectFitnessLang($page.url.pathname));
@@ -82,45 +83,6 @@
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({
@@ -352,48 +314,18 @@
<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)}
{ pct: ns.macroSplit.protein, target: ns.macroTargets?.protein, label: t('protein', lang), color: 'var(--nord14)', fill: '#a3be8c' },
{ pct: ns.macroSplit.fat, target: ns.macroTargets?.fat, label: t('fat', lang), color: 'var(--nord12)', fill: '#d08770' },
{ pct: ns.macroSplit.carbs, target: ns.macroTargets?.carbs, label: t('carbs', lang), color: 'var(--nord9)', fill: '#81a1c1' },
] as macro (macro.color)}
<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>
<StatsRingGraph
percent={macro.pct}
color={macro.color}
label={macro.label}
target={macro.target}
markerColor={macro.fill}
/>
</div>
{/each}
<div class="macro-legend">
@@ -860,49 +792,16 @@
width: 100%;
}
.macro-ring {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
flex: 1;
max-width: 130px;
}
.macro-ring-svg {
.macro-ring :global(.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 {
.macro-ring :global(.ring-label) {
font-size: 0.85rem;
font-weight: 600;
text-align: center;
}
.target-label {
font-size: 7px;
font-weight: 700;
}
.macro-legend {
display: none;