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