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