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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.46.2",
|
||||
"version": "1.46.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
const dialog = getConfirmDialog();
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
function onKeydown(e) {
|
||||
if (!dialog.open) return;
|
||||
if (e.key === 'Escape') dialog.respond(false);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
+4
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>} */
|
||||
|
||||
+126
-31
@@ -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)}
|
||||
|
||||
+21
-6
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user