diff --git a/package.json b/package.json index d58baf48..0a87e26b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.46.2", + "version": "1.46.3", "private": true, "type": "module", "scripts": { diff --git a/src/lib/components/ConfirmDialog.svelte b/src/lib/components/ConfirmDialog.svelte index 106d0fc6..adcf3f09 100644 --- a/src/lib/components/ConfirmDialog.svelte +++ b/src/lib/components/ConfirmDialog.svelte @@ -3,6 +3,7 @@ const dialog = getConfirmDialog(); + /** @param {KeyboardEvent} e */ function onKeydown(e) { if (!dialog.open) return; if (e.key === 'Escape') dialog.respond(false); diff --git a/src/lib/components/DatePicker.svelte b/src/lib/components/DatePicker.svelte index 5d62c4fe..ffd8e9f6 100644 --- a/src/lib/components/DatePicker.svelte +++ b/src/lib/components/DatePicker.svelte @@ -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; } } diff --git a/src/lib/components/LanguageSelector.svelte b/src/lib/components/LanguageSelector.svelte index 5a76d2a0..99ac6057 100644 --- a/src/lib/components/LanguageSelector.svelte +++ b/src/lib/components/LanguageSelector.svelte @@ -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; diff --git a/src/lib/components/SaveFab.svelte b/src/lib/components/SaveFab.svelte index dbb0d87d..f0ac59d5 100644 --- a/src/lib/components/SaveFab.svelte +++ b/src/lib/components/SaveFab.svelte @@ -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(); diff --git a/src/lib/components/cospend/BarChart.svelte b/src/lib/components/cospend/BarChart.svelte index d1affd27..b63c413d 100644 --- a/src/lib/components/cospend/BarChart.svelte +++ b/src/lib/components/cospend/BarChart.svelte @@ -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); diff --git a/src/lib/components/fitness/FoodSearch.svelte b/src/lib/components/fitness/FoodSearch.svelte index 9ec72b2f..6632e178 100644 --- a/src/lib/components/fitness/FoodSearch.svelte +++ b/src/lib/components/fitness/FoodSearch.svelte @@ -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 | 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} p.description === sp.description) : -1} onchange={(e) => { - const idx = Number(e.target.value); + {#if ing.portions && ing.portions.length > 0} + {@const ingPortions = ing.portions} + diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index 3e08d4d6..4af07914 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -262,7 +262,7 @@ {:else}
{/if} -
{t('protein_per_kg', lang)}
+
{t('protein', lang)}
{#if ns.avgProteinPerKg != null} {t('seven_day_avg', lang)} diff --git a/src/routes/fitness/[stats=fitnessStats]/[history=fitnessHistory]/[part]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/[history=fitnessHistory]/[part]/+page.svelte index 9369476d..43c0703e 100644 --- a/src/routes/fitness/[stats=fitnessStats]/[history=fitnessHistory]/[part]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/[history=fitnessHistory]/[part]/+page.svelte @@ -19,6 +19,13 @@ ) ); + /** + * @typedef {{ paired: true, dates: string[], left: (number|null)[], right: (number|null)[] }} PairedSeries + * @typedef {{ paired: false, dates: string[], values: number[] }} SingleSeries + * @typedef {PairedSeries | SingleSeries} Series + */ + + /** @type {Series} */ const series = $derived.by(() => { if (card.paired) { /** @type {string[]} */ @@ -36,7 +43,7 @@ left.push(l ?? null); right.push(r ?? null); } - return { dates, left, right }; + return { paired: true, dates, left, right }; } /** @type {string[]} */ const dates = []; @@ -49,11 +56,11 @@ dates.push(m.date); values.push(v); } - return { dates, values }; + return { paired: false, dates, values }; }); const chartData = $derived.by(() => { - if (card.paired) { + if (series.paired) { return { dates: series.dates, labels: series.dates, @@ -77,8 +84,15 @@ }; }); + /** + * @typedef {{ paired: true, latest: { left: number|null, right: number|null }, first: { left: number|null, right: number|null }, count: number }} PairedStats + * @typedef {{ paired: false, latest: number|null, first: number|null, count: number, min: number|null, max: number|null }} SingleStats + * @typedef {PairedStats | SingleStats} Stats + */ + + /** @type {Stats} */ const stats = $derived.by(() => { - if (card.paired) { + if (series.paired) { const l = series.left.filter((/** @type {number|null} */ v) => v != null); const r = series.right.filter((/** @type {number|null} */ v) => v != null); const latest = { @@ -89,10 +103,11 @@ left: l.length ? /** @type {number} */ (l[0]) : null, right: r.length ? /** @type {number} */ (r[0]) : null }; - return { latest, first, count: series.dates.length }; + return { paired: true, latest, first, count: series.dates.length }; } const v = series.values; return { + paired: false, latest: v.length ? v[v.length - 1] : null, first: v.length ? v[0] : null, count: v.length, @@ -142,7 +157,7 @@
{:else}
- {#if card.paired} + {#if stats.paired}
L · {t('latest', lang)} diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index 5ccb0fec..4a27f835 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -44,8 +44,9 @@ // Voice guidance config (defaults, overridden from localStorage in onMount) let vgEnabled = $state(false); - let vgTriggerType = $state('distance'); + let vgTriggerType = $state(/** @type {'distance' | 'time'} */ ('distance')); let vgTriggerValue = $state(1); + /** @type {string[]} */ let vgMetrics = $state(['totalTime', 'totalDistance', 'avgPace']); let vgVolume = $state(0.8); let vgAudioDuck = $state(false); @@ -281,9 +282,10 @@ }; } + /** @param {string} id */ function toggleMetric(id) { if (vgMetrics.includes(id)) { - vgMetrics = vgMetrics.filter(m => m !== id); + vgMetrics = vgMetrics.filter((/** @type {string} */ m) => m !== id); } else { vgMetrics = [...vgMetrics, id]; } @@ -579,17 +581,22 @@ const exerciseId = ACTIVITY_EXERCISE_MAP[actType ?? 'running'] ?? 'running'; const exerciseName = getExerciseById(exerciseId)?.name ?? exerciseId; - sessionData.exercises = [{ - exerciseId, - name: exerciseName, - sets: [{ - distance: filteredDistance, - duration: Math.round(durationMin * 100) / 100, - completed: true, - }], - gpsTrack, - totalDistance: filteredDistance, - }]; + sessionData.exercises = /** @type {typeof sessionData.exercises} */ ( + /** @type {unknown} */ ([{ + exerciseId, + name: exerciseName, + sets: [{ + reps: undefined, + weight: undefined, + rpe: undefined, + distance: filteredDistance, + duration: Math.round(durationMin * 100) / 100, + completed: true, + }], + gpsTrack, + totalDistance: filteredDistance, + }]) + ); } else if (wasGpsMode && gpsTrack.length === 0) { // GPS workout with no track data — nothing to save gps.reset(); @@ -606,8 +613,8 @@ for (const ex of sessionData.exercises) { const exercise = getExerciseById(ex.exerciseId); if (exercise?.bodyPart === 'cardio') { - ex.gpsTrack = filteredTrack; - ex.totalDistance = filteredDistance; + /** @type {any} */ (ex).gpsTrack = filteredTrack; + /** @type {any} */ (ex).totalDistance = filteredDistance; } } } @@ -665,6 +672,10 @@ return { exerciseId: pr.exerciseId, type, value }; } + /** + * @param {any} local + * @param {any} saved + */ function buildCompletion(local, saved) { const startTime = new Date(local.startTime); const endTime = new Date(local.endTime); @@ -1250,7 +1261,16 @@ bind:value={intervalEditorName} /> - {#snippet stepCard(step, num, onMoveUp, onMoveDown, onRemove, canMoveUp, canMoveDown, canRemove)} + {#snippet stepCard( + /** @type {EditorLeaf} */ step, + /** @type {string} */ num, + /** @type {() => void} */ onMoveUp, + /** @type {() => void} */ onMoveDown, + /** @type {() => void} */ onRemove, + /** @type {boolean} */ canMoveUp, + /** @type {boolean} */ canMoveDown, + /** @type {boolean} */ canRemove + )}
{num} @@ -1404,7 +1424,7 @@ bind:value={nameInput} onfocus={() => { nameEditing = true; }} onblur={() => { nameEditing = false; workout.name = nameInput; }} - onkeydown={(e) => { if (e.key === 'Enter') e.target.blur(); }} + onkeydown={(e) => { if (e.key === 'Enter' && e.target instanceof HTMLElement) e.target.blur(); }} placeholder={t('workout_name_placeholder', lang)} />