chore: clear all svelte-check errors and warnings repo-wide (454 → 0)

Mostly additive JSDoc/TS type annotations and null/undefined guards —
no runtime behavior changes. Starting baseline: 454 errors + 1 warning
across ~50 files. After: 0/0, build is clean.

Highlights:
- Duplicate object-literal keys fixed: 11 in cospendI18n.ts, 2 in
  fitnessI18n.ts (dropped second `loading`; renamed `protein_per_kg`
  stats-card label to reuse `protein`), 1 in shoppingCategorizer.
- `bind:this` state declared with `HTMLDivElement | null` across
  DatePicker + Muscle{Map,Filter,Heatmap}.
- SaveFab's required `onclick` made optional (type="submit" handles
  form submission in most callsites).
- Implicit any on ~200 callback parameters replaced with concrete
  JSDoc/TS types. Chart.js generics and one mongoose query chain cast
  are the only `any` / `unknown as any[]` uses introduced.
- Stats history discriminated union (`paired: true | false`) lets the
  template narrow `series` and `stats` properly.
- Food page server guards use `throw new Error('unreachable')` after
  `errorWithVerse(...)` awaits so TS narrows `entry`/`recipe`/`meal`
  below. Same pattern applied to cospend payments, calendar detail,
  and prayers server loads.
- Mongo `Date → string` serialization helper in cospend list so
  `IShoppingItem[]` fits `ShoppingItem[]` at the boundary.
- Recipe category/tag pages use a local `RecipeItem` alias (derived
  from `BriefRecipeType`) so `rand_array`/filter callbacks type.
- `web-haptics/svelte` has no bundled `.d.ts`; added a local
  `@ts-expect-error` shim on the one import line.

Files touched: ~50 across fitness, cospend, faith, recipe, and shared
lib components / API routes.
This commit is contained in:
2026-04-23 13:11:14 +02:00
parent 36058d1b94
commit e9ebe492fb
48 changed files with 565 additions and 190 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.46.2",
"version": "1.46.3",
"private": true,
"type": "module",
"scripts": {
+1
View File
@@ -3,6 +3,7 @@
const dialog = getConfirmDialog();
/** @param {KeyboardEvent} e */
function onKeydown(e) {
if (!dialog.open) return;
if (e.key === 'Escape') dialog.respond(false);
+8 -2
View File
@@ -4,6 +4,7 @@
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
let open = $state(false);
/** @type {HTMLDivElement | null} */
let pickerRef = $state(null);
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
@@ -39,12 +40,14 @@
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
});
/** @param {string} dateStr */
function isDisabled(dateStr) {
if (min && dateStr < min) return true;
if (max && dateStr > max) return true;
return false;
}
/** @param {number} delta */
function navigateDate(delta) {
const d = new Date((value || todayStr) + 'T12:00:00');
d.setDate(d.getDate() + delta);
@@ -52,12 +55,14 @@
if (!isDisabled(next)) value = next;
}
/** @param {number} delta */
function navMonth(delta) {
viewMonth += delta;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
}
/** @param {string} dateStr */
function selectDay(dateStr) {
value = dateStr;
open = false;
@@ -77,7 +82,7 @@
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
/** @type {{ date: string, day: number, currentMonth: boolean, isToday: boolean, isSelected: boolean }[]} */
/** @type {{ date: string, day: number, currentMonth: boolean, isToday: boolean, isSelected: boolean, disabled: boolean }[]} */
const days = [];
// Previous month trailing days
@@ -110,8 +115,9 @@
});
// Close on outside click
/** @param {MouseEvent} e */
function handleClickOutside(e) {
if (pickerRef && !pickerRef.contains(e.target)) {
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
open = false;
}
}
+1 -1
View File
@@ -164,7 +164,7 @@
}
// Handle fitness pages
if (path.startsWith('/fitness')) {
if (path.startsWith('/fitness') && lang !== 'la') {
const newPath = convertFitnessPath(path, lang);
await goto(newPath);
return;
+2 -1
View File
@@ -2,7 +2,8 @@
import Check from '$lib/assets/icons/Check.svelte';
import ActionButton from './ActionButton.svelte';
let { disabled = false, onclick, label = 'Save', type = 'submit' } = $props();
/** @type {{ disabled?: boolean, onclick?: ((e: MouseEvent) => void) | undefined, label?: string, type?: 'submit' | 'reset' | 'button' }} */
let { disabled = false, onclick = undefined, label = 'Save', type = 'submit' } = $props();
</script>
<ActionButton {type} {onclick} {disabled} ariaLabel={label}>
+3 -2
View File
@@ -8,10 +8,11 @@
* data?: { labels: string[], datasets: Array<{ label: string, data: number[] }> },
* title?: string,
* height?: string,
* onFilterChange?: ((categories: string[] | null) => void) | null
* onFilterChange?: ((categories: string[] | null) => void) | null,
* lang?: 'en' | 'de'
* }}
*/
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = /** @type {'en' | 'de'} */ ('de') } = $props();
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = 'de' } = $props();
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
+49 -14
View File
@@ -6,16 +6,33 @@
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
/**
* @typedef {{ description: string, grams: number }} Portion
*/
/**
* @typedef {{
* id: string,
* name: string,
* source: string,
* per100g: any,
* portions?: Portion[],
* brands?: string,
* category?: string,
* calories?: number,
* favorited?: boolean,
* }} FoodItem
*/
/**
* @type {{
* onselect: (food: { name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: any[], selectedPortion?: { description: string, grams: number } }) => void,
* onselect: (food: { name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: Portion[], selectedPortion?: Portion }) => void,
* oncancel?: () => void,
* onfavoritechange?: (payload: { source: string, sourceId: string, name: string, favorited: boolean }) => void,
* showFavorites?: boolean,
* showDetailLinks?: boolean,
* autofocus?: boolean,
* confirmLabel?: string,
* initialResults?: any[],
* initialResults?: FoodItem[],
* }}
*/
let {
@@ -36,8 +53,10 @@
// --- Search state ---
let query = $state('');
/** @type {FoodItem[]} */
let results = $state(untrack(() => initialResults ?? []));
let loading = $state(false);
/** @type {ReturnType<typeof setTimeout> | null} */
let timeout = $state(null);
const isPrefilledMode = $derived(initialResults != null);
let filterQuery = $state('');
@@ -48,6 +67,7 @@
);
// --- Selection state ---
/** @type {FoodItem | null} */
let selected = $state(null);
let amountInput = $state('100');
let portionIdx = $state(-1); // -1 = grams
@@ -55,7 +75,9 @@
// --- Barcode scanner state ---
let scanning = $state(false);
let scanError = $state('');
/** @type {HTMLVideoElement | null} */
let videoEl = $state(null);
/** @type {MediaStream | null} */
let scanStream = $state(null);
let scanDebug = $state('');
@@ -78,9 +100,10 @@
}, 300);
}
/** @param {FoodItem} item */
function selectItem(item) {
selected = item;
if (item.portions?.length > 0) {
if ((item.portions?.length ?? 0) > 0) {
portionIdx = 0;
amountInput = '1';
} else {
@@ -126,6 +149,7 @@
const grams = resolveGrams();
if (!grams || grams <= 0) return;
/** @type {{ name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: Portion[], selectedPortion?: Portion }} */
const food = {
name: selected.name,
source: selected.source,
@@ -133,7 +157,7 @@
amountGrams: grams,
per100g: selected.per100g,
};
if (selected.portions?.length > 0) {
if (selected.portions && selected.portions.length > 0) {
food.portions = selected.portions;
}
if (portionIdx >= 0 && selected.portions?.[portionIdx]) {
@@ -151,6 +175,7 @@
portionIdx = -1;
}
/** @param {FoodItem} item */
async function toggleFavorite(item) {
const wasFav = item.favorited;
item.favorited = !wasFav;
@@ -178,6 +203,7 @@
/** @param {string | undefined} source */
function sourceLabel(source) {
if (source === 'bls') return 'BLS';
if (source === 'usda') return 'USDA';
@@ -186,11 +212,14 @@
return source?.toUpperCase() ?? '';
}
// EAN/UPC check digit validation (works for EAN-8, UPC-A, EAN-13)
/**
* EAN/UPC check digit validation (works for EAN-8, UPC-A, EAN-13)
* @param {string} code
*/
function validCheckDigit(code) {
const digits = code.split('').map(Number);
const check = digits.pop();
const sum = digits.reduce((s, d, i) => s + d * ((i % 2 === (digits.length % 2 === 0 ? 0 : 1)) ? 1 : 3), 0);
const sum = digits.reduce((/** @type {number} */ s, /** @type {number} */ d, /** @type {number} */ i) => s + d * ((i % 2 === (digits.length % 2 === 0 ? 0 : 1)) ? 1 : 3), 0);
return (10 - (sum % 10)) % 10 === check;
}
@@ -240,13 +269,16 @@
await videoEl.play();
// Use native BarcodeDetector if available, else ponyfill with self-hosted WASM
/** @type {any} */
let detector;
/** @type {any} */
const formats = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
try {
if ('BarcodeDetector' in globalThis) {
const supported = await globalThis.BarcodeDetector.getSupportedFormats();
const BD = /** @type {any} */ (globalThis).BarcodeDetector;
const supported = await BD.getSupportedFormats();
if (supported.includes('ean_13')) {
detector = new globalThis.BarcodeDetector({ formats });
detector = new BD({ formats });
}
}
} catch {
@@ -257,7 +289,7 @@
const mod = await import('barcode-detector/ponyfill');
await mod.prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
locateFile: (/** @type {string} */ path, /** @type {string} */ prefix) => {
if (path.endsWith('.wasm')) return '/fitness/zxing_reader.wasm';
return prefix + path;
},
@@ -307,7 +339,8 @@
}
} catch (detectErr) {
errorCount++;
scanDebug = `ERROR: ${detectErr?.name}: ${detectErr?.message}`;
const e = /** @type {{ name?: string, message?: string }} */ (detectErr);
scanDebug = `ERROR: ${e?.name}: ${e?.message}`;
if (errorCount >= 5) {
scanError = isEn ? 'Barcode detection failed repeatedly. Try reloading.' : 'Barcode-Erkennung wiederholt fehlgeschlagen. Seite neu laden.';
stopScan();
@@ -320,7 +353,8 @@
detectLoop();
} catch (err) {
scanning = false;
const name = err?.name;
const e = /** @type {{ name?: string, message?: string }} */ (err);
const name = e?.name;
if (name === 'NotAllowedError') {
scanError = isEn
? 'Camera permission denied — enable it in your browser site settings'
@@ -330,7 +364,7 @@
} else if (name === 'NotReadableError') {
scanError = isEn ? 'Camera is in use by another app' : 'Kamera wird von einer anderen App verwendet';
} else {
scanError = isEn ? `Camera error: ${err?.message || name}` : `Kamerafehler: ${err?.message || name}`;
scanError = isEn ? `Camera error: ${e?.message || name}` : `Kamerafehler: ${e?.message || name}`;
}
}
}
@@ -344,6 +378,7 @@
if (videoEl) videoEl.srcObject = null;
}
/** @param {string} code */
async function lookupBarcode(code) {
loading = true;
scanError = '';
@@ -472,10 +507,10 @@
min="0.1"
step={portionIdx >= 0 ? '0.5' : '1'}
/>
{#if selected.portions?.length > 0}
{#if (selected.portions?.length ?? 0) > 0}
<select class="fs-unit-select" bind:value={portionIdx} onchange={() => {
const grams = resolveGrams();
if (portionIdx >= 0 && selected.portions[portionIdx]) {
if (portionIdx >= 0 && selected?.portions?.[portionIdx]) {
amountInput = String(Math.round((grams / selected.portions[portionIdx].grams) * 10) / 10 || 1);
} else {
amountInput = String(grams || 100);
@@ -45,6 +45,7 @@
};
});
/** @param {number | null | undefined} v */
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
@@ -2,12 +2,14 @@
import { Coffee, Sun, Moon, Cookie } from '@lucide/svelte';
import { t } from '$lib/js/fitnessI18n';
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
let {
value = 'snack',
lang = 'de',
onchange = () => {},
} = $props();
/** @type {Array<'breakfast' | 'lunch' | 'dinner' | 'snack'>} */
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
const mealMeta = {
+41 -10
View File
@@ -3,10 +3,16 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -19,6 +25,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -32,19 +39,29 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Check if a region's groups overlap with selectedGroups */
/**
* Check if a region's groups overlap with selectedGroups
* @param {string[]} groups
*/
function isRegionSelected(groups) {
if (selectedGroups.length === 0) return false;
return groups.some(g => selectedGroups.includes(g));
}
/** Compute fill for a region based on selection state */
/**
* Compute fill for a region based on selection state
* @param {string[]} groups
*/
function regionFill(groups) {
if (isRegionSelected(groups)) return 'var(--color-primary)';
return 'var(--color-bg-tertiary)';
}
/** Inject fill styles into SVG string */
/**
* Inject fill styles into SVG string
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
@@ -59,6 +76,7 @@
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently hovered region for tooltip */
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
@@ -67,10 +85,15 @@
return isEn ? hovered.label.en : hovered.label.de;
});
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/** Toggle a region's muscle groups in/out of selection */
/**
* Toggle a region's muscle groups in/out of selection
* @param {MuscleRegion} region
*/
function toggleRegion(region) {
const groups = region.groups;
const allSelected = groups.every(g => selectedGroups.includes(g));
@@ -82,11 +105,17 @@
}
}
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
@@ -94,8 +123,9 @@
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
@@ -103,8 +133,9 @@
hovered = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('click', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
toggleRegion(map[g.id]);
}
@@ -5,12 +5,20 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
* @typedef {{ primary?: number, secondary?: number, weeklyAvg?: number }} MuscleTotals
*/
/** @type {{ data?: { totals?: Record<string, MuscleTotals> } | null }} */
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleTotals>} */
const totals = $derived(data?.totals ?? {});
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -23,6 +31,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -36,7 +45,10 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Sum weeklyAvg across all muscle groups for a region */
/**
* Sum weeklyAvg across all muscle groups for a region
* @param {string[]} groups
*/
function regionScore(groups) {
let score = 0;
for (const g of groups) {
@@ -55,7 +67,10 @@
return max;
});
/** Compute fill as a color-mix CSS value — resolved natively by the browser */
/**
* Compute fill as a color-mix CSS value — resolved natively by the browser
* @param {number} score
*/
function scoreFill(score) {
if (score === 0) return 'var(--color-bg-tertiary)';
const pct = Math.round(Math.min(score / maxScore, 1) * 100);
@@ -65,6 +80,8 @@
/**
* Preprocess an SVG string: inject fill styles into each muscle group.
* Replaces `<g id="groupId">` with `<g id="groupId" style="...">`.
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
@@ -82,6 +99,7 @@
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently selected region info */
/** @type {(MuscleRegion & { svgId: string }) | null} */
let selected = $state(null);
const selectedInfo = $derived.by(() => {
@@ -99,22 +117,30 @@
const hasData = $derived(Object.keys(totals).length > 0);
/** DOM refs for event delegation */
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
*/
function setupEvents(container, map) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
@@ -122,8 +148,9 @@
selected = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('click', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
}
+31 -5
View File
@@ -3,10 +3,16 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ primaryGroups?: string[], secondaryGroups?: string[], lang?: string }} */
let { primaryGroups = [], secondaryGroups = [], lang = 'en' } = $props();
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -19,6 +25,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -35,12 +42,14 @@
const primarySet = $derived(new Set(primaryGroups));
const secondarySet = $derived(new Set(secondaryGroups));
/** @param {string[]} groups */
function regionState(groups) {
if (groups.some(g => primarySet.has(g))) return 'primary';
if (groups.some(g => secondarySet.has(g))) return 'secondary';
return 'inactive';
}
/** @param {string[]} groups */
function regionFill(groups) {
const state = regionState(groups);
if (state === 'primary') return 'var(--color-primary)';
@@ -48,6 +57,10 @@
return 'var(--color-bg-tertiary)';
}
/**
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
@@ -61,6 +74,7 @@
const frontSvg = $derived(injectFills(frontSvgRaw, FRONT_MAP));
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => {
@@ -71,21 +85,30 @@
return label + suffix;
});
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
container.addEventListener('mouseleave', () => { hovered = null; });
@@ -96,7 +119,10 @@
setupEvents(backEl, BACK_MAP, 'back');
});
// Check if any muscles are on front/back to decide which to show
/**
* Check if any muscles are on front/back to decide which to show
* @param {Record<string, MuscleRegion>} map
*/
function hasActiveRegions(map) {
return Object.values(map).some(r => regionState(r.groups) !== 'inactive');
}
@@ -275,7 +275,7 @@
const startDay = (first.getDay() + 6) % 7; // Monday = 0
// Build raw cells with status, including overflow days from adjacent months
/** @type {({ day: number, date: string, status: string, pos: string, edges: string, overflow?: boolean } | null)[]} */
/** @type {{ day: number, date: string, status: string, pos: string, edges: string, overflow?: boolean }[]} */
const cells = [];
// Previous month overflow
@@ -4,7 +4,21 @@
import { toast } from '$lib/js/toast.svelte';
import { t } from '$lib/js/fitnessI18n';
import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte';
/** @typedef {import('$lib/server/roundOffScoring').ComboSuggestion} ComboSuggestion */
/**
* @type {{
* remainingKcal: number,
* remainingProtein: number,
* remainingFat: number,
* remainingCarbs: number,
* currentDate: string,
* lang?: 'en' | 'de',
* nutritionSlug?: string,
* initialSuggestions?: ComboSuggestion[] | null,
* onlogged?: () => void,
* }}
*/
let {
remainingKcal,
remainingProtein,
@@ -20,6 +34,7 @@
const isEn = $derived(lang === 'en');
// svelte-ignore state_referenced_locally
/** @type {ComboSuggestion[] | null} */
let suggestions = $state(initialSuggestions);
// svelte-ignore state_referenced_locally
let loading = $state(!initialSuggestions);
@@ -35,6 +50,7 @@
}
let editingComboIdx = $state(-1);
/** @type {'breakfast' | 'lunch' | 'dinner' | 'snack'} */
let editMealType = $state('snack');
async function fetchSuggestions() {
@@ -64,6 +80,7 @@
}
});
/** @param {number} comboIdx */
function startLog(comboIdx) {
editingComboIdx = comboIdx;
editMealType = defaultMealType();
@@ -73,6 +90,7 @@
editingComboIdx = -1;
}
/** @param {ComboSuggestion} combo */
async function logCombo(combo) {
loggingIdx = editingComboIdx;
try {
@@ -113,12 +131,14 @@
loggingIdx = -1;
}
/** @param {number | undefined | null} v */
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (Math.abs(v) >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
/** @param {number} v */
function fmtSigned(v) {
const s = fmt(v);
return v > 0 ? '+' + s : s;
@@ -3,10 +3,12 @@
let { src, poster = '', onClose } = $props();
/** @param {KeyboardEvent} e */
function handleKeydown(e) {
if (e.key === 'Escape') onClose();
}
/** @param {MouseEvent} e */
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose();
}
@@ -54,6 +54,7 @@
});
// Parse amount string to number (simplified from nutrition.svelte.ts)
/** @param {string | undefined | null} amount */
function parseAmount(amount) {
if (!amount?.trim()) return 0;
let s = amount.trim().replace(',', '.');
@@ -69,6 +70,7 @@
// Compute total recipe nutrition (all ingredients at multiplier=1)
const recipeTotals = $derived.by(() => {
/** @type {Record<string, number>} */
const result = {};
const nutrientKeys = [
'calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
@@ -120,6 +122,7 @@
const per100g = $derived.by(() => {
const w = recipeTotals.totalWeightGrams;
if (w <= 0) return recipeTotals.totals;
/** @type {Record<string, number>} */
const result = {};
for (const [k, v] of Object.entries(recipeTotals.totals)) {
result[k] = v / w * 100;
@@ -994,7 +994,7 @@ button:disabled {
<button class="btn-secondary" onclick={handleSkip}>
Skip Translation
</button>
<button class="btn-primary" onclick={handleAutoTranslate} disabled={untranslatedBaseRecipes.length > 0}>
<button class="btn-primary" onclick={() => handleAutoTranslate()} disabled={untranslatedBaseRecipes.length > 0}>
{#if untranslatedBaseRecipes.length > 0}
Translate base recipes first
{:else}
-11
View File
@@ -214,7 +214,6 @@ const translations: Translations = {
// UsersList
split_between_users: { en: 'Split Between Users', de: 'Aufteilen zwischen' },
predefined_note: { en: 'Splitting between predefined users:', de: 'Aufteilung zwischen vordefinierten Benutzern:' },
you: { en: 'You', de: 'Du' },
remove: { en: 'Remove', de: 'Entfernen' },
add_user_placeholder: { en: 'Add user...', de: 'Benutzer hinzufügen...' },
add_user: { en: 'Add User', de: 'Benutzer hinzufügen' },
@@ -223,17 +222,8 @@ const translations: Translations = {
split_method: { en: 'Split Method', de: 'Aufteilungsmethode' },
how_split: { en: 'How should this payment be split?', de: 'Wie soll diese Zahlung aufgeteilt werden?' },
split_5050: { en: 'Split 50/50', de: '50/50 teilen' },
equal_split: { en: 'Equal Split', de: 'Gleichmässig' },
personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönlich + Gleichmässig' },
custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' },
custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' },
personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' },
personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge pro Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' },
total_personal: { en: 'Total Personal', de: 'Persönlich gesamt' },
remainder_to_split: { en: 'Remainder to Split', de: 'Restbetrag zum Aufteilen' },
personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' },
split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' },
owes: { en: 'owes', de: 'schuldet' },
is_owed: { en: 'is owed', de: 'bekommt' },
error_prefix: { en: 'Error', de: 'Fehler' },
@@ -270,7 +260,6 @@ const translations: Translations = {
freq_monthly: { en: 'Monthly', de: 'Monatlich' },
freq_quarterly: { en: 'Quarterly', de: 'Vierteljährlich' },
freq_yearly: { en: 'Yearly', de: 'Jährlich' },
freq_custom: { en: 'Custom (Cron)', de: 'Benutzerdefiniert (Cron)' },
start_date: { en: 'Start Date *', de: 'Startdatum *' },
end_date_optional: { en: 'End Date (optional)', de: 'Enddatum (optional)' },
end_date_hint: { en: 'Leave empty for indefinite recurring', de: 'Leer lassen für unbefristete Wiederholung' },
-2
View File
@@ -130,7 +130,6 @@ const translations: Translations = {
template_library: { en: 'Template Library', de: 'Vorlagen-Bibliothek' },
browse_library: { en: 'Browse Library', de: 'Bibliothek durchsuchen' },
template_added: { en: 'Template added', de: 'Vorlage hinzugefügt' },
loading: { en: 'Loading', de: 'Laden' },
edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' },
new_template: { en: 'New Template', de: 'Neue Vorlage' },
template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' },
@@ -448,7 +447,6 @@ const translations: Translations = {
// 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' },
+1
View File
@@ -1,3 +1,4 @@
// @ts-expect-error — web-haptics has no bundled .d.ts; shim types as any at boundary
import { createWebHaptics } from 'web-haptics/svelte';
export type HapticPulse = { duration: number; intensity?: number };
+1 -1
View File
@@ -193,7 +193,7 @@ function computeNutritionInfo(
if ((!mappings || mappings.length === 0) && (!referencedNutrition || referencedNutrition.length === 0)) return null;
const index = new Map(
mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
(mappings ?? []).map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const totals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0, sodium: 0, cholesterol: 0 };
+1 -1
View File
@@ -156,7 +156,7 @@ const ICON_ALIASES: Record<string, string> = {
// Swiss German → High German aliases
'rahm': 'sahne', 'schlagrahm': 'schlagsahne', 'halbrahm': 'sahne', 'vollrahm': 'sahne',
'rüebli': 'karotten', 'rüebli': 'karotten',
'rüebli': 'karotten',
'nüsslisalat': 'feldsalat', 'federkohl': 'grünkohl',
'peperoni': 'paprika', 'peperoncini': 'chili',
'poulet': 'hähnchen', 'pouletbrust': 'hähnchenbrust', 'pouletschenkel': 'hähnchenschenkel',
@@ -2,7 +2,15 @@ import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { getShoppingUser } from '$lib/server/shoppingAuth';
import { dbConnect } from '$utils/db';
import { ShoppingList } from '$models/ShoppingList';
import { ShoppingList, type IShoppingItem } from '$models/ShoppingList';
import type { ShoppingItem } from '$lib/js/shoppingSync.svelte';
function serializeItems(items: IShoppingItem[]): ShoppingItem[] {
return items.map((it) => ({
...it,
addedAt: it.addedAt instanceof Date ? it.addedAt.toISOString() : String(it.addedAt)
}));
}
export const load: PageServerLoad = async ({ locals, url }) => {
const session = await locals.auth();
@@ -17,7 +25,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return {
session: null,
shareToken: token,
initialList: list ? { version: list.version, items: list.items } : { version: 0, items: [] }
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
};
}
}
@@ -29,6 +37,6 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return {
session,
shareToken: null,
initialList: list ? { version: list.version, items: list.items } : { version: 0, items: [] }
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
};
};
@@ -64,6 +64,7 @@
let selectedStore = $state(STORE_NAMES[0]);
let categoryOrder = $derived(STORE_PRESETS[selectedStore] || STORE_PRESETS[STORE_NAMES[0]]);
/** @param {string} name */
function setStore(name) {
selectedStore = name;
try { localStorage.setItem('shopping-store', name); } catch { /* ignore */ }
@@ -107,7 +108,10 @@
return { qty: null, name: raw };
}
/** Get icon URL for an item */
/**
* Get icon URL for an item
* @param {import('$lib/js/shoppingSync.svelte').ShoppingItem} item
*/
function iconUrl(item) {
if (item.icon) return `https://bocken.org/static/shopping-icons/${item.icon}.png`;
// Fallback: first letter
@@ -122,8 +126,12 @@
const groups = new Map();
for (const item of sync.items) {
if (!groups.has(item.category)) groups.set(item.category, []);
groups.get(item.category).push(item);
let arr = groups.get(item.category);
if (!arr) {
arr = [];
groups.set(item.category, arr);
}
arr.push(item);
}
for (const [, items] of groups) {
@@ -132,7 +140,7 @@
const ordered = categoryOrder
.filter(cat => groups.has(cat))
.map(cat => ({ category: cat, items: groups.get(cat) }));
.map(cat => ({ category: cat, items: groups.get(cat) ?? [] }));
for (const [cat, items] of groups) {
if (!categoryOrder.includes(cat)) {
@@ -31,5 +31,6 @@ export const load: PageServerLoad = async ({ locals, fetch, url }) => {
} catch (e) {
console.error('Error loading payments data:', e);
await errorWithVerse(fetch, url.pathname, 500, 'Failed to load payments data');
throw new Error('unreachable');
}
};
@@ -78,7 +78,10 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const yearMap =
adventIOfUrlYear != null && iso >= adventIOfUrlYear ? yearMapNext : yearMapN;
const entry = yearMap.get(iso);
if (!entry) await errorWithVerse(fetch, url.pathname, 404, 'Not found');
if (!entry) {
await errorWithVerse(fetch, url.pathname, 404, 'Not found');
throw new Error('unreachable');
}
const today = new Date();
const todayIso = today.toISOString().slice(0, 10);
@@ -5,6 +5,20 @@ import { validPrayerSlugs } from '$lib/data/prayerSlugs';
const angelusSlugs = new Set(['angelus', 'regina-caeli']);
type AngelusStreak = {
streak: number;
lastComplete: string | null;
todayPrayed: number;
todayDate: string | null;
};
interface PrayerPageData {
prayer: string;
initialLatin: boolean;
hasUrlLatin: boolean;
angelusStreak?: AngelusStreak | null;
}
export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
if (!validPrayerSlugs.has(params.prayer)) {
await errorWithVerse(fetch, url.pathname, 404, 'Prayer not found');
@@ -14,7 +28,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
const hasUrlLatin = latinParam !== null;
const initialLatin = hasUrlLatin ? latinParam !== '0' : true;
const result: Record<string, unknown> = {
const result: PrayerPageData = {
prayer: params.prayer,
initialLatin,
hasUrlLatin
@@ -27,7 +41,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
try {
const res = await fetch('/api/glaube/angelus-streak');
if (res.ok) {
result.angelusStreak = await res.json();
result.angelusStreak = (await res.json()) as AngelusStreak;
}
} catch {
// Fail silently — streak will use localStorage
@@ -1,10 +1,12 @@
<script>
import { ArrowDown, ArrowLeft } from '@lucide/svelte';
import { page } from '$app/stores';
/** @type {number | string | null} */
let expanded = $state(null);
const isGerman = $derived($page.url.pathname.startsWith('/glaube'));
const isLatin = $derived($page.url.pathname.startsWith('/fides'));
/** @param {number | string} id */
function toggle(id) {
expanded = expanded === id ? null : id;
}
@@ -7,7 +7,7 @@ export const load : LayoutServerLoad = async ({locals, params, fetch, url}) => {
await errorWithVerse(fetch, url.pathname, 404, 'Not found');
}
const lang = params.recipeLang === 'recipes' ? 'en' : 'de';
const lang: 'en' | 'de' = params.recipeLang === 'recipes' ? 'en' : 'de';
return {
session: locals.session ?? await locals.auth(),
@@ -8,7 +8,7 @@ export const load: LayoutLoad = async ({ params, data }) => {
throw error(404, 'Not found');
}
const lang = params.recipeLang === 'recipes' ? 'en' : 'de';
const lang: 'en' | 'de' = params.recipeLang === 'recipes' ? 'en' : 'de';
// Check if we're offline:
// 1. Browser reports offline (navigator.onLine === false)
@@ -1,9 +1,12 @@
<script lang="ts">
import type { PageData } from './$types';
import type { BriefRecipeType } from '$types/types';
import Search from '$lib/components/recipes/Search.svelte';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import { rand_array } from '$lib/js/randomize';
type RecipeItem = BriefRecipeType & { isFavorite: boolean };
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
@@ -11,17 +14,19 @@
const label = $derived(isEnglish ? 'Recipes in Category' : 'Rezepte in Kategorie');
const siteTitle = $derived(isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte');
let matchedRecipeIds = $state(new Set());
let matchedRecipeIds = $state(new Set<string>());
let hasActiveSearch = $state(false);
function handleSearchResults(ids: Set<string>, categories: Set<string>) {
matchedRecipeIds = ids;
hasActiveSearch = ids.size < data.allRecipes.length;
hasActiveSearch = ids.size < (data.allRecipes as RecipeItem[]).length;
}
const displayRecipes = $derived.by(() => {
if (!hasActiveSearch) return data.recipes;
return data.allRecipes.filter((r: any) => matchedRecipeIds.has(r._id));
const displayRecipes = $derived.by((): RecipeItem[] => {
const all = data.allRecipes as RecipeItem[];
const base = data.recipes as RecipeItem[];
if (!hasActiveSearch) return base;
return all.filter((r) => matchedRecipeIds.has(r._id));
});
</script>
<style>
@@ -1,9 +1,12 @@
<script lang="ts">
import type { PageData } from './$types';
import type { BriefRecipeType } from '$types/types';
import CompactCard from '$lib/components/recipes/CompactCard.svelte';
import Search from '$lib/components/recipes/Search.svelte';
import { rand_array } from '$lib/js/randomize';
type RecipeItem = BriefRecipeType & { isFavorite: boolean };
let { data } = $props<{ data: PageData }>();
let current_month = new Date().getMonth() + 1;
@@ -11,17 +14,19 @@
const label = $derived(isEnglish ? 'Recipes with Keyword' : 'Rezepte mit Stichwort');
const siteTitle = $derived(isEnglish ? 'Bocken Recipes' : 'Bocken Rezepte');
let matchedRecipeIds = $state(new Set());
let matchedRecipeIds = $state(new Set<string>());
let hasActiveSearch = $state(false);
function handleSearchResults(ids: Set<string>, categories: Set<string>) {
matchedRecipeIds = ids;
hasActiveSearch = ids.size < data.allRecipes.length;
hasActiveSearch = ids.size < (data.allRecipes as RecipeItem[]).length;
}
const displayRecipes = $derived.by(() => {
if (!hasActiveSearch) return data.recipes;
return data.allRecipes.filter((r: any) => matchedRecipeIds.has(r._id));
const displayRecipes = $derived.by((): RecipeItem[] => {
const all = data.allRecipes as RecipeItem[];
const base = data.recipes as RecipeItem[];
if (!hasActiveSearch) return base;
return all.filter((r) => matchedRecipeIds.has(r._id));
});
</script>
<style>
+8 -8
View File
@@ -1,17 +1,17 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { dbConnect } from '$utils/db';
import { ShoppingList } from '$models/ShoppingList';
import { ShoppingList, type IShoppingList } from '$models/ShoppingList';
import { broadcast } from '$lib/server/shoppingSSE';
import { getShoppingUser } from '$lib/server/shoppingAuth';
async function getOrCreateList() {
let list = await ShoppingList.findOne().lean();
if (!list) {
list = await ShoppingList.create({ version: 0, items: [] });
list = list.toObject();
}
return list;
type ShoppingListDoc = IShoppingList & { version: number };
async function getOrCreateList(): Promise<ShoppingListDoc> {
const existing = await ShoppingList.findOne().lean<ShoppingListDoc>();
if (existing) return existing;
const created = await ShoppingList.create({ version: 0, items: [] });
return created.toObject() as ShoppingListDoc;
}
// GET /api/cospend/list — fetch current shopping list
+1 -1
View File
@@ -137,7 +137,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
};
});
const splitPromises = convertedSplits.map((split) => {
const splitPromises = convertedSplits.map((split: (typeof convertedSplits)[number]) => {
return PaymentSplit.create(split as any);
});
+1 -1
View File
@@ -48,7 +48,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
const entry = await PeriodEntry.create({
startDate: start,
endDate: endDate ? new Date(endDate) : null,
endDate: endDate ? new Date(endDate) : undefined,
createdBy: user.nickname
});
@@ -32,11 +32,11 @@ export const GET: RequestHandler = async ({ locals }) => {
BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
).sort({ date: 1 }).lean() as any[],
).sort({ date: 1 }).lean() as unknown as any[],
WorkoutSession.find(
{ createdBy: user.nickname, startTime: { $gte: thirtyDaysAgo, $lt: todayStart }, 'kcalEstimate.kcal': { $gt: 0 } },
{ startTime: 1, 'kcalEstimate.kcal': 1, _id: 0 }
).lean() as any[],
).lean() as unknown as any[],
]);
// Compute trend weight (SMA of last measurements, same algo as overview)
+2 -2
View File
@@ -135,7 +135,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const favDocs = await FavoriteIngredient.find({ createdBy: user.nickname }).lean();
// Batch-load favorited recipes
const recipeFavIds = favDocs.filter(f => f.source === 'recipe').map(f => f.sourceId);
const recipeFavIds = favDocs.filter(f => (f.source as string) === 'recipe').map(f => f.sourceId);
const favRecipes = recipeFavIds.length > 0
? await Recipe.find({
$or: recipeFavIds.map(id =>
@@ -156,7 +156,7 @@ export const GET: RequestHandler = async ({ url, locals }) => {
result = lookupBls(fav.sourceId, full);
} else if (fav.source === 'usda') {
result = lookupUsda(fav.sourceId, full);
} else if (fav.source === 'recipe') {
} else if ((fav.source as string) === 'recipe') {
const r = favRecipeMap.get(fav.sourceId);
if (r) {
const nutrition = computeRecipePer100g(r);
+1 -1
View File
@@ -117,7 +117,7 @@
onPauseToggle={() => workout.paused ? workout.resumeTimer() : workout.pauseTimer()}
restSeconds={workout.restTimerSeconds}
restTotal={workout.restTimerTotal}
onRestAdjust={(delta) => workout.adjustRestTimer(delta)}
onRestAdjust={(/** @type {number} */ delta) => workout.adjustRestTimer(delta)}
onRestSkip={() => workout.cancelRestTimer()}
/>
{/if}
@@ -14,7 +14,9 @@
let { data } = $props();
let query = $state('');
/** @type {string[]} */
let equipmentFilters = $state([]);
/** @type {string[]} */
let muscleGroups = $state([]);
/** @type {'all' | 'stretch' | 'non-stretch'} */
let typeFilter = $state('all');
@@ -31,32 +33,40 @@
return [...selected, ...rest];
});
/** Display label for a muscle group */
/**
* Display label for a muscle group
* @param {string} group
*/
function muscleLabel(group) {
const raw = isEn ? group : (MUSCLE_GROUP_DE[group] ?? group);
return raw.charAt(0).toUpperCase() + raw.slice(1);
}
/** @param {string} group */
function addMuscle(group) {
if (group && !muscleGroups.includes(group)) {
muscleGroups = [...muscleGroups, group];
}
}
/** @param {string} group */
function removeMuscle(group) {
muscleGroups = muscleGroups.filter(g => g !== group);
}
/** @param {string} eq */
function addEquipment(eq) {
if (eq && !equipmentFilters.includes(eq)) {
equipmentFilters = [...equipmentFilters, eq];
}
}
/** @param {string} eq */
function removeEquipment(eq) {
equipmentFilters = equipmentFilters.filter(e => e !== eq);
}
/** @param {string} eq */
function equipmentLabel(eq) {
const raw = translateTerm(eq, lang);
return raw.charAt(0).toUpperCase() + raw.slice(1);
@@ -74,11 +84,13 @@
}
}
/** @param {string} eq */
function toggleEquipment(eq) {
if (equipmentFilters.includes(eq)) removeEquipment(eq);
else addEquipment(eq);
}
/** @param {string} group */
function toggleMuscle(group) {
if (muscleGroups.includes(group)) removeMuscle(group);
else addMuscle(group);
@@ -184,11 +184,13 @@
const filledCount = $derived(bpMarkers.filter((m) => m.filled).length);
const totalParts = 13;
/** @param {number} delta */
function stepWeight(delta) {
const cur = Number(formWeight) || lastWeight || 0;
formWeight = (Math.round((cur + delta) * 10) / 10).toFixed(1);
}
/** @param {number} delta */
function stepBodyFat(delta) {
const cur = Number(formBodyFat) || lastBodyFat || 0;
formBodyFat = (Math.round((cur + delta) * 10) / 10).toFixed(1);
@@ -47,17 +47,20 @@
/** @param {Step} s */
function historyFor(s) {
if (s.paired) {
const left = s.dbLeft ?? '';
const right = s.dbRight ?? '';
return past
.filter((/** @type {any} */ m) => m.measurements?.[s.dbLeft] != null || m.measurements?.[s.dbRight] != null)
.filter((/** @type {any} */ m) => m.measurements?.[left] != null || m.measurements?.[right] != null)
.map((/** @type {any} */ m) => ({
date: m.date,
left: m.measurements?.[s.dbLeft] ?? null,
right: m.measurements?.[s.dbRight] ?? null
left: m.measurements?.[left] ?? null,
right: m.measurements?.[right] ?? null
}));
}
const single = s.dbSingle ?? '';
return past
.filter((/** @type {any} */ m) => m.measurements?.[s.dbSingle] != null)
.map((/** @type {any} */ m) => ({ date: m.date, value: m.measurements[s.dbSingle] }));
.filter((/** @type {any} */ m) => m.measurements?.[single] != null)
.map((/** @type {any} */ m) => ({ date: m.date, value: m.measurements[single] }));
}
/** @type {Record<string, any>} */
@@ -12,6 +12,41 @@
import { confirm } from '$lib/js/confirmDialog.svelte';
import { getDRI, NUTRIENT_META } from '$lib/data/dailyReferenceIntake';
/**
* @typedef {{
* _id: string,
* mealType: string,
* name: string,
* source?: string,
* sourceId?: string,
* amountGrams: number,
* liquidMl?: number,
* per100g?: Record<string, number>,
* }} FoodLogEntry
*
* @typedef {{
* name: string,
* source?: string,
* sourceId?: string,
* amountGrams: number,
* per100g?: Record<string, number>,
* }} MealIngredient
*
* @typedef {{
* _id: string,
* name: string,
* ingredients: MealIngredient[],
* }} CustomMeal
*
* @typedef {{
* name: string,
* source: string,
* sourceId: string,
* amountGrams: number,
* per100g: Record<string, number>,
* }} FoodSelection
*/
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const isEn = $derived(lang === 'en');
@@ -29,6 +64,7 @@
return d.toLocaleDateString(isEn ? 'en-US' : 'de-DE', { weekday: 'short', day: 'numeric', month: 'short' });
});
/** @param {number} offset */
function dateOffset(offset) {
const d = new Date(currentDate + 'T12:00:00');
d.setDate(d.getDate() + offset);
@@ -43,9 +79,9 @@
// --- Entries ---
// svelte-ignore state_referenced_locally
let entries = $state(data.foodLog?.entries ?? []);
let entries = $state(/** @type {FoodLogEntry[]} */ (data.foodLog?.entries ?? []));
// svelte-ignore state_referenced_locally
let recipeImages = $state(data.recipeImages ?? {});
let recipeImages = $state(/** @type {Record<string, string>} */ (data.recipeImages ?? {}));
// Keep reactive with server data when navigating
$effect(() => {
@@ -120,6 +156,10 @@
let goalStep = $state(1);
let selectedPresetIdx = $state(-1);
/**
* @param {(typeof dietPresets)[number]} preset
* @param {number} idx
*/
function applyPreset(preset, idx) {
selectedPresetIdx = idx;
editProteinMode = preset.proteinMode;
@@ -226,6 +266,7 @@
}
// --- Computed daily totals ---
/** @type {Array<'breakfast'|'lunch'|'dinner'|'snack'>} */
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
const grouped = $derived.by(() => {
@@ -252,13 +293,17 @@
/** Detect if a food log entry is a beverage (non-water) */
const DRINK_PATTERNS = /^(milch|kaffee|coffee|tee|tea|cola|fanta|sprite|saft|juice|limo|smoothie|kakao|cocoa|bier|beer|wein|wine|eistee|ice tea|energy|redbull|red bull|mate|schorle|sprudel|mineral|orangensaft|apfelsaft|multivitamin|iso|gatorade|powerade)/i;
/** @param {FoodLogEntry} e */
function isBeverage(e) {
if (e.mealType === 'water') return false;
if (e.source === 'bls' && e.sourceId?.startsWith('N')) return true;
return DRINK_PATTERNS.test(e.name);
}
/** Detect if a custom meal ingredient is a liquid (for hydration auto-logging) */
/**
* Detect if a custom meal ingredient is a liquid (for hydration auto-logging)
* @param {MealIngredient} ing
*/
function isLiquidIngredient(ing) {
if (ing.source === 'bls' && ing.sourceId?.startsWith('N')) return true;
return DRINK_PATTERNS.test(ing.name) || /^(wasser|water|trinkwasser)/i.test(ing.name);
@@ -283,11 +328,11 @@
editingGoal = false;
}
let waterEntries = $derived(entries.filter(e => e.mealType === 'water'));
let waterEntries = $derived(entries.filter((/** @type {FoodLogEntry} */ e) => e.mealType === 'water'));
let beverageEntries = $derived(entries.filter(isBeverage));
let waterMl = $derived(waterEntries.reduce((s, e) => s + e.amountGrams, 0));
let beverageMl = $derived(beverageEntries.reduce((s, e) => s + e.amountGrams, 0));
let mealLiquidMl = $derived(entries.reduce((s, e) => s + (e.liquidMl ?? 0), 0));
let waterMl = $derived(waterEntries.reduce((/** @type {number} */ s, /** @type {FoodLogEntry} */ e) => s + e.amountGrams, 0));
let beverageMl = $derived(beverageEntries.reduce((/** @type {number} */ s, /** @type {FoodLogEntry} */ e) => s + e.amountGrams, 0));
let mealLiquidMl = $derived(entries.reduce((/** @type {number} */ s, /** @type {FoodLogEntry} */ e) => s + (e.liquidMl ?? 0), 0));
let totalLiquidMl = $derived(waterMl + beverageMl + mealLiquidMl);
let beverageCups = $derived(Math.round(beverageMl / WATER_CUP_ML));
let waterCups = $derived(Math.round(waterMl / WATER_CUP_ML));
@@ -321,6 +366,7 @@
lastTotalCups = cur;
});
/** @param {number} target */
async function setWaterCups(target) {
const current = waterCups;
if (target === current) return;
@@ -347,20 +393,25 @@
if (newEntries.length) entries = [...entries, ...newEntries];
} else {
const toRemove = waterEntries.slice(target);
const ids = toRemove.map(e => e._id);
await Promise.all(ids.map(id =>
const ids = toRemove.map((/** @type {FoodLogEntry} */ e) => e._id);
await Promise.all(ids.map((/** @type {string} */ id) =>
fetch(`/api/fitness/food-log/${id}`, { method: 'DELETE' })
));
entries = entries.filter(e => !ids.includes(e._id));
entries = entries.filter((/** @type {FoodLogEntry} */ e) => !ids.includes(e._id));
}
} catch {
toast.error(isEn ? 'Failed to update water' : 'Fehler beim Aktualisieren');
}
}
/** @param {FoodLogEntry} e */
function entryCalories(e) {
return (e.per100g?.calories ?? 0) * e.amountGrams / 100;
}
/**
* @param {FoodLogEntry} e
* @param {string} key
*/
function entryNutrient(e, key) {
return (e.per100g?.[key] ?? 0) * e.amountGrams / 100;
}
@@ -374,14 +425,16 @@
const dayTotals = $derived.by(() => {
let calories = 0, protein = 0, fat = 0, carbs = 0, fiber = 0, sugars = 0, saturatedFat = 0;
/** @type {Record<string, number>} */
const micros = {};
/** @type {Record<string, number>} */
const aminos = {};
for (const k of microKeys) micros[k] = 0;
for (const k of aminoKeys) aminos[k] = 0;
for (const e of entries) {
const r = e.amountGrams / 100;
const p = e.per100g ?? {};
const p = /** @type {Record<string, number>} */ (e.per100g ?? {});
calories += (p.calories ?? 0) * r;
protein += (p.protein ?? 0) * r;
fat += (p.fat ?? 0) * r;
@@ -468,6 +521,7 @@
const hasBmrData = $derived(latestWeight != null && data.goal?.heightCm != null && birthYear != null);
// NEAT-only multipliers (exercise tracked separately)
/** @type {Record<string, number>} */
const ACTIVITY_MULT = { sedentary: 1.2, light: 1.3, moderate: 1.4, very_active: 1.5 };
const dailyBmr = $derived.by(() => {
@@ -537,16 +591,21 @@
const ARC_LENGTH = (ARC_DEGREES / 360) * 2 * Math.PI * RADIUS;
const ARC_ROTATE = 120;
/** @param {number} percent */
function strokeOffset(percent) {
return ARC_LENGTH - (Math.min(percent, 100) / 100) * ARC_LENGTH;
}
/** Stroke offset for overflow arc drawn from the end backwards */
/**
* Stroke offset for overflow arc drawn from the end backwards
* @param {number} overflowPct
*/
function overflowOffset(overflowPct) {
return ARC_LENGTH - (Math.min(overflowPct, 100) / 100) * ARC_LENGTH;
}
// --- Inline add food ---
/** @type {string | null} */
let addingMeal = $state(null);
let inlineTab = $state('search'); // 'search' | 'favorites' | 'meals'
@@ -578,6 +637,7 @@
goto(`/fitness/${s.nutrition}`, { replaceState: true, keepFocus: true, noScroll: true });
}
/** @param {FoodSelection} food */
async function fabLogFood(food) {
try {
const res = await fetch('/api/fitness/food-log', {
@@ -606,7 +666,10 @@
// --- Custom meals in FAB ---
let fabTab = $state('search'); // 'search' | 'favorites' | 'meals'
/** @typedef {{ name: string, source: string, id: string, per100g: Record<string, number>, portions?: any, calories: number, favorited: boolean }} FavTabItem */
// --- Favorites tab ---
/** @type {FavTabItem[]} */
let favTabItems = $state([]); // enriched with per100g
let favTabLoaded = $state(false);
@@ -614,7 +677,7 @@
if (favTabLoaded && !force) return;
const favs = quickFavorites;
// Fetch per100g for each favorite in parallel
const enriched = await Promise.all(favs.map(async (fav) => {
const enriched = await Promise.all(favs.map(async (/** @type {{ name: string, source: string, sourceId: string }} */ fav) => {
try {
const res = await fetch(`/api/nutrition/lookup?source=${fav.source}&id=${encodeURIComponent(fav.sourceId)}`);
if (res.ok) {
@@ -632,9 +695,10 @@
} catch {}
return null;
}));
favTabItems = enriched.filter(Boolean);
favTabItems = /** @type {FavTabItem[]} */ (enriched.filter(Boolean));
favTabLoaded = true;
}
/** @type {CustomMeal[]} */
let customMeals = $state([]);
let customMealsLoaded = $state(false);
@@ -647,10 +711,12 @@
);
// Custom meal detail screen (replaces meal list when a meal is selected)
/** @type {CustomMeal | null} */
let selectedCmMeal = $state(null);
let cmAmountMode = $state('multiplier'); // 'multiplier' | 'grams'
let cmAmountVal = $state(1.0);
/** @param {CustomMeal} meal */
function selectCmMeal(meal) {
selectedCmMeal = meal;
cmAmountMode = 'multiplier';
@@ -661,13 +727,17 @@
selectedCmMeal = null;
}
/** @param {CustomMeal} meal */
function cmResolvedGrams(meal) {
const base = mealTotalGrams(meal);
if (cmAmountMode === 'grams') return cmAmountVal;
return base * cmAmountVal;
}
/** Preview macros scaled to the selected amount */
/**
* Preview macros scaled to the selected amount
* @param {CustomMeal} meal
*/
function cmPreview(meal) {
const { per100g, totalGrams } = aggregateMealPer100g(meal);
const grams = cmResolvedGrams(meal);
@@ -707,15 +777,19 @@
'tryptophan', 'valine', 'histidine', 'alanine', 'arginine', 'asparticAcid',
'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
/** @param {CustomMeal} meal */
function mealTotalGrams(meal) {
return meal.ingredients.reduce((sum, ing) => sum + ing.amountGrams, 0);
return meal.ingredients.reduce((/** @type {number} */ sum, /** @type {MealIngredient} */ ing) => sum + ing.amountGrams, 0);
}
/** @param {CustomMeal} meal */
function mealTotalCal(meal) {
return meal.ingredients.reduce((sum, ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
return meal.ingredients.reduce((/** @type {number} */ sum, /** @type {MealIngredient} */ ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
}
/** @param {CustomMeal} meal */
function aggregateMealPer100g(meal) {
/** @type {Record<string, number>} */
const totals = {};
for (const k of NUTRIENT_KEYS) totals[k] = 0;
let totalGrams = 0;
@@ -724,15 +798,20 @@
totalGrams += ing.amountGrams;
for (const k of NUTRIENT_KEYS) totals[k] += (ing.per100g?.[k] ?? 0) * r;
}
/** @type {Record<string, number>} */
const per100g = {};
const scale = totalGrams > 0 ? 100 / totalGrams : 0;
for (const k of NUTRIENT_KEYS) per100g[k] = totals[k] * scale;
const liquidMl = meal.ingredients
.filter(isLiquidIngredient)
.reduce((sum, ing) => sum + ing.amountGrams, 0);
.reduce((/** @type {number} */ sum, /** @type {MealIngredient} */ ing) => sum + ing.amountGrams, 0);
return { per100g, totalGrams, liquidMl };
}
/**
* @param {CustomMeal} meal
* @param {number | null} [amountGrams]
*/
async function logCustomMeal(meal, amountGrams = null) {
try {
const { per100g, totalGrams, liquidMl } = aggregateMealPer100g(meal);
@@ -766,6 +845,7 @@
}
}
/** @param {string} meal */
function startAdd(meal) {
addingMeal = meal;
inlineTab = 'search';
@@ -780,6 +860,10 @@
cmFilter = '';
}
/**
* @param {CustomMeal} meal
* @param {number | null} [amountGrams]
*/
async function inlineLogCustomMeal(meal, amountGrams = null) {
if (!addingMeal) return;
try {
@@ -814,6 +898,7 @@
}
}
/** @param {FoodSelection} food */
async function inlineLogFood(food) {
try {
const res = await fetch('/api/fitness/food-log', {
@@ -845,10 +930,11 @@
/** @type {'breakfast'|'lunch'|'dinner'|'snack'} */
let editingMeal = $state('breakfast');
/** @param {FoodLogEntry} entry */
function startEditEntry(entry) {
editingEntryId = entry._id;
editingGrams = entry.amountGrams;
editingMeal = entry.mealType;
editingMeal = /** @type {'breakfast'|'lunch'|'dinner'|'snack'} */ (entry.mealType);
}
async function saveEditEntry() {
@@ -935,6 +1021,7 @@
if (id) moveEntryToMeal(id, meal);
}
/** @param {string} id */
async function deleteEntry(id) {
if (!await confirm(t('delete_entry_confirm', lang))) return;
try {
@@ -981,11 +1068,12 @@
const vitamins = ['vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate'];
const other = ['cholesterol'];
/** @param {string[]} keys */
function mkRows(keys) {
return keys.map(k => {
const meta = NUTRIENT_META[k];
const meta = NUTRIENT_META[/** @type {keyof typeof NUTRIENT_META} */ (k)];
const value = dayTotals.micros[k] ?? 0;
const goal = dri[k] ?? 0;
const goal = dri[/** @type {keyof typeof dri} */ (k)] ?? 0;
const pct = goal > 0 ? Math.round(value / goal * 100) : 0;
return { key: k, label: isEn ? meta.label : meta.labelDe, unit: meta.unit, value, goal, pct, isMax: meta.isMax };
});
@@ -998,10 +1086,10 @@
const aminoRows = [...essentialOrder, ...nonEssentialOrder].map(k => {
const value = dayTotals.aminos[k] ?? 0;
// WHO DRI is mg/kg/day; value is in grams → convert goal to grams
const driPerKg = AMINO_DRI_PER_KG[k];
const driPerKg = AMINO_DRI_PER_KG[/** @type {keyof typeof AMINO_DRI_PER_KG} */ (k)];
const goal = driPerKg ? (driPerKg * w) / 1000 : 0;
const pct = goal > 0 ? Math.round(value / goal * 100) : 0;
const meta = AMINO_META[k];
const meta = AMINO_META[/** @type {keyof typeof AMINO_META} */ (k)];
return { key: k, label: isEn ? meta.en : meta.de, unit: 'g', value, goal, pct, isMax: false };
});
@@ -1013,12 +1101,14 @@
];
});
/** @param {number} v */
function fmt(v) {
if (v >= 100) return Math.round(v).toString();
if (v >= 10) return v.toFixed(1);
return v.toFixed(1);
}
/** @param {number} v */
function fmtCal(v) {
return Math.round(v).toString();
}
@@ -1030,12 +1120,16 @@
snack: { icon: Cookie, color: 'var(--nord14)' },
};
/** @typedef {{ name: string, source: string, sourceId: string }} QuickFavorite */
/** @typedef {{ name: string, source: string, sourceId: string, mealType?: string, amountGrams?: number, per100g?: Record<string, number> }} RecentFood */
// --- Quick-log sidebar ---
/** @type {'breakfast' | 'lunch' | 'dinner' | 'snack'} */
let quickLogMealType = $state(defaultMealType());
// svelte-ignore state_referenced_locally
let quickFavorites = $state(data.favorites ?? []);
let quickFavorites = $state(/** @type {QuickFavorite[]} */ (data.favorites ?? []));
// svelte-ignore state_referenced_locally
let historicalRecents = $state(data.recentFoods ?? []);
let historicalRecents = $state(/** @type {RecentFood[]} */ (data.recentFoods ?? []));
$effect(() => {
quickFavorites = data.favorites ?? [];
@@ -1074,11 +1168,12 @@
favTabLoaded = false;
}
/** @type {{ name: string, source: string, sourceId: string, per100g?: any, amountGrams?: number } | null} */
/** @type {{ name: string, source?: string, sourceId?: string, per100g?: any, amountGrams?: number } | null} */
let qlSelected = $state(null);
let qlGrams = $state(100);
let qlLoading = $state(false);
/** @param {{ name: string, source?: string, sourceId?: string, per100g?: any, amountGrams?: number }} item */
async function qlSelect(item) {
if (qlSelected && qlSelected.source === item.source && qlSelected.sourceId === item.sourceId) {
qlSelected = null;
@@ -1091,7 +1186,7 @@
// Favorites don't have per100g — fetch by exact source+id
qlLoading = true;
try {
const res = await fetch(`/api/nutrition/lookup?source=${item.source}&id=${encodeURIComponent(item.sourceId)}`);
const res = await fetch(`/api/nutrition/lookup?source=${item.source}&id=${encodeURIComponent(item.sourceId ?? '')}`);
if (res.ok) {
const data = await res.json();
if (data.per100g) {
@@ -1139,7 +1234,7 @@
<title>{t('nutrition_title', lang)} — Fitness</title>
</svelte:head>
{#snippet cmDetailScreen(meal, logFn)}
{#snippet cmDetailScreen(/** @type {CustomMeal} */ meal, /** @type {(m: CustomMeal, grams?: number | null) => void} */ logFn)}
{@const preview = cmPreview(meal)}
<div class="cm-detail">
<div class="cm-detail-header">
@@ -1194,7 +1289,7 @@
</div>
{/snippet}
{#snippet favoritesTab(logFn)}
{#snippet favoritesTab(/** @type {(food: FoodSelection) => void} */ logFn)}
<div class="fav-tab-list">
{#if !favTabLoaded}
<p class="meals-empty">{t('loading', lang)}</p>
@@ -1206,7 +1301,7 @@
</div>
{/snippet}
{#snippet customMealsTab(logFn)}
{#snippet customMealsTab(/** @type {(m: CustomMeal, grams?: number | null) => void} */ logFn)}
{#if selectedCmMeal}
{@render cmDetailScreen(selectedCmMeal, logFn)}
{:else}
@@ -1719,7 +1814,7 @@
{#each entries.filter(e => (e.liquidMl ?? 0) > 0) as e}
<div class="beverage-item">
<span class="beverage-name">{e.name}</span>
<span class="beverage-ml">{Math.round(e.liquidMl)} ml</span>
<span class="beverage-ml">{Math.round(e.liquidMl ?? 0)} ml</span>
</div>
{/each}
</div>
@@ -69,7 +69,10 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
if (source === 'bls') {
const entry = BLS_DB.find(e => e.blsCode === id);
if (!entry) await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
if (!entry) {
await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
throw new Error('unreachable');
}
return {
food: {
source: 'bls' as const,
@@ -91,7 +94,10 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
const recipe = await Recipe.findOne(recipeQuery)
.select('short_name name translations images')
.lean();
if (!recipe) await errorWithVerse(fetch, url.pathname, 404, 'Recipe not found');
if (!recipe) {
await errorWithVerse(fetch, url.pathname, 404, 'Recipe not found');
throw new Error('unreachable');
}
// Use logged per100g from food diary entry if provided, otherwise compute from current recipe
const logEntryId = url.searchParams.get('logEntry');
@@ -131,7 +137,10 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
if (source === 'off') {
await dbConnect();
const entry = await OpenFoodFact.findOne({ barcode: id }).lean();
if (!entry) await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
if (!entry) {
await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
throw new Error('unreachable');
}
const portions: { description: string; grams: number }[] = [];
if (entry.serving?.grams) {
portions.push(entry.serving as { description: string; grams: number });
@@ -156,7 +165,10 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
if (source === 'custom') {
await dbConnect();
const meal = await CustomMeal.findById(id).lean();
if (!meal) await errorWithVerse(fetch, url.pathname, 404, 'Meal not found');
if (!meal) {
await errorWithVerse(fetch, url.pathname, 404, 'Meal not found');
throw new Error('unreachable');
}
// Aggregate per100g from ingredients
const totals: Record<string, number> = {};
@@ -211,7 +223,10 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => {
// USDA
const fdcId = Number(id);
const entry = NUTRITION_DB.find(e => e.fdcId === fdcId);
if (!entry) await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
if (!entry) {
await errorWithVerse(fetch, url.pathname, 404, 'Food not found');
throw new Error('unreachable');
}
return {
food: {
source: 'usda' as const,
@@ -30,7 +30,10 @@
: isEn ? 'per 100 g' : 'pro 100 g'
);
/** Scale a nutrient value by the selected portion */
/**
* Scale a nutrient value by the selected portion
* @param {number | undefined | null} val
*/
function scaled(val) {
return (val ?? 0) * portionMultiplier;
}
@@ -50,6 +53,7 @@
});
// --- Formatting ---
/** @param {number | undefined | null} v */
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
@@ -62,11 +66,12 @@
const vitaminKeys = ['vitaminA', 'vitaminC', 'vitaminD', 'vitaminE', 'vitaminK', 'thiamin', 'riboflavin', 'niacin', 'vitaminB6', 'vitaminB12', 'folate'];
const otherKeys = ['cholesterol'];
/** @param {string[]} keys */
function mkMicroRows(keys) {
return keys.map(k => {
const meta = NUTRIENT_META[k];
const value = scaled(n[k]);
const goal = dri[k] ?? 0;
return keys.map((/** @type {string} */ k) => {
const meta = NUTRIENT_META[/** @type {keyof typeof NUTRIENT_META} */ (k)];
const value = scaled(/** @type {Record<string, number>} */ (n)[k]);
const goal = /** @type {Record<string, number>} */ (dri)[k] ?? 0;
const pct = goal > 0 ? Math.round(value / goal * 100) : 0;
return { key: k, label: isEn ? meta.label : meta.labelDe, unit: meta.unit, value, goal, pct, isMax: meta.isMax };
});
@@ -104,18 +109,22 @@
const nonEssentialOrder = ['alanine', 'arginine', 'asparticAcid', 'cysteine', 'glutamicAcid', 'glycine', 'proline', 'serine', 'tyrosine'];
const hasAminos = $derived.by(() => {
return essentialOrder.some(k => (n[k] ?? 0) > 0) || nonEssentialOrder.some(k => (n[k] ?? 0) > 0);
const nRec = /** @type {Record<string, number>} */ (n);
return essentialOrder.some(k => (nRec[k] ?? 0) > 0) || nonEssentialOrder.some(k => (nRec[k] ?? 0) > 0);
});
const aminoRows = $derived(
[...essentialOrder, ...nonEssentialOrder]
.filter(k => (n[k] ?? 0) > 0)
.map(k => ({
key: k,
label: isEn ? AMINO_META[k].en : AMINO_META[k].de,
value: scaled(n[k]),
essential: essentialOrder.includes(k),
}))
.filter(k => (/** @type {Record<string, number>} */ (n)[k] ?? 0) > 0)
.map(k => {
const aminoKey = /** @type {keyof typeof AMINO_META} */ (k);
return {
key: k,
label: isEn ? AMINO_META[aminoKey].en : AMINO_META[aminoKey].de,
value: scaled(/** @type {Record<string, number>} */ (n)[k]),
essential: essentialOrder.includes(k),
};
})
);
// --- Expand toggles ---
@@ -128,13 +137,14 @@
$effect(() => {
fetch('/api/fitness/favorite-ingredients').then(r => r.json()).then(data => {
favorited = (data.favorites ?? []).some(f => f.source === food.source && f.sourceId === (food.id ?? food.sourceId));
const foodId = food.id ?? /** @type {any} */ (food).sourceId;
favorited = (data.favorites ?? []).some((/** @type {{ source: string; sourceId: string }} */ f) => f.source === food.source && f.sourceId === foodId);
}).catch(() => {});
});
async function toggleFavorite() {
favLoading = true;
const id = food.id ?? food.sourceId;
const id = food.id ?? /** @type {any} */ (food).sourceId;
try {
if (favorited) {
await fetch('/api/fitness/favorite-ingredients', {
@@ -7,19 +7,24 @@
import { confirm } from '$lib/js/confirmDialog.svelte';
import FoodSearch from '$lib/components/fitness/FoodSearch.svelte';
import MacroBreakdown from '$lib/components/fitness/MacroBreakdown.svelte';
/** @typedef {import('$models/CustomMeal').ICustomMeal & { _id?: string }} Meal */
/** @typedef {import('$models/CustomMeal').ICustomMealIngredient} MealIngredient */
const lang = $derived(detectFitnessLang($page.url.pathname));
const s = $derived(fitnessSlugs(lang));
const isEn = $derived(lang === 'en');
// --- Meals state ---
/** @type {Meal[]} */
let meals = $state([]);
let loading = $state(true);
// --- Form state ---
let editing = $state(false);
/** @type {string | null} */
let editingId = $state(null);
let mealName = $state('');
/** @type {MealIngredient[]} */
let ingredients = $state([]);
let saving = $state(false);
@@ -46,10 +51,12 @@
});
// --- Computed ---
/** @param {Meal} meal */
function mealTotalCal(meal) {
return meal.ingredients.reduce((sum, ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
return meal.ingredients.reduce((/** @type {number} */ sum, /** @type {MealIngredient} */ ing) => sum + (ing.per100g?.calories ?? 0) * ing.amountGrams / 100, 0);
}
/** @param {MealIngredient[]} ings */
function ingredientsTotalNutrition(ings) {
let calories = 0, protein = 0, fat = 0, carbs = 0;
let saturatedFat = 0, sugars = 0, fiber = 0;
@@ -68,11 +75,13 @@
const formTotals = $derived(ingredientsTotalNutrition(ingredients));
/** @param {{ name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: { description: string; grams: number }[], selectedPortion?: { description: string; grams: number } }} food */
function addIngredient(food) {
ingredients = [...ingredients, food];
ingredients = [...ingredients, /** @type {MealIngredient} */ (food)];
showSearch = false;
}
/** @param {number} index */
function removeIngredient(index) {
ingredients = ingredients.filter((_, i) => i !== index);
}
@@ -86,11 +95,12 @@
showSearch = false;
}
/** @param {Meal} meal */
function startEdit(meal) {
editing = true;
editingId = meal._id;
editingId = meal._id ?? null;
mealName = meal.name;
ingredients = meal.ingredients.map(i => ({ ...i }));
ingredients = meal.ingredients.map((/** @type {MealIngredient} */ i) => ({ ...i }));
showSearch = false;
}
@@ -132,12 +142,13 @@
}
}
/** @param {Meal} meal */
async function deleteMeal(meal) {
if (!await confirm(t('delete_meal_confirm', lang))) return;
try {
const res = await fetch(`/api/fitness/custom-meals/${meal._id}`, { method: 'DELETE' });
if (res.ok) {
meals = meals.filter(m => m._id !== meal._id);
meals = meals.filter((/** @type {Meal} */ m) => m._id !== meal._id);
toast.success(isEn ? 'Meal deleted' : 'Mahlzeit gelöscht');
}
} catch {
@@ -145,6 +156,7 @@
}
}
/** @param {number} v */
function fmt(v) {
return v >= 100 ? Math.round(v).toString() : v.toFixed(1);
}
@@ -209,17 +221,18 @@
min="0.1"
step={sp ? '0.5' : '1'}
onchange={(e) => {
const qty = Number(e.target.value) || 1;
const qty = Number(/** @type {HTMLInputElement} */ (e.target).value) || 1;
ingredients[i].amountGrams = sp ? Math.round(qty * sp.grams) : qty;
ingredients = [...ingredients];
}}
/>
{#if ing.portions?.length > 0}
<select class="inline-portion" value={sp ? ing.portions.findIndex(p => p.description === sp.description) : -1} onchange={(e) => {
const idx = Number(e.target.value);
{#if ing.portions && ing.portions.length > 0}
{@const ingPortions = ing.portions}
<select class="inline-portion" value={sp ? ingPortions.findIndex((/** @type {{description: string; grams: number}} */ p) => p.description === sp.description) : -1} onchange={(e) => {
const idx = Number(/** @type {HTMLSelectElement} */ (e.target).value);
const oldGrams = ing.amountGrams;
if (idx >= 0) {
const portion = ing.portions[idx];
const portion = ingPortions[idx];
ingredients[i].selectedPortion = portion;
// Convert current grams to new unit, round to nearest 0.5
const qty = Math.round((oldGrams / portion.grams) * 2) / 2 || 1;
@@ -230,7 +243,7 @@
ingredients = [...ingredients];
}}>
<option value={-1}>g</option>
{#each ing.portions as p, pi}
{#each ingPortions as p, pi}
<option value={pi}>{p.description} ({Math.round(p.grams)}g)</option>
{/each}
</select>
@@ -262,7 +262,7 @@
{:else}
<div class="card-value card-value-na"></div>
{/if}
<div class="card-label">{t('protein_per_kg', lang)}</div>
<div class="card-label">{t('protein', lang)}</div>
<div class="card-hint">
{#if ns.avgProteinPerKg != null}
{t('seven_day_avg', lang)}
@@ -19,6 +19,13 @@
)
);
/**
* @typedef {{ paired: true, dates: string[], left: (number|null)[], right: (number|null)[] }} PairedSeries
* @typedef {{ paired: false, dates: string[], values: number[] }} SingleSeries
* @typedef {PairedSeries | SingleSeries} Series
*/
/** @type {Series} */
const series = $derived.by(() => {
if (card.paired) {
/** @type {string[]} */
@@ -36,7 +43,7 @@
left.push(l ?? null);
right.push(r ?? null);
}
return { dates, left, right };
return { paired: true, dates, left, right };
}
/** @type {string[]} */
const dates = [];
@@ -49,11 +56,11 @@
dates.push(m.date);
values.push(v);
}
return { dates, values };
return { paired: false, dates, values };
});
const chartData = $derived.by(() => {
if (card.paired) {
if (series.paired) {
return {
dates: series.dates,
labels: series.dates,
@@ -77,8 +84,15 @@
};
});
/**
* @typedef {{ paired: true, latest: { left: number|null, right: number|null }, first: { left: number|null, right: number|null }, count: number }} PairedStats
* @typedef {{ paired: false, latest: number|null, first: number|null, count: number, min: number|null, max: number|null }} SingleStats
* @typedef {PairedStats | SingleStats} Stats
*/
/** @type {Stats} */
const stats = $derived.by(() => {
if (card.paired) {
if (series.paired) {
const l = series.left.filter((/** @type {number|null} */ v) => v != null);
const r = series.right.filter((/** @type {number|null} */ v) => v != null);
const latest = {
@@ -89,10 +103,11 @@
left: l.length ? /** @type {number} */ (l[0]) : null,
right: r.length ? /** @type {number} */ (r[0]) : null
};
return { latest, first, count: series.dates.length };
return { paired: true, latest, first, count: series.dates.length };
}
const v = series.values;
return {
paired: false,
latest: v.length ? v[v.length - 1] : null,
first: v.length ? v[0] : null,
count: v.length,
@@ -142,7 +157,7 @@
</div>
{:else}
<section class="summary">
{#if card.paired}
{#if stats.paired}
<div class="stat-grid">
<div class="stat">
<span class="stat-label">L · {t('latest', lang)}</span>
@@ -44,8 +44,9 @@
// Voice guidance config (defaults, overridden from localStorage in onMount)
let vgEnabled = $state(false);
let vgTriggerType = $state('distance');
let vgTriggerType = $state(/** @type {'distance' | 'time'} */ ('distance'));
let vgTriggerValue = $state(1);
/** @type {string[]} */
let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']);
let vgVolume = $state(0.8);
let vgAudioDuck = $state(false);
@@ -281,9 +282,10 @@
};
}
/** @param {string} id */
function toggleMetric(id) {
if (vgMetrics.includes(id)) {
vgMetrics = vgMetrics.filter(m => m !== id);
vgMetrics = vgMetrics.filter((/** @type {string} */ m) => m !== id);
} else {
vgMetrics = [...vgMetrics, id];
}
@@ -579,17 +581,22 @@
const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running';
const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId;
sessionData.exercises = [{
exerciseId,
name: exerciseName,
sets: [{
distance: filteredDistance,
duration: Math.round(durationMin * 100) / 100,
completed: true,
}],
gpsTrack,
totalDistance: filteredDistance,
}];
sessionData.exercises = /** @type {typeof sessionData.exercises} */ (
/** @type {unknown} */ ([{
exerciseId,
name: exerciseName,
sets: [{
reps: undefined,
weight: undefined,
rpe: undefined,
distance: filteredDistance,
duration: Math.round(durationMin * 100) / 100,
completed: true,
}],
gpsTrack,
totalDistance: filteredDistance,
}])
);
} else if (wasGpsMode && gpsTrack.length === 0) {
// GPS workout with no track data — nothing to save
gps.reset();
@@ -606,8 +613,8 @@
for (const ex of sessionData.exercises) {
const exercise = getExerciseById(ex.exerciseId);
if (exercise?.bodyPart === 'cardio') {
ex.gpsTrack = filteredTrack;
ex.totalDistance = filteredDistance;
/** @type {any} */ (ex).gpsTrack = filteredTrack;
/** @type {any} */ (ex).totalDistance = filteredDistance;
}
}
}
@@ -665,6 +672,10 @@
return { exerciseId: pr.exerciseId, type, value };
}
/**
* @param {any} local
* @param {any} saved
*/
function buildCompletion(local, saved) {
const startTime = new Date(local.startTime);
const endTime = new Date(local.endTime);
@@ -1250,7 +1261,16 @@
bind:value={intervalEditorName}
/>
{#snippet stepCard(step, num, onMoveUp, onMoveDown, onRemove, canMoveUp, canMoveDown, canRemove)}
{#snippet stepCard(
/** @type {EditorLeaf} */ step,
/** @type {string} */ num,
/** @type {() => void} */ onMoveUp,
/** @type {() => void} */ onMoveDown,
/** @type {() => void} */ onRemove,
/** @type {boolean} */ canMoveUp,
/** @type {boolean} */ canMoveDown,
/** @type {boolean} */ canRemove
)}
<div class="interval-step-card">
<div class="interval-step-header">
<span class="interval-step-num">{num}</span>
@@ -1404,7 +1424,7 @@
bind:value={nameInput}
onfocus={() => { nameEditing = true; }}
onblur={() => { nameEditing = false; workout.name = nameInput; }}
onkeydown={(e) => { if (e.key === 'Enter') e.target.blur(); }}
onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }}
placeholder={t('workout_name_placeholder', lang)}
/>