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

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

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

Files touched: ~50 across fitness, cospend, faith, recipe, and shared
lib components / API routes.
This commit is contained in:
2026-04-23 13:11:14 +02:00
parent 36058d1b94
commit e9ebe492fb
48 changed files with 565 additions and 190 deletions
+1
View File
@@ -3,6 +3,7 @@
const dialog = getConfirmDialog();
/** @param {KeyboardEvent} e */
function onKeydown(e) {
if (!dialog.open) return;
if (e.key === 'Escape') dialog.respond(false);
+8 -2
View File
@@ -4,6 +4,7 @@
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
let open = $state(false);
/** @type {HTMLDivElement | null} */
let pickerRef = $state(null);
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
@@ -39,12 +40,14 @@
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : 'en-US', { weekday: 'short', month: 'short', day: 'numeric' });
});
/** @param {string} dateStr */
function isDisabled(dateStr) {
if (min && dateStr < min) return true;
if (max && dateStr > max) return true;
return false;
}
/** @param {number} delta */
function navigateDate(delta) {
const d = new Date((value || todayStr) + 'T12:00:00');
d.setDate(d.getDate() + delta);
@@ -52,12 +55,14 @@
if (!isDisabled(next)) value = next;
}
/** @param {number} delta */
function navMonth(delta) {
viewMonth += delta;
if (viewMonth > 11) { viewMonth = 0; viewYear++; }
if (viewMonth < 0) { viewMonth = 11; viewYear--; }
}
/** @param {string} dateStr */
function selectDay(dateStr) {
value = dateStr;
open = false;
@@ -77,7 +82,7 @@
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const daysInPrevMonth = new Date(viewYear, viewMonth, 0).getDate();
/** @type {{ date: string, day: number, currentMonth: boolean, isToday: boolean, isSelected: boolean }[]} */
/** @type {{ date: string, day: number, currentMonth: boolean, isToday: boolean, isSelected: boolean, disabled: boolean }[]} */
const days = [];
// Previous month trailing days
@@ -110,8 +115,9 @@
});
// Close on outside click
/** @param {MouseEvent} e */
function handleClickOutside(e) {
if (pickerRef && !pickerRef.contains(e.target)) {
if (pickerRef && e.target instanceof Node && !pickerRef.contains(e.target)) {
open = false;
}
}
+1 -1
View File
@@ -164,7 +164,7 @@
}
// Handle fitness pages
if (path.startsWith('/fitness')) {
if (path.startsWith('/fitness') && lang !== 'la') {
const newPath = convertFitnessPath(path, lang);
await goto(newPath);
return;
+2 -1
View File
@@ -2,7 +2,8 @@
import Check from '$lib/assets/icons/Check.svelte';
import ActionButton from './ActionButton.svelte';
let { disabled = false, onclick, label = 'Save', type = 'submit' } = $props();
/** @type {{ disabled?: boolean, onclick?: ((e: MouseEvent) => void) | undefined, label?: string, type?: 'submit' | 'reset' | 'button' }} */
let { disabled = false, onclick = undefined, label = 'Save', type = 'submit' } = $props();
</script>
<ActionButton {type} {onclick} {disabled} ariaLabel={label}>
+3 -2
View File
@@ -8,10 +8,11 @@
* data?: { labels: string[], datasets: Array<{ label: string, data: number[] }> },
* title?: string,
* height?: string,
* onFilterChange?: ((categories: string[] | null) => void) | null
* onFilterChange?: ((categories: string[] | null) => void) | null,
* lang?: 'en' | 'de'
* }}
*/
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = /** @type {'en' | 'de'} */ ('de') } = $props();
let { data = { labels: [], datasets: [] }, title = '', height = '400px', onFilterChange = null, lang = 'de' } = $props();
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
+49 -14
View File
@@ -6,16 +6,33 @@
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
/**
* @typedef {{ description: string, grams: number }} Portion
*/
/**
* @typedef {{
* id: string,
* name: string,
* source: string,
* per100g: any,
* portions?: Portion[],
* brands?: string,
* category?: string,
* calories?: number,
* favorited?: boolean,
* }} FoodItem
*/
/**
* @type {{
* onselect: (food: { name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: any[], selectedPortion?: { description: string, grams: number } }) => void,
* onselect: (food: { name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: Portion[], selectedPortion?: Portion }) => void,
* oncancel?: () => void,
* onfavoritechange?: (payload: { source: string, sourceId: string, name: string, favorited: boolean }) => void,
* showFavorites?: boolean,
* showDetailLinks?: boolean,
* autofocus?: boolean,
* confirmLabel?: string,
* initialResults?: any[],
* initialResults?: FoodItem[],
* }}
*/
let {
@@ -36,8 +53,10 @@
// --- Search state ---
let query = $state('');
/** @type {FoodItem[]} */
let results = $state(untrack(() => initialResults ?? []));
let loading = $state(false);
/** @type {ReturnType<typeof setTimeout> | null} */
let timeout = $state(null);
const isPrefilledMode = $derived(initialResults != null);
let filterQuery = $state('');
@@ -48,6 +67,7 @@
);
// --- Selection state ---
/** @type {FoodItem | null} */
let selected = $state(null);
let amountInput = $state('100');
let portionIdx = $state(-1); // -1 = grams
@@ -55,7 +75,9 @@
// --- Barcode scanner state ---
let scanning = $state(false);
let scanError = $state('');
/** @type {HTMLVideoElement | null} */
let videoEl = $state(null);
/** @type {MediaStream | null} */
let scanStream = $state(null);
let scanDebug = $state('');
@@ -78,9 +100,10 @@
}, 300);
}
/** @param {FoodItem} item */
function selectItem(item) {
selected = item;
if (item.portions?.length > 0) {
if ((item.portions?.length ?? 0) > 0) {
portionIdx = 0;
amountInput = '1';
} else {
@@ -126,6 +149,7 @@
const grams = resolveGrams();
if (!grams || grams <= 0) return;
/** @type {{ name: string, source: string, sourceId: string, amountGrams: number, per100g: any, portions?: Portion[], selectedPortion?: Portion }} */
const food = {
name: selected.name,
source: selected.source,
@@ -133,7 +157,7 @@
amountGrams: grams,
per100g: selected.per100g,
};
if (selected.portions?.length > 0) {
if (selected.portions && selected.portions.length > 0) {
food.portions = selected.portions;
}
if (portionIdx >= 0 && selected.portions?.[portionIdx]) {
@@ -151,6 +175,7 @@
portionIdx = -1;
}
/** @param {FoodItem} item */
async function toggleFavorite(item) {
const wasFav = item.favorited;
item.favorited = !wasFav;
@@ -178,6 +203,7 @@
/** @param {string | undefined} source */
function sourceLabel(source) {
if (source === 'bls') return 'BLS';
if (source === 'usda') return 'USDA';
@@ -186,11 +212,14 @@
return source?.toUpperCase() ?? '';
}
// EAN/UPC check digit validation (works for EAN-8, UPC-A, EAN-13)
/**
* EAN/UPC check digit validation (works for EAN-8, UPC-A, EAN-13)
* @param {string} code
*/
function validCheckDigit(code) {
const digits = code.split('').map(Number);
const check = digits.pop();
const sum = digits.reduce((s, d, i) => s + d * ((i % 2 === (digits.length % 2 === 0 ? 0 : 1)) ? 1 : 3), 0);
const sum = digits.reduce((/** @type {number} */ s, /** @type {number} */ d, /** @type {number} */ i) => s + d * ((i % 2 === (digits.length % 2 === 0 ? 0 : 1)) ? 1 : 3), 0);
return (10 - (sum % 10)) % 10 === check;
}
@@ -240,13 +269,16 @@
await videoEl.play();
// Use native BarcodeDetector if available, else ponyfill with self-hosted WASM
/** @type {any} */
let detector;
/** @type {any} */
const formats = ['ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128'];
try {
if ('BarcodeDetector' in globalThis) {
const supported = await globalThis.BarcodeDetector.getSupportedFormats();
const BD = /** @type {any} */ (globalThis).BarcodeDetector;
const supported = await BD.getSupportedFormats();
if (supported.includes('ean_13')) {
detector = new globalThis.BarcodeDetector({ formats });
detector = new BD({ formats });
}
}
} catch {
@@ -257,7 +289,7 @@
const mod = await import('barcode-detector/ponyfill');
await mod.prepareZXingModule({
overrides: {
locateFile: (path, prefix) => {
locateFile: (/** @type {string} */ path, /** @type {string} */ prefix) => {
if (path.endsWith('.wasm')) return '/fitness/zxing_reader.wasm';
return prefix + path;
},
@@ -307,7 +339,8 @@
}
} catch (detectErr) {
errorCount++;
scanDebug = `ERROR: ${detectErr?.name}: ${detectErr?.message}`;
const e = /** @type {{ name?: string, message?: string }} */ (detectErr);
scanDebug = `ERROR: ${e?.name}: ${e?.message}`;
if (errorCount >= 5) {
scanError = isEn ? 'Barcode detection failed repeatedly. Try reloading.' : 'Barcode-Erkennung wiederholt fehlgeschlagen. Seite neu laden.';
stopScan();
@@ -320,7 +353,8 @@
detectLoop();
} catch (err) {
scanning = false;
const name = err?.name;
const e = /** @type {{ name?: string, message?: string }} */ (err);
const name = e?.name;
if (name === 'NotAllowedError') {
scanError = isEn
? 'Camera permission denied — enable it in your browser site settings'
@@ -330,7 +364,7 @@
} else if (name === 'NotReadableError') {
scanError = isEn ? 'Camera is in use by another app' : 'Kamera wird von einer anderen App verwendet';
} else {
scanError = isEn ? `Camera error: ${err?.message || name}` : `Kamerafehler: ${err?.message || name}`;
scanError = isEn ? `Camera error: ${e?.message || name}` : `Kamerafehler: ${e?.message || name}`;
}
}
}
@@ -344,6 +378,7 @@
if (videoEl) videoEl.srcObject = null;
}
/** @param {string} code */
async function lookupBarcode(code) {
loading = true;
scanError = '';
@@ -472,10 +507,10 @@
min="0.1"
step={portionIdx >= 0 ? '0.5' : '1'}
/>
{#if selected.portions?.length > 0}
{#if (selected.portions?.length ?? 0) > 0}
<select class="fs-unit-select" bind:value={portionIdx} onchange={() => {
const grams = resolveGrams();
if (portionIdx >= 0 && selected.portions[portionIdx]) {
if (portionIdx >= 0 && selected?.portions?.[portionIdx]) {
amountInput = String(Math.round((grams / selected.portions[portionIdx].grams) * 10) / 10 || 1);
} else {
amountInput = String(grams || 100);
@@ -45,6 +45,7 @@
};
});
/** @param {number | null | undefined} v */
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (v >= 100) return Math.round(v).toString();
@@ -2,12 +2,14 @@
import { Coffee, Sun, Moon, Cookie } from '@lucide/svelte';
import { t } from '$lib/js/fitnessI18n';
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
let {
value = 'snack',
lang = 'de',
onchange = () => {},
} = $props();
/** @type {Array<'breakfast' | 'lunch' | 'dinner' | 'snack'>} */
const mealTypes = ['breakfast', 'lunch', 'dinner', 'snack'];
const mealMeta = {
+41 -10
View File
@@ -3,10 +3,16 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -19,6 +25,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -32,19 +39,29 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Check if a region's groups overlap with selectedGroups */
/**
* Check if a region's groups overlap with selectedGroups
* @param {string[]} groups
*/
function isRegionSelected(groups) {
if (selectedGroups.length === 0) return false;
return groups.some(g => selectedGroups.includes(g));
}
/** Compute fill for a region based on selection state */
/**
* Compute fill for a region based on selection state
* @param {string[]} groups
*/
function regionFill(groups) {
if (isRegionSelected(groups)) return 'var(--color-primary)';
return 'var(--color-bg-tertiary)';
}
/** Inject fill styles into SVG string */
/**
* Inject fill styles into SVG string
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
@@ -59,6 +76,7 @@
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently hovered region for tooltip */
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
@@ -67,10 +85,15 @@
return isEn ? hovered.label.en : hovered.label.de;
});
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/** Toggle a region's muscle groups in/out of selection */
/**
* Toggle a region's muscle groups in/out of selection
* @param {MuscleRegion} region
*/
function toggleRegion(region) {
const groups = region.groups;
const allSelected = groups.every(g => selectedGroups.includes(g));
@@ -82,11 +105,17 @@
}
}
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
@@ -94,8 +123,9 @@
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
@@ -103,8 +133,9 @@
hovered = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('click', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
toggleRegion(map[g.id]);
}
@@ -5,12 +5,20 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
* @typedef {{ primary?: number, secondary?: number, weeklyAvg?: number }} MuscleTotals
*/
/** @type {{ data?: { totals?: Record<string, MuscleTotals> } | null }} */
let { data } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleTotals>} */
const totals = $derived(data?.totals ?? {});
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -23,6 +31,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -36,7 +45,10 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** Sum weeklyAvg across all muscle groups for a region */
/**
* Sum weeklyAvg across all muscle groups for a region
* @param {string[]} groups
*/
function regionScore(groups) {
let score = 0;
for (const g of groups) {
@@ -55,7 +67,10 @@
return max;
});
/** Compute fill as a color-mix CSS value — resolved natively by the browser */
/**
* Compute fill as a color-mix CSS value — resolved natively by the browser
* @param {number} score
*/
function scoreFill(score) {
if (score === 0) return 'var(--color-bg-tertiary)';
const pct = Math.round(Math.min(score / maxScore, 1) * 100);
@@ -65,6 +80,8 @@
/**
* Preprocess an SVG string: inject fill styles into each muscle group.
* Replaces `<g id="groupId">` with `<g id="groupId" style="...">`.
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
@@ -82,6 +99,7 @@
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** Currently selected region info */
/** @type {(MuscleRegion & { svgId: string }) | null} */
let selected = $state(null);
const selectedInfo = $derived.by(() => {
@@ -99,22 +117,30 @@
const hasData = $derived(Object.keys(totals).length > 0);
/** DOM refs for event delegation */
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
*/
function setupEvents(container, map) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
@@ -122,8 +148,9 @@
selected = null;
});
container.addEventListener('click', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('click', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
selected = { ...map[g.id], svgId: g.id };
}
+31 -5
View File
@@ -3,10 +3,16 @@
import frontSvgRaw from '$lib/assets/muscle-front.svg?raw';
import backSvgRaw from '$lib/assets/muscle-back.svg?raw';
/**
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ primaryGroups?: string[], secondaryGroups?: string[], lang?: string }} */
let { primaryGroups = [], secondaryGroups = [], lang = 'en' } = $props();
const isEn = $derived(lang === 'en');
/** @type {Record<string, MuscleRegion>} */
const FRONT_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'front-shoulders': { groups: ['anterior deltoids', 'lateral deltoids'], label: { en: 'Front Delts', de: 'Vord. Schultern' } },
@@ -19,6 +25,7 @@
'calves': { groups: ['calves'], label: { en: 'Calves', de: 'Waden' } },
};
/** @type {Record<string, MuscleRegion>} */
const BACK_MAP = {
'traps': { groups: ['traps'], label: { en: 'Traps', de: 'Trapez' } },
'traps-middle': { groups: ['traps'], label: { en: 'Mid Traps', de: 'Mittl. Trapez' } },
@@ -35,12 +42,14 @@
const primarySet = $derived(new Set(primaryGroups));
const secondarySet = $derived(new Set(secondaryGroups));
/** @param {string[]} groups */
function regionState(groups) {
if (groups.some(g => primarySet.has(g))) return 'primary';
if (groups.some(g => secondarySet.has(g))) return 'secondary';
return 'inactive';
}
/** @param {string[]} groups */
function regionFill(groups) {
const state = regionState(groups);
if (state === 'primary') return 'var(--color-primary)';
@@ -48,6 +57,10 @@
return 'var(--color-bg-tertiary)';
}
/**
* @param {string} svgStr
* @param {Record<string, MuscleRegion>} map
*/
function injectFills(svgStr, map) {
let result = svgStr;
for (const [svgId, region] of Object.entries(map)) {
@@ -61,6 +74,7 @@
const frontSvg = $derived(injectFills(frontSvgRaw, FRONT_MAP));
const backSvg = $derived(injectFills(backSvgRaw, BACK_MAP));
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => {
@@ -71,21 +85,30 @@
return label + suffix;
});
/** @type {HTMLDivElement | null} */
let frontEl = $state(null);
/** @type {HTMLDivElement | null} */
let backEl = $state(null);
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
if (!container) return;
container.addEventListener('mouseover', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted');
}
});
container.addEventListener('mouseout', (e) => {
const g = e.target.closest('g[id]');
container.addEventListener('mouseout', (/** @type {Event} */ e) => {
const target = /** @type {Element | null} */ (e.target);
const g = target?.closest('g[id]');
if (g) g.classList.remove('highlighted');
});
container.addEventListener('mouseleave', () => { hovered = null; });
@@ -96,7 +119,10 @@
setupEvents(backEl, BACK_MAP, 'back');
});
// Check if any muscles are on front/back to decide which to show
/**
* Check if any muscles are on front/back to decide which to show
* @param {Record<string, MuscleRegion>} map
*/
function hasActiveRegions(map) {
return Object.values(map).some(r => regionState(r.groups) !== 'inactive');
}
@@ -275,7 +275,7 @@
const startDay = (first.getDay() + 6) % 7; // Monday = 0
// Build raw cells with status, including overflow days from adjacent months
/** @type {({ day: number, date: string, status: string, pos: string, edges: string, overflow?: boolean } | null)[]} */
/** @type {{ day: number, date: string, status: string, pos: string, edges: string, overflow?: boolean }[]} */
const cells = [];
// Previous month overflow
@@ -4,7 +4,21 @@
import { toast } from '$lib/js/toast.svelte';
import { t } from '$lib/js/fitnessI18n';
import MealTypePicker from '$lib/components/fitness/MealTypePicker.svelte';
/** @typedef {import('$lib/server/roundOffScoring').ComboSuggestion} ComboSuggestion */
/**
* @type {{
* remainingKcal: number,
* remainingProtein: number,
* remainingFat: number,
* remainingCarbs: number,
* currentDate: string,
* lang?: 'en' | 'de',
* nutritionSlug?: string,
* initialSuggestions?: ComboSuggestion[] | null,
* onlogged?: () => void,
* }}
*/
let {
remainingKcal,
remainingProtein,
@@ -20,6 +34,7 @@
const isEn = $derived(lang === 'en');
// svelte-ignore state_referenced_locally
/** @type {ComboSuggestion[] | null} */
let suggestions = $state(initialSuggestions);
// svelte-ignore state_referenced_locally
let loading = $state(!initialSuggestions);
@@ -35,6 +50,7 @@
}
let editingComboIdx = $state(-1);
/** @type {'breakfast' | 'lunch' | 'dinner' | 'snack'} */
let editMealType = $state('snack');
async function fetchSuggestions() {
@@ -64,6 +80,7 @@
}
});
/** @param {number} comboIdx */
function startLog(comboIdx) {
editingComboIdx = comboIdx;
editMealType = defaultMealType();
@@ -73,6 +90,7 @@
editingComboIdx = -1;
}
/** @param {ComboSuggestion} combo */
async function logCombo(combo) {
loggingIdx = editingComboIdx;
try {
@@ -113,12 +131,14 @@
loggingIdx = -1;
}
/** @param {number | undefined | null} v */
function fmt(v) {
if (v == null || isNaN(v)) return '0';
if (Math.abs(v) >= 100) return Math.round(v).toString();
return v.toFixed(1);
}
/** @param {number} v */
function fmtSigned(v) {
const s = fmt(v);
return v > 0 ? '+' + s : s;
@@ -3,10 +3,12 @@
let { src, poster = '', onClose } = $props();
/** @param {KeyboardEvent} e */
function handleKeydown(e) {
if (e.key === 'Escape') onClose();
}
/** @param {MouseEvent} e */
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose();
}
@@ -54,6 +54,7 @@
});
// Parse amount string to number (simplified from nutrition.svelte.ts)
/** @param {string | undefined | null} amount */
function parseAmount(amount) {
if (!amount?.trim()) return 0;
let s = amount.trim().replace(',', '.');
@@ -69,6 +70,7 @@
// Compute total recipe nutrition (all ingredients at multiplier=1)
const recipeTotals = $derived.by(() => {
/** @type {Record<string, number>} */
const result = {};
const nutrientKeys = [
'calories', 'protein', 'fat', 'saturatedFat', 'carbs', 'fiber', 'sugars',
@@ -120,6 +122,7 @@
const per100g = $derived.by(() => {
const w = recipeTotals.totalWeightGrams;
if (w <= 0) return recipeTotals.totals;
/** @type {Record<string, number>} */
const result = {};
for (const [k, v] of Object.entries(recipeTotals.totals)) {
result[k] = v / w * 100;
@@ -994,7 +994,7 @@ button:disabled {
<button class="btn-secondary" onclick={handleSkip}>
Skip Translation
</button>
<button class="btn-primary" onclick={handleAutoTranslate} disabled={untranslatedBaseRecipes.length > 0}>
<button class="btn-primary" onclick={() => handleAutoTranslate()} disabled={untranslatedBaseRecipes.length > 0}>
{#if untranslatedBaseRecipes.length > 0}
Translate base recipes first
{:else}
-11
View File
@@ -214,7 +214,6 @@ const translations: Translations = {
// UsersList
split_between_users: { en: 'Split Between Users', de: 'Aufteilen zwischen' },
predefined_note: { en: 'Splitting between predefined users:', de: 'Aufteilung zwischen vordefinierten Benutzern:' },
you: { en: 'You', de: 'Du' },
remove: { en: 'Remove', de: 'Entfernen' },
add_user_placeholder: { en: 'Add user...', de: 'Benutzer hinzufügen...' },
add_user: { en: 'Add User', de: 'Benutzer hinzufügen' },
@@ -223,17 +222,8 @@ const translations: Translations = {
split_method: { en: 'Split Method', de: 'Aufteilungsmethode' },
how_split: { en: 'How should this payment be split?', de: 'Wie soll diese Zahlung aufgeteilt werden?' },
split_5050: { en: 'Split 50/50', de: '50/50 teilen' },
equal_split: { en: 'Equal Split', de: 'Gleichmässig' },
personal_equal_split: { en: 'Personal + Equal Split', de: 'Persönlich + Gleichmässig' },
custom_proportions: { en: 'Custom Proportions', de: 'Individuelle Anteile' },
custom_split_amounts: { en: 'Custom Split Amounts', de: 'Individuelle Beträge' },
personal_amounts: { en: 'Personal Amounts', de: 'Persönliche Beträge' },
personal_amounts_desc: { en: 'Enter personal amounts for each user. The remainder will be split equally.', de: 'Persönliche Beträge pro Benutzer eingeben. Der Rest wird gleichmässig aufgeteilt.' },
total_personal: { en: 'Total Personal', de: 'Persönlich gesamt' },
remainder_to_split: { en: 'Remainder to Split', de: 'Restbetrag zum Aufteilen' },
personal_exceeds_total: { en: 'Warning: Personal amounts exceed total payment amount!', de: 'Warnung: Persönliche Beträge übersteigen den Gesamtbetrag!' },
split_preview: { en: 'Split Preview', de: 'Aufteilungsvorschau' },
owes: { en: 'owes', de: 'schuldet' },
is_owed: { en: 'is owed', de: 'bekommt' },
error_prefix: { en: 'Error', de: 'Fehler' },
@@ -270,7 +260,6 @@ const translations: Translations = {
freq_monthly: { en: 'Monthly', de: 'Monatlich' },
freq_quarterly: { en: 'Quarterly', de: 'Vierteljährlich' },
freq_yearly: { en: 'Yearly', de: 'Jährlich' },
freq_custom: { en: 'Custom (Cron)', de: 'Benutzerdefiniert (Cron)' },
start_date: { en: 'Start Date *', de: 'Startdatum *' },
end_date_optional: { en: 'End Date (optional)', de: 'Enddatum (optional)' },
end_date_hint: { en: 'Leave empty for indefinite recurring', de: 'Leer lassen für unbefristete Wiederholung' },
-2
View File
@@ -130,7 +130,6 @@ const translations: Translations = {
template_library: { en: 'Template Library', de: 'Vorlagen-Bibliothek' },
browse_library: { en: 'Browse Library', de: 'Bibliothek durchsuchen' },
template_added: { en: 'Template added', de: 'Vorlage hinzugefügt' },
loading: { en: 'Loading', de: 'Laden' },
edit_template: { en: 'Edit Template', de: 'Vorlage bearbeiten' },
new_template: { en: 'New Template', de: 'Neue Vorlage' },
template_name_placeholder: { en: 'Template name', de: 'Vorlagenname' },
@@ -448,7 +447,6 @@ const translations: Translations = {
// Nutrition stats
nutrition_stats: { en: 'Nutrition', de: 'Ernährung' },
protein_per_kg: { en: 'Protein', de: 'Protein' },
protein_per_kg_unit: { en: 'g/kg', de: 'g/kg' },
calorie_balance: { en: 'Calorie Balance', de: 'Kalorienbilanz' },
calorie_balance_unit: { en: 'kcal/day', de: 'kcal/Tag' },
+1
View File
@@ -1,3 +1,4 @@
// @ts-expect-error — web-haptics has no bundled .d.ts; shim types as any at boundary
import { createWebHaptics } from 'web-haptics/svelte';
export type HapticPulse = { duration: number; intensity?: number };
+1 -1
View File
@@ -193,7 +193,7 @@ function computeNutritionInfo(
if ((!mappings || mappings.length === 0) && (!referencedNutrition || referencedNutrition.length === 0)) return null;
const index = new Map(
mappings.map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
(mappings ?? []).map(m => [`${m.sectionIndex}-${m.ingredientIndex}`, m])
);
const totals = { calories: 0, protein: 0, fat: 0, saturatedFat: 0, carbs: 0, fiber: 0, sugars: 0, sodium: 0, cholesterol: 0 };
+1 -1
View File
@@ -156,7 +156,7 @@ const ICON_ALIASES: Record<string, string> = {
// Swiss German → High German aliases
'rahm': 'sahne', 'schlagrahm': 'schlagsahne', 'halbrahm': 'sahne', 'vollrahm': 'sahne',
'rüebli': 'karotten', 'rüebli': 'karotten',
'rüebli': 'karotten',
'nüsslisalat': 'feldsalat', 'federkohl': 'grünkohl',
'peperoni': 'paprika', 'peperoncini': 'chili',
'poulet': 'hähnchen', 'pouletbrust': 'hähnchenbrust', 'pouletschenkel': 'hähnchenschenkel',