7 Commits

Author SHA1 Message Date
Alexander 9a15779a44 feat(fitness): rename Measure route to Check-in / Erfassung (NotebookPen icon)
CI / update (push) Successful in 3m47s
Route slugs and nav label rename only — storage, API endpoints
(`/api/fitness/measurements`), and the `BodyMeasurement` Mongo model
keep their technical names.

- `/fitness/measure` → `/fitness/check-in` (EN)
- `/fitness/messen` → `/fitness/erfassung` (DE)
- Folder `[measure=fitnessMeasure]` → `[checkin=fitnessCheckIn]`
  (git rename; history preserved).
- Param matcher `fitnessMeasure.ts` → `fitnessCheckIn.ts`, accepts
  `check-in` / `erfassung`.
- `fitnessSlugs(lang).measure` and `fitnessLabels(lang).measure` code
  keys are unchanged — value returns "check-in"/"erfassung" and
  "Check-in"/"Erfassung" respectively, so no call site needs touching.
- slugMap language-detection updated to `erfassung ↔ check-in`.
- Service-worker cache list + the layout regex that gates the wider
  content width now reference the new slugs.
- Nav icon swapped from `Ruler` to `NotebookPen` — reads as "logging
  entries" and spans weight / composition / period better.

Bookmarks on the old URLs will 404; no redirect added.
2026-04-23 14:12:54 +02:00
Alexander f807a43d58 feat(fitness/stats): body-fat trend chart as Δ from baseline
Mirrors the weight chart pipeline (SMA + ±1σ confidence band) for
body-fat %, but emits deltas from the first displayed measurement so
the y-axis shows change instead of raw numbers. Title surfaces the
baseline (e.g. "Body Fat · Δ from 18.2%"), y-unit is "pp" (percentage
points), colours are purple trend on top of an orange raw-data line
so it reads differently from weight's green+blue at a glance.

FitnessChart gained two shared upgrades: `interaction.mode = 'index'`
on line charts so hovering the x-axis shows tooltips for every dataset
(including the trend line whose pointRadius is 0), and a `σ` dataset
filter so the confidence band doesn't clutter the tooltip. A new
optional `tooltipFormatter` prop lets callers format the hover label;
the BF chart uses it to show the signed delta + reconstructed
absolute % for raw points and additionally the ±1σ window for trend
points (e.g. "+0.30 ±0.45 pp · 18.5% (18.0–18.9%)").
2026-04-23 13:57:47 +02:00
Alexander 8611275bca feat(fitness/body-parts): "Same as last" button + larger Copy L→R pill
- New "Same as last" pill below each step's stepper. Clicking fills
  the input(s) with the prior recorded value(s) — for paired steps
  in split mode, both L and R — and advances to the next step.
  Only rendered when a previous measurement exists; the placeholder
  already surfaces the exact number so the button text stays terse.
- Copy L→R button resized to match the same-as-last pill (0.88 rem
  text, 0.55 × 1.1 rem padding) and given top margin. Unicode →
  swapped for a proper ArrowRight icon between L and R.
- i18n: added `same_as_last` and split `copy_l_to_r` into
  `copy_l_to_r_before` / `copy_l_to_r_after` so each language keeps
  its natural wrapping around the arrow (EN "Copy L / R",
  DE "L / R übernehmen").
2026-04-23 13:45:16 +02:00
Alexander 91e1efda6f feat(fitness/measure): consolidate entries by day + richer past-measurements summary
- Server POST now upserts by (user, calendar day). Non-conflicting
  fields merge silently; real overwrites (new non-empty value ≠
  stored value) return 409 with the conflict list. Client retries
  with `?overwrite=1` after a confirm dialog naming each field and
  its old→new value. Null/empty payload fields are skipped, so
  logging a body-fat entry on a day that already has weight merges
  cleanly without flagging a phantom weight conflict.
- `summaryParts` in the history now includes a body-parts count,
  e.g. "86 kg · 0.1% bf · 5 body parts" or "5 body parts" instead
  of the flat "Body measurements only" fallback. Pluralised in EN
  and DE.
- Inline quick-edit: "Full edit →" text replaced by a dashed primary
  pill `Pencil · Edit all fields · ChevronRight`, inlined with the
  X / ✓ action buttons on the same row. The label collapses to
  icons only at ≤480px so the three controls stay on one line.
- Quick-edit date input swapped from native `<input type="date">`
  to the site's `DatePicker` component.
- New i18n: `overwrite_title`, `overwrite_message`, `overwrite_confirm`.
- TODO.md marks features #2 and #3 done. CLAUDE.md carries a
  policy note (no AI-attribution trailers on commits).
2026-04-23 13:35:41 +02:00
Alexander 6d3165f405 feat(fitness/measure): paginate past measurements (SSR 10, "Show more" pulls 20)
SSR now ships only the 10 most recent measurements (down from 200) to
cut initial page weight. A "Show more (N/total)" pill appears below
the list when more are available; clicking fetches the next 20 via
the existing GET endpoint (offset/limit already supported) and
appends with dedupe by `_id`.

`measurementsTotal` is seeded from the API's `total` field and kept
in sync on save (+1) / delete (−1). The button is hidden when the
history is collapsed or when `measurements.length >= total`.

Added `show_more` i18n string.
2026-04-23 13:18:32 +02:00
Alexander e9ebe492fb 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.
2026-04-23 13:12:07 +02:00
Alexander 36058d1b94 chore(fitness): drop unused .totals* CSS from body-parts page
Removes the leftover styles for the running-totals block that was
deleted earlier. Clears 9 Svelte "Unused CSS selector" build warnings.
2026-04-23 11:42:01 +02:00
58 changed files with 1125 additions and 311 deletions
+33 -27
View File
@@ -1,7 +1,25 @@
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively: # Repository Instructions
## Commits
- **Never** append `Co-Authored-By: Claude ...` (or any similar AI-attribution trailer) to commit messages. Do not add it even if a default template or prior convention suggests it.
- Do not include "Generated with Claude Code" footers or similar watermarks in commit messages, PR bodies, or any files in this repo.
### Versioning
When committing, bump version numbers as appropriate using semver:
- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
- **major** (X.0.0): breaking changes, major redesigns, data model changes
Version files to update:
- `package.json` — site version (bump on every commit)
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.
## Available MCP Tools: ## Available MCP Tools:
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
### 1. list-sections ### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths. Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
@@ -30,9 +48,9 @@ Svelte 5 removed event modifiers like `on:click|preventDefault`. Use inline hand
Generates a Svelte Playground link with the provided code. Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project. After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
## Theming Rules # Theming Rules
### Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties) ## Semantic CSS Variables (ALWAYS use these, NEVER hardcode Nord values for themed properties)
| Purpose | Variable | Light resolves to | Dark resolves to | | Purpose | Variable | Light resolves to | Dark resolves to |
|---|---|---|---| |---|---|---|---|
@@ -46,22 +64,22 @@ After completing the code, ask the user if they want a playground link. Only cal
| Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 | | Tertiary text (descriptions) | `--color-text-tertiary` | nord2 | nord5 |
| Borders | `--color-border` | nord4 | nord2/3 | | Borders | `--color-border` | nord4 | nord2/3 |
### What NOT to do ## What NOT to do
- **NEVER** use `var(--nord0)` through `var(--nord6)` for backgrounds, text, or borders — these don't adapt to theme - **NEVER** use `var(--nord0)` through `var(--nord6)` for backgrounds, text, or borders — these don't adapt to theme
- **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically - **NEVER** write `@media (prefers-color-scheme: dark)` or `:global(:root[data-theme="dark"])` override blocks — semantic variables handle both themes automatically
- **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy - **NEVER** use `var(--font-default-dark)` or `var(--accent-dark)` — these are legacy
### Primary interactive elements ## Primary interactive elements
- Background: `var(--color-primary)` (nord10 light / nord8 dark) - Background: `var(--color-primary)` (nord10 light / nord8 dark)
- Hover: `var(--color-primary-hover)` - Hover: `var(--color-primary-hover)`
- Active: `var(--color-primary-active)` - Active: `var(--color-primary-active)`
- Text on primary bg: `var(--color-text-on-primary)` - Text on primary bg: `var(--color-text-on-primary)`
### Accent colors (OK to use directly, they work in both themes) ## Accent colors (OK to use directly, they work in both themes)
- `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors - `var(--blue)`, `var(--red)`, `var(--green)`, `var(--orange)` — named accent colors
- `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only - `var(--nord10)`, `var(--nord11)`, `var(--nord12)`, `var(--nord14)` — OK for hover states of accent-colored buttons only
### Chart.js theme reactivity ## Chart.js theme reactivity
Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`: Charts don't use CSS variables. Use the `isDark()` pattern from `FitnessChart.svelte`:
```js ```js
function isDark() { function isDark() {
@@ -74,58 +92,46 @@ const textColor = isDark() ? '#D8DEE9' : '#2E3440';
``` ```
Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener. Re-create the chart on theme change via `MutationObserver` on `data-theme` + `matchMedia` listener.
### Form inputs ## Form inputs
- Background: `var(--color-bg-tertiary)` - Background: `var(--color-bg-tertiary)`
- Border: `var(--color-border)` - Border: `var(--color-border)`
- Text: `var(--color-text-primary)` - Text: `var(--color-text-primary)`
- Label: `var(--color-text-secondary)` - Label: `var(--color-text-secondary)`
### Toggle component ## Toggle component
Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches. Use `Toggle.svelte` (iOS-style) instead of raw `<input type="checkbox">` for user-facing boolean switches.
## Site-Wide Design Language ## Site-Wide Design Language
### Layout & Spacing ## Layout & Spacing
- Max content width: `1000px``1200px` with `margin-inline: auto` - Max content width: `1000px``1200px` with `margin-inline: auto`
- Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile - Card/grid gaps: `2rem` desktop, `1rem` tablet, `0.5rem` mobile
- Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop) - Breakpoints: `410px` (small mobile), `560px` (tablet), `900px` (rosary), `1024px` (desktop)
### Border Radius Tokens ## Border Radius Tokens
- `--radius-pill: 1000px` — nav bar, pill buttons - `--radius-pill: 1000px` — nav bar, pill buttons
- `--radius-card: 20px` — major cards (recipe cards) - `--radius-card: 20px` — major cards (recipe cards)
- `--radius-lg: 0.75rem` — medium rounded elements - `--radius-lg: 0.75rem` — medium rounded elements
- `--radius-md: 0.5rem` — standard rounding - `--radius-md: 0.5rem` — standard rounding
- `--radius-sm: 0.3rem` — small elements - `--radius-sm: 0.3rem` — small elements
### Shadow Tokens ## Shadow Tokens
- `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode - `--shadow-sm` / `--shadow-md` / `--shadow-lg` / `--shadow-hover` — use these, don't hardcode
- Shadows are spread-based (`0 0 Xem Yem`) not offset-based - Shadows are spread-based (`0 0 Xem Yem`) not offset-based
### Hover & Interaction Patterns ## Hover & Interaction Patterns
- Cards/links: `scale: 1.02` + shadow elevation on hover - Cards/links: `scale: 1.02` + shadow elevation on hover
- Tags/pills: `scale: 1.05` with `--transition-fast` (100ms) - Tags/pills: `scale: 1.05` with `--transition-fast` (100ms)
- Standard transitions: `--transition-normal` (200ms) - Standard transitions: `--transition-normal` (200ms)
- Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg) - Nav bar: glassmorphism (`backdrop-filter: blur(16px)`, semi-transparent bg)
### Typography ## Typography
- Font stack: Helvetica, Arial, "Noto Sans", sans-serif - Font stack: Helvetica, Arial, "Noto Sans", sans-serif
- Size tokens: `--text-sm` through `--text-3xl` - Size tokens: `--text-sm` through `--text-3xl`
- Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile - Headings in grids: `1.5rem` desktop → `1.2rem` tablet → `0.95rem` mobile
### Surfaces & Cards ## Surfaces & Cards
- Use `--color-surface` / `--color-surface-hover` for card backgrounds - Use `--color-surface` / `--color-surface-hover` for card backgrounds
- Use `--color-bg-elevated` for hover/active states - Use `--color-bg-elevated` for hover/active states
- Recipe cards: 300px wide, `--radius-card` corners - Recipe cards: 300px wide, `--radius-card` corners
- Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped) - Global utility classes: `.g-icon-badge` (circular), `.g-pill` (pill-shaped)
## Versioning
When committing, bump version numbers as appropriate using semver:
- **patch** (x.y.Z): bug fixes, minor styling tweaks, small corrections
- **minor** (x.Y.0): new features, significant UI changes, new pages/routes
- **major** (X.0.0): breaking changes, major redesigns, data model changes
Version files to update:
- `package.json` — site version (bump on every commit)
- `src-tauri/tauri.conf.json` + `src-tauri/Cargo.toml` — Tauri/Android app version. Only bump these when the Tauri app codebase itself changes (e.g. `src-tauri/` files), NOT for website-only changes.
+9
View File
@@ -1,5 +1,14 @@
# TODO # TODO
## Features
[x] on /fitness/measure, fill "Past measurements" in SSR only for the last 10 measurements. anything further should be fetched client side on mount to decreae initial page load time. use a "show more" button and paginate measurments.
[x] on /fitness/measure (resp. their associated logging API routes), consolidate measurements by day. If we want to log another measurement, overwriting an old one, show a warning to indicate this. disparate measurements (e.g., weight and bodyfat) should not show this warning but simply be merged into one log entry for that day.
[x] on /fitness/measure in the past measurments tab, show more than "Body measurements only" if we don't have Bodyweight logged. we can be a bit more elaborate in our syntax here tbh.
[x] add a button on /fitness/measure/body-parts for each measurement directly below to say "Same value", instead of having to hit +, then - to lock in same number
[x] BF graph (with trend line like weight graph) on /fitness/stats page. Emphasize relative changes, not absolute numbers in design (as we cannot trust those) (e.g., use start day of overview as 0% and then show +/- x % on the graph)
[x] Workshop better names than "Measure" for the /fitness/measure route. It's about body data points (i.e., non-food related). What's a better, short name than "Measure" to capture the logging of weight, body composition, body part measurements, and period tracking?
[ ] on /fitness/stats/histoy/<part> for body measurement graphs, make the range reasonable. e.g., if we have 1 cm change, do not fill the entire y-height with 1 cm. Use reasonable padding for low ranges (i think we do something like htis already on the weight graph?)
## Refactor Recipe Search Component ## Refactor Recipe Search Component
Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will: Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte` component for the visual input part. This will:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.46.1", "version": "1.46.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1
View File
@@ -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);
+8 -2
View File
@@ -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;
} }
} }
+1 -1
View File
@@ -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 -1
View File
@@ -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}>
+3 -2
View File
@@ -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);
+17 -3
View File
@@ -10,10 +10,11 @@
* title?: string, * title?: string,
* height?: string, * height?: string,
* yUnit?: string, * yUnit?: string,
* goalLine?: number * goalLine?: number,
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string
* }} * }}
*/ */
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined } = $props(); let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined } = $props();
/** @type {HTMLCanvasElement | undefined} */ /** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined); let canvas = $state(undefined);
@@ -109,6 +110,7 @@
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
animation: { duration: 0 }, animation: { duration: 0 },
interaction: type === 'line' ? { mode: 'index', intersect: false } : undefined,
scales: { scales: {
x: useTimeAxis ? { x: useTimeAxis ? {
type: 'time', type: 'time',
@@ -156,7 +158,19 @@
bodyColor: dark ? '#D8DEE9' : '#3B4252', bodyColor: dark ? '#D8DEE9' : '#3B4252',
borderWidth: 0, borderWidth: 0,
cornerRadius: 8, cornerRadius: 8,
padding: 10 padding: 10,
filter: (/** @type {any} */ ctx) => !(ctx.dataset?.label ?? '').includes('σ'),
...(tooltipFormatter ? {
callbacks: {
label: (/** @type {any} */ ctx) => {
const v = ctx.parsed.y;
const label = ctx.dataset.label ?? '';
if (v == null) return label;
const formatted = tooltipFormatter(v, ctx.datasetIndex, ctx.dataIndex, label);
return `${label}: ${formatted}`;
}
}
} : {})
} }
}) })
} }
+49 -14
View File
@@ -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 = {
+41 -10
View File
@@ -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 };
} }
+31 -5
View File
@@ -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}
-11
View File
@@ -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' },
+14 -6
View File
@@ -1,8 +1,8 @@
/** Fitness route i18n — slug mappings and UI translations */ /** Fitness route i18n — slug mappings and UI translations */
const slugMap: Record<string, Record<string, string>> = { const slugMap: Record<string, Record<string, string>> = {
en: { statistik: 'stats', verlauf: 'history', training: 'workout', aktiv: 'active', uebungen: 'exercises', messen: 'measure', ernaehrung: 'nutrition' }, en: { statistik: 'stats', verlauf: 'history', training: 'workout', aktiv: 'active', uebungen: 'exercises', erfassung: 'check-in', ernaehrung: 'nutrition' },
de: { stats: 'statistik', history: 'verlauf', workout: 'training', active: 'aktiv', exercises: 'uebungen', measure: 'messen', nutrition: 'ernaehrung' } de: { stats: 'statistik', history: 'verlauf', workout: 'training', active: 'aktiv', exercises: 'uebungen', 'check-in': 'erfassung', nutrition: 'ernaehrung' }
}; };
const germanSlugs = new Set(Object.keys(slugMap.en)); const germanSlugs = new Set(Object.keys(slugMap.en));
@@ -31,7 +31,7 @@ export function fitnessSlugs(lang: 'en' | 'de') {
workout: lang === 'en' ? 'workout' : 'training', workout: lang === 'en' ? 'workout' : 'training',
active: lang === 'en' ? 'active' : 'aktiv', active: lang === 'en' ? 'active' : 'aktiv',
exercises: lang === 'en' ? 'exercises' : 'uebungen', exercises: lang === 'en' ? 'exercises' : 'uebungen',
measure: lang === 'en' ? 'measure' : 'messen', measure: lang === 'en' ? 'check-in' : 'erfassung',
nutrition: lang === 'en' ? 'nutrition' : 'ernaehrung' nutrition: lang === 'en' ? 'nutrition' : 'ernaehrung'
}; };
} }
@@ -43,7 +43,7 @@ export function fitnessLabels(lang: 'en' | 'de') {
history: lang === 'en' ? 'History' : 'Verlauf', history: lang === 'en' ? 'History' : 'Verlauf',
workout: lang === 'en' ? 'Workout' : 'Training', workout: lang === 'en' ? 'Workout' : 'Training',
exercises: lang === 'en' ? 'Exercises' : 'Übungen', exercises: lang === 'en' ? 'Exercises' : 'Übungen',
measure: lang === 'en' ? 'Measure' : 'Messen', measure: lang === 'en' ? 'Check-in' : 'Erfassung',
nutrition: lang === 'en' ? 'Nutrition' : 'Ernährung' nutrition: lang === 'en' ? 'Nutrition' : 'Ernährung'
}; };
} }
@@ -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' },
@@ -293,6 +292,8 @@ const translations: Translations = {
exit: { en: 'Exit', de: 'Schlie\u00dfen' }, exit: { en: 'Exit', de: 'Schlie\u00dfen' },
same_both_sides: { en: 'Same on both sides', de: 'Auf beiden Seiten gleich' }, same_both_sides: { en: 'Same on both sides', de: 'Auf beiden Seiten gleich' },
copy_l_to_r: { en: 'Copy L \u2192 R', de: 'L \u2192 R \u00fcbernehmen' }, copy_l_to_r: { en: 'Copy L \u2192 R', de: 'L \u2192 R \u00fcbernehmen' },
copy_l_to_r_before: { en: 'Copy L', de: 'L' },
copy_l_to_r_after: { en: 'R', de: 'R \u00fcbernehmen' },
kbd_nav: { en: 'nav', de: 'Navigation' }, kbd_nav: { en: 'nav', de: 'Navigation' },
kbd_next: { en: 'next', de: 'weiter' }, kbd_next: { en: 'next', de: 'weiter' },
kbd_skip: { en: 'skip', de: 'auslassen' }, kbd_skip: { en: 'skip', de: 'auslassen' },
@@ -313,6 +314,14 @@ const translations: Translations = {
body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' }, body_fat_pct: { en: 'Body Fat (%)', de: 'Körperfett (%)' },
history: { en: 'History', de: 'Verlauf' }, history: { en: 'History', de: 'Verlauf' },
past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' }, past_measurements: { en: 'Past measurements', de: 'Frühere Messungen' },
show_more: { en: 'Show more', de: 'Mehr anzeigen' },
overwrite_title: { en: 'Overwrite existing values?', de: 'Bestehende Werte überschreiben?' },
overwrite_message: {
en: 'You already have values for this date: {fields}. Replace them?',
de: 'Für dieses Datum sind bereits Werte erfasst: {fields}. Überschreiben?'
},
overwrite_confirm: { en: 'Overwrite', de: 'Überschreiben' },
same_as_last: { en: 'Same as last', de: 'Wie zuletzt' },
// SetTable // SetTable
set_header: { en: 'SET', de: 'SATZ' }, set_header: { en: 'SET', de: 'SATZ' },
@@ -448,7 +457,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
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'; import { createWebHaptics } from 'web-haptics/svelte';
export type HapticPulse = { duration: number; intensity?: number }; 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; 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 };
+1 -1
View File
@@ -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',
@@ -1,5 +1,5 @@
import type { ParamMatcher } from '@sveltejs/kit'; import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => { export const match: ParamMatcher = (param) => {
return param === 'measure' || param === 'messen'; return param === 'check-in' || param === 'erfassung';
}; };
@@ -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');
} }
}; };
@@ -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>
+8 -8
View File
@@ -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
+1 -1
View File
@@ -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);
}); });
+81 -12
View File
@@ -30,23 +30,92 @@ export const GET: RequestHandler = async ({ url, locals }) => {
return json({ measurements, total, limit, offset }); return json({ measurements, total, limit, offset });
}; };
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals, url }) => {
const user = await requireAuth(locals); const user = await requireAuth(locals);
await dbConnect(); await dbConnect();
const data = await request.json(); const data = await request.json();
const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data; const { date, weight, bodyFatPercent, caloricIntake, measurements: bp, notes } = data;
const overwrite = url.searchParams.get('overwrite') === '1';
const measurement = new BodyMeasurement({ const target = date ? new Date(date) : new Date();
date: date ? new Date(date) : new Date(), const dayStart = new Date(Date.UTC(target.getUTCFullYear(), target.getUTCMonth(), target.getUTCDate()));
weight, const dayEnd = new Date(dayStart.getTime() + 24 * 60 * 60 * 1000);
bodyFatPercent,
caloricIntake, const existing = await BodyMeasurement.findOne({
measurements, createdBy: user.nickname,
notes, date: { $gte: dayStart, $lt: dayEnd }
createdBy: user.nickname
}); });
await measurement.save(); if (!existing) {
return json({ measurement }, { status: 201 }); const doc = new BodyMeasurement({
date: dayStart,
weight,
bodyFatPercent,
caloricIntake,
measurements: bp,
notes,
createdBy: user.nickname
});
await doc.save();
return json({ measurement: doc, merged: false }, { status: 201 });
}
type Conflict = { key: string; oldVal: unknown; newVal: unknown };
const conflicts: Conflict[] = [];
const merges: Record<string, unknown> = {};
const existingDoc = existing;
/** Top-level field: add if missing, conflict if present and different. Null/empty = "no value supplied". */
function check(key: 'weight' | 'bodyFatPercent' | 'caloricIntake' | 'notes', newVal: unknown) {
if (newVal == null || newVal === '') return;
const oldVal = existingDoc[key];
if (oldVal == null || oldVal === '') {
merges[key] = newVal;
} else if (newVal !== oldVal) {
conflicts.push({ key, oldVal, newVal });
}
}
check('weight', weight);
check('bodyFatPercent', bodyFatPercent);
check('caloricIntake', caloricIntake);
check('notes', notes);
/** Body-parts sub-object: check each field independently */
let mergedBp: Record<string, unknown> | undefined;
if (bp && typeof bp === 'object') {
const existingBp = (existing.measurements ?? {}) as Record<string, unknown>;
mergedBp = { ...existingBp };
for (const [partKey, partVal] of Object.entries(bp)) {
if (partVal == null) continue;
const oldPart = existingBp[partKey];
if (oldPart == null) {
mergedBp[partKey] = partVal;
} else if (oldPart !== partVal) {
conflicts.push({ key: `measurements.${partKey}`, oldVal: oldPart, newVal: partVal });
}
}
}
if (conflicts.length > 0 && !overwrite) {
return json({ conflicts, existingId: existing._id }, { status: 409 });
}
if (overwrite && conflicts.length > 0) {
for (const c of conflicts) {
if (c.key.startsWith('measurements.')) {
const partKey = c.key.slice('measurements.'.length);
if (!mergedBp) mergedBp = { ...((existing.measurements ?? {}) as Record<string, unknown>) };
mergedBp[partKey] = c.newVal;
} else {
merges[c.key] = c.newVal;
}
}
}
Object.assign(existing, merges);
if (mergedBp !== undefined) existing.measurements = mergedBp;
await existing.save();
return json({ measurement: existing, merged: true });
}; };
+1 -1
View File
@@ -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)
@@ -41,7 +41,7 @@ export const GET: RequestHandler = async ({ locals }) => {
// Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions // Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions
const DISPLAY_LIMIT = 30; const DISPLAY_LIMIT = 30;
const SMA_LOOKBACK = 6; // w - 1 where w = 7 max const SMA_LOOKBACK = 6; // w - 1 where w = 7 max
const [allSessions, weightMeasurements] = await Promise.all([ const [allSessions, weightMeasurements, bfMeasurements] = await Promise.all([
WorkoutSession.find( WorkoutSession.find(
{ createdBy: user.nickname }, { createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 } { 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
@@ -49,9 +49,14 @@ 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 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean(),
BodyMeasurement.find(
{ createdBy: user.nickname, bodyFatPercent: { $ne: null } },
{ date: 1, bodyFatPercent: 1, _id: 0 }
).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean() ).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean()
]); ]);
weightMeasurements.reverse(); // back to chronological order weightMeasurements.reverse(); // back to chronological order
bfMeasurements.reverse();
let totalTonnage = 0; let totalTonnage = 0;
let totalCardioKm = 0; let totalCardioKm = 0;
@@ -206,13 +211,59 @@ export const GET: RequestHandler = async ({ locals }) => {
weightChart.lower.push(round(mean - std)); weightChart.lower.push(round(mean - std));
} }
// Build body-fat chart as Δ from the first displayed point — emphasises
// relative change over noisy absolute numbers.
const bfChart: {
labels: string[];
dates: string[];
data: number[];
sma: (number | null)[];
upper: (number | null)[];
lower: (number | null)[];
baseline: number | null;
} = { labels: [], dates: [], data: [], sma: [], upper: [], lower: [], baseline: null };
if (bfMeasurements.length > 0) {
const allBf: number[] = bfMeasurements.map((m) => m.bodyFatPercent!);
const displayStartBf = Math.max(0, allBf.length - DISPLAY_LIMIT);
const baseline = allBf[displayStartBf];
bfChart.baseline = Math.round(baseline * 100) / 100;
for (let idx = displayStartBf; idx < bfMeasurements.length; idx++) {
const d = new Date(bfMeasurements[idx].date);
bfChart.labels.push(
d.toLocaleDateString('en', { month: 'short', day: 'numeric' })
);
bfChart.dates.push(d.toISOString());
bfChart.data.push(Math.round((allBf[idx] - baseline) * 100) / 100);
}
const wBf = Math.min(7, Math.max(2, Math.floor(allBf.length / 2)));
for (let idx = displayStartBf; idx < allBf.length; idx++) {
const k = Math.min(wBf, idx + 1);
let sum = 0;
for (let j = idx - k + 1; j <= idx; j++) sum += allBf[j];
const mean = sum / k;
let variance = 0;
for (let j = idx - k + 1; j <= idx; j++) variance += (allBf[j] - mean) ** 2;
const std = k > 1
? Math.sqrt(variance / (k - 1)) * Math.sqrt(wBf / k)
: Math.sqrt(variance) * Math.sqrt(wBf);
const round = (v: number) => Math.round(v * 100) / 100;
bfChart.sma.push(round(mean - baseline));
bfChart.upper.push(round(mean - baseline + std));
bfChart.lower.push(round(mean - baseline - std));
}
}
return json({ return json({
totalWorkouts, totalWorkouts,
totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10, totalTonnage: Math.round(totalTonnage / 1000 * 10) / 10,
totalCardioKm: Math.round(totalCardioKm * 10) / 10, totalCardioKm: Math.round(totalCardioKm * 10) / 10,
kcalEstimate, kcalEstimate,
workoutsChart, workoutsChart,
weightChart weightChart,
bfChart
}); });
}; };
+2 -2
View File
@@ -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);
+5 -5
View File
@@ -4,7 +4,7 @@
import Header from '$lib/components/Header.svelte'; import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte'; import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { BarChart3, Clock, Dumbbell, ListChecks, Ruler, UtensilsCrossed } from '@lucide/svelte'; import { BarChart3, Clock, Dumbbell, ListChecks, NotebookPen, UtensilsCrossed } from '@lucide/svelte';
import { getWorkout } from '$lib/js/workout.svelte'; import { getWorkout } from '$lib/js/workout.svelte';
import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import { getWorkoutSync } from '$lib/js/workoutSync.svelte';
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte'; import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
@@ -27,7 +27,7 @@
const slugs = [ const slugs = [
'workout', 'training', 'workout/active', 'training/aktiv', 'workout', 'training', 'workout/active', 'training/aktiv',
'exercises', 'uebungen', 'stats', 'statistik', 'exercises', 'uebungen', 'stats', 'statistik',
'history', 'verlauf', 'measure', 'messen', 'history', 'verlauf', 'check-in', 'erfassung',
'nutrition', 'ernaehrung' 'nutrition', 'ernaehrung'
]; ];
const urls = slugs.map((s) => `/fitness/${s}`); const urls = slugs.map((s) => `/fitness/${s}`);
@@ -69,7 +69,7 @@
!$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`) !$page.url.pathname.startsWith(`/fitness/${s.nutrition}/meals`)
); );
const isMeasureIndex = $derived( const isMeasureIndex = $derived(
/^\/fitness\/(measure|messen)\/?$/.test($page.url.pathname) /^\/fitness\/(check-in|erfassung)\/?$/.test($page.url.pathname)
); );
/** @param {number} secs */ /** @param {number} secs */
function formatElapsed(secs) { function formatElapsed(secs) {
@@ -86,7 +86,7 @@
<li style="--active-fill: var(--nord13)"><a href="/fitness/{s.history}" class:active={isActive(`/fitness/${s.history}`)}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.history}</span></a></li> <li style="--active-fill: var(--nord13)"><a href="/fitness/{s.history}" class:active={isActive(`/fitness/${s.history}`)}><Clock size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.history}</span></a></li>
<li style="--active-fill: var(--nord8)"><a href="/fitness/{s.workout}" class:active={isActive(`/fitness/${s.workout}`)}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.workout}</span></a></li> <li style="--active-fill: var(--nord8)"><a href="/fitness/{s.workout}" class:active={isActive(`/fitness/${s.workout}`)}><Dumbbell size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.workout}</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/fitness/{s.exercises}" class:active={isActive(`/fitness/${s.exercises}`)}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.exercises}</span></a></li> <li style="--active-fill: var(--nord14)"><a href="/fitness/{s.exercises}" class:active={isActive(`/fitness/${s.exercises}`)}><ListChecks size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.exercises}</span></a></li>
<li style="--active-fill: var(--nord12)"><a href="/fitness/{s.measure}" class:active={isActive(`/fitness/${s.measure}`)}><Ruler size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.measure}</span></a></li> <li style="--active-fill: var(--nord12)"><a href="/fitness/{s.measure}" class:active={isActive(`/fitness/${s.measure}`)}><NotebookPen size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.measure}</span></a></li>
<li style="--active-fill: var(--nord15)"><a href="/fitness/{s.nutrition}" class:active={isActive(`/fitness/${s.nutrition}`)}><UtensilsCrossed size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.nutrition}</span></a></li> <li style="--active-fill: var(--nord15)"><a href="/fitness/{s.nutrition}" class:active={isActive(`/fitness/${s.nutrition}`)}><UtensilsCrossed size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.nutrition}</span></a></li>
</ul> </ul>
{/snippet} {/snippet}
@@ -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}
@@ -3,7 +3,7 @@ import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
const [latestRes, listRes, goalRes, periodRes, shareRes] = await Promise.all([ const [latestRes, listRes, goalRes, periodRes, shareRes] = await Promise.all([
fetch('/api/fitness/measurements/latest'), fetch('/api/fitness/measurements/latest'),
fetch('/api/fitness/measurements?limit=200'), fetch('/api/fitness/measurements?limit=10'),
fetch('/api/fitness/goal'), fetch('/api/fitness/goal'),
fetch('/api/fitness/period').catch(() => null), fetch('/api/fitness/period').catch(() => null),
fetch('/api/fitness/period/share').catch(() => null) fetch('/api/fitness/period/share').catch(() => null)
@@ -27,6 +27,9 @@
let latest = $state(data.latest ? { ...data.latest } : {}); let latest = $state(data.latest ? { ...data.latest } : {});
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []); let measurements = $state(data.measurements?.measurements ? [...data.measurements.measurements] : []);
// svelte-ignore state_referenced_locally
let measurementsTotal = $state(/** @type {number} */ (data.measurements?.total ?? measurements.length));
let loadingMore = $state(false);
let showWeightHistory = $state(false); let showWeightHistory = $state(false);
// Profile fields (sex, height, birth year) — stored in FitnessGoal // Profile fields (sex, height, birth year) — stored in FitnessGoal
@@ -105,6 +108,27 @@
{ label: t('r_calf', lang), key: 'rightCalf', value: latestBp.rightCalf } { label: t('r_calf', lang), key: 'rightCalf', value: latestBp.rightCalf }
]); ]);
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
const res = await fetch(`/api/fitness/measurements?limit=20&offset=${measurements.length}`);
if (res.ok) {
const body = await res.json();
const next = Array.isArray(body?.measurements) ? body.measurements : [];
const existing = new Set(measurements.map((/** @type {any} */ m) => m._id));
const fresh = next.filter((/** @type {any} */ m) => !existing.has(m._id));
measurements = [...measurements, ...fresh];
if (typeof body?.total === 'number') measurementsTotal = body.total;
} else {
toast.error(lang === 'en' ? 'Failed to load more' : 'Laden fehlgeschlagen');
}
} catch {
toast.error(lang === 'en' ? 'Failed to load more' : 'Laden fehlgeschlagen');
}
loadingMore = false;
}
/** @param {string} id */ /** @param {string} id */
async function deleteMeasurement(id) { async function deleteMeasurement(id) {
if (!await confirm(t('delete_measurement_confirm', lang))) return; if (!await confirm(t('delete_measurement_confirm', lang))) return;
@@ -112,6 +136,7 @@
const res = await fetch(`/api/fitness/measurements/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/fitness/measurements/${id}`, { method: 'DELETE' });
if (res.ok) { if (res.ok) {
measurements = measurements.filter((m) => m._id !== id); measurements = measurements.filter((m) => m._id !== id);
measurementsTotal = Math.max(0, measurementsTotal - 1);
try { try {
const latestRes = await fetch('/api/fitness/measurements/latest'); const latestRes = await fetch('/api/fitness/measurements/latest');
if (latestRes.ok) latest = await latestRes.json(); if (latestRes.ok) latest = await latestRes.json();
@@ -135,7 +160,15 @@
const parts = []; const parts = [];
if (m.weight != null) parts.push(`${m.weight} kg`); if (m.weight != null) parts.push(`${m.weight} kg`);
if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`); if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`);
return parts.join(' · ') || t('body_measurements_only', lang); const bpCount = m.measurements
? Object.values(m.measurements).filter((v) => v != null).length
: 0;
if (bpCount > 0) {
const en = bpCount === 1 ? '1 body part' : `${bpCount} body parts`;
const de = bpCount === 1 ? '1 Körperteil' : `${bpCount} Körperteile`;
parts.push(lang === 'en' ? en : de);
}
return parts.join(' · ') || t('no_measurements_yet', lang);
} }
// --- New measurement form --- // --- New measurement form ---
@@ -184,11 +217,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);
@@ -291,22 +326,72 @@
formDate = new Date().toISOString().slice(0, 10); formDate = new Date().toISOString().slice(0, 10);
} }
/**
* Render a conflict list for the overwrite dialog.
* @param {Array<{ key: string, oldVal: unknown, newVal: unknown }>} conflicts
*/
function formatConflicts(conflicts) {
/** @type {Record<string, string>} */
const partKeyMap = {
leftBicep: 'l_bicep', rightBicep: 'r_bicep',
leftForearm: 'l_forearm', rightForearm: 'r_forearm',
leftThigh: 'l_thigh', rightThigh: 'r_thigh',
leftCalf: 'l_calf', rightCalf: 'r_calf'
};
return conflicts.map((c) => {
let label = c.key;
let unit = '';
if (c.key === 'weight') { label = lang === 'en' ? 'Weight' : 'Gewicht'; unit = ' kg'; }
else if (c.key === 'bodyFatPercent') { label = lang === 'en' ? 'Body Fat' : 'Körperfett'; unit = ' %'; }
else if (c.key === 'caloricIntake') { label = lang === 'en' ? 'Calories' : 'Kalorien'; unit = ' kcal'; }
else if (c.key === 'notes') { label = lang === 'en' ? 'Notes' : 'Notizen'; }
else if (c.key.startsWith('measurements.')) {
const part = c.key.slice('measurements.'.length);
label = t(partKeyMap[part] ?? part, lang);
unit = ' cm';
}
return `${label} (${c.oldVal}${unit} → ${c.newVal}${unit})`;
}).join(', ');
}
async function saveMeasurement() { async function saveMeasurement() {
saving = true; saving = true;
try { try {
const res = await fetch('/api/fitness/measurements', { const body = buildBody();
/** @param {boolean} overwrite */
const doPost = (overwrite) => fetch(`/api/fitness/measurements${overwrite ? '?overwrite=1' : ''}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildBody()) body: JSON.stringify(body)
}); });
let res = await doPost(false);
if (res.status === 409) {
const { conflicts } = await res.json();
const ok = await confirm(
t('overwrite_message', lang).replace('{fields}', formatConflicts(conflicts)),
{
title: t('overwrite_title', lang),
confirmText: t('overwrite_confirm', lang),
cancelText: t('cancel', lang),
destructive: true
}
);
if (!ok) { saving = false; return; }
res = await doPost(true);
}
if (res.ok) { if (res.ok) {
const created = await res.json(); const payload = await res.json();
// Refresh latest and prepend to history const saved = payload.measurement ?? payload;
try { try {
const latestRes = await fetch('/api/fitness/measurements/latest'); const latestRes = await fetch('/api/fitness/measurements/latest');
if (latestRes.ok) latest = await latestRes.json(); if (latestRes.ok) latest = await latestRes.json();
} catch {} } catch {}
measurements = [created.measurement ?? created, ...measurements]; if (payload.merged) {
measurements = measurements.map((/** @type {any} */ m) => m._id === saved._id ? saved : m);
} else {
measurements = [saved, ...measurements];
measurementsTotal = measurementsTotal + 1;
}
resetForm(); resetForm();
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
} else { } else {
@@ -508,12 +593,9 @@
<div class="history-item" class:editing={editingId === m._id}> <div class="history-item" class:editing={editingId === m._id}>
{#if editingId === m._id} {#if editingId === m._id}
<div class="edit-row"> <div class="edit-row">
<input <div class="edit-date-wrap">
type="date" <DatePicker bind:value={editDate} {lang} />
bind:value={editDate} </div>
class="edit-input edit-date"
onkeydown={(e) => onEditKey(e, m)}
/>
<div class="edit-num"> <div class="edit-num">
<input <input
type="number" type="number"
@@ -539,6 +621,11 @@
<span class="edit-unit">%</span> <span class="edit-unit">%</span>
</div> </div>
<div class="edit-actions"> <div class="edit-actions">
<a class="edit-more" href="/fitness/{measureSlug}/edit/{m._id}" aria-label={t('edit_measurement', lang)}>
<Pencil size={11} />
<span class="edit-more-label">{lang === 'en' ? 'Edit all fields' : 'Alle Felder bearbeiten'}</span>
<ChevronRight size={11} />
</a>
<button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t('cancel', lang)}> <button type="button" class="edit-btn cancel" onclick={cancelEdit} aria-label={t('cancel', lang)}>
<X size={14} /> <X size={14} />
</button> </button>
@@ -546,9 +633,6 @@
<Check size={14} /> <Check size={14} />
</button> </button>
</div> </div>
<a class="edit-more" href="/fitness/{measureSlug}/edit/{m._id}" aria-label={t('edit_measurement', lang)}>
{lang === 'en' ? 'Full edit →' : 'Alle Felder →'}
</a>
</div> </div>
{:else} {:else}
<div class="history-main"> <div class="history-main">
@@ -569,6 +653,12 @@
</div> </div>
{/each} {/each}
</div> </div>
{#if showWeightHistory && measurements.length < measurementsTotal}
<button type="button" class="show-more" onclick={loadMore} disabled={loadingMore}>
{loadingMore ? t('saving', lang) : t('show_more', lang)}
<span class="show-more-count">({measurements.length}/{measurementsTotal})</span>
</button>
{/if}
</section> </section>
{/if} {/if}
@@ -1134,6 +1224,35 @@
flex-direction: column; flex-direction: column;
gap: 0.4rem; gap: 0.4rem;
} }
.show-more {
align-self: stretch;
margin-top: 0.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 1rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-pill);
background: transparent;
color: var(--color-text-secondary);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: border-color var(--transition-fast, 120ms), color var(--transition-fast, 120ms), background var(--transition-fast, 120ms);
}
.show-more:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in oklab, var(--color-primary) 6%, transparent);
}
.show-more:disabled { opacity: 0.5; cursor: not-allowed; }
.show-more-count {
font-size: 0.7rem;
font-weight: 500;
color: var(--color-text-tertiary);
font-variant-numeric: tabular-nums;
}
.history-item { .history-item {
background: var(--color-surface); background: var(--color-surface);
border-radius: 8px; border-radius: 8px;
@@ -1220,9 +1339,9 @@
outline: none; outline: none;
border-color: var(--color-primary); border-color: var(--color-primary);
} }
.edit-input.edit-date { .edit-date-wrap {
flex: 1 1 120px; flex: 1 1 140px;
min-width: 110px; min-width: 130px;
} }
.edit-num { .edit-num {
display: inline-flex; display: inline-flex;
@@ -1275,13 +1394,29 @@
color: var(--color-text-primary); color: var(--color-text-primary);
} }
.edit-more { .edit-more {
flex-basis: 100%; display: inline-flex;
font-size: 0.68rem; align-items: center;
color: var(--color-text-tertiary); gap: 0.3rem;
padding: 0.3rem 0.7rem;
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--color-primary) 5%, transparent);
color: var(--color-text-secondary);
font-size: 0.7rem;
font-weight: 600;
text-decoration: none; text-decoration: none;
padding-top: 0.1rem; white-space: nowrap;
transition: border-color var(--transition-fast, 120ms), background var(--transition-fast, 120ms), color var(--transition-fast, 120ms);
}
.edit-more:hover {
border-style: solid;
border-color: var(--color-primary);
background: color-mix(in oklab, var(--color-primary) 10%, transparent);
color: var(--color-primary);
}
@media (max-width: 480px) {
.edit-more-label { display: none; }
} }
.edit-more:hover { color: var(--color-primary); }
/* Desktop 2-col layout */ /* Desktop 2-col layout */
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -1,11 +1,12 @@
<script> <script>
import { page } from '$app/stores'; import { page } from '$app/stores';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Minus, Plus, X, ArrowLeft, ArrowRight, Check, Ruler, CopyPlus, TrendingUp } from '@lucide/svelte'; import { Minus, Plus, X, ArrowLeft, ArrowRight, Check, Ruler, CopyPlus, TrendingUp, History } from '@lucide/svelte';
import { fly, fade } from 'svelte/transition'; import { fly, fade } from 'svelte/transition';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n'; import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
import { toast } from '$lib/js/toast.svelte'; import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
import DatePicker from '$lib/components/DatePicker.svelte'; import DatePicker from '$lib/components/DatePicker.svelte';
import SaveFab from '$lib/components/SaveFab.svelte'; import SaveFab from '$lib/components/SaveFab.svelte';
import Toggle from '$lib/components/Toggle.svelte'; import Toggle from '$lib/components/Toggle.svelte';
@@ -47,17 +48,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>} */
@@ -115,6 +119,20 @@
} }
/** @param {string} key */ /** @param {string} key */
function copyLtoR(key) { values[key].right = values[key].left; } function copyLtoR(key) { values[key].right = values[key].left; }
/** @param {string} key */
function useLastValue(key) {
const s = steps.find((x) => x.key === key);
if (!s) return;
const last = historyFor(s).at(-1);
if (!last) return;
if (s.paired) {
if (last.left != null) values[key].left = String(last.left);
if (!values[key].same && last.right != null) values[key].right = String(last.right);
} else if (last.value != null) {
values[key] = String(last.value);
}
}
/** @param {number} i */ /** @param {number} i */
function jumpTo(i) { function jumpTo(i) {
direction = i > idx ? 1 : -1; direction = i > idx ? 1 : -1;
@@ -185,11 +203,42 @@
} }
saving = true; saving = true;
try { try {
const res = await fetch('/api/fitness/measurements', { const body = { date: formDate, measurements: ms };
/** @param {boolean} overwrite */
const doPost = (overwrite) => fetch(`/api/fitness/measurements${overwrite ? '?overwrite=1' : ''}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: formDate, measurements: ms }) body: JSON.stringify(body)
}); });
let res = await doPost(false);
if (res.status === 409) {
const { conflicts } = await res.json();
/** @type {Record<string, string>} */
const partKeyMap = {
leftBicep: 'l_bicep', rightBicep: 'r_bicep',
leftForearm: 'l_forearm', rightForearm: 'r_forearm',
leftThigh: 'l_thigh', rightThigh: 'r_thigh',
leftCalf: 'l_calf', rightCalf: 'r_calf'
};
/** @param {{ key: string, oldVal: unknown, newVal: unknown }} c */
const fmtConflict = (c) => {
const part = c.key.startsWith('measurements.') ? c.key.slice('measurements.'.length) : c.key;
const label = t(partKeyMap[part] ?? part, lang);
return `${label} (${c.oldVal} cm → ${c.newVal} cm)`;
};
const fields = conflicts.map(fmtConflict).join(', ');
const ok = await confirm(
t('overwrite_message', lang).replace('{fields}', fields),
{
title: t('overwrite_title', lang),
confirmText: t('overwrite_confirm', lang),
cancelText: t('cancel', lang),
destructive: true
}
);
if (!ok) { saving = false; return; }
res = await doPost(true);
}
if (res.ok) { if (res.ok) {
toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert'); toast.success(lang === 'en' ? 'Measurement saved' : 'Messung gespeichert');
await goto(`/fitness/${measureSlug}`); await goto(`/fitness/${measureSlug}`);
@@ -428,10 +477,19 @@
</button> </button>
</div> </div>
<button type="button" class="copy-btn" onclick={() => copyLtoR(step.key)} disabled={!pv.left}> <button type="button" class="copy-btn" onclick={() => copyLtoR(step.key)} disabled={!pv.left}>
<CopyPlus size={13} /> {t('copy_l_to_r', lang)} <CopyPlus size={15} />
<span>{t('copy_l_to_r_before', lang)}</span>
<ArrowRight size={14} />
<span>{t('copy_l_to_r_after', lang)}</span>
</button> </button>
</div> </div>
{/if} {/if}
{#if lastForStep?.left != null || lastForStep?.right != null}
<button type="button" class="same-value-btn" onclick={() => { useLastValue(step.key); next(); }}>
<History size={15} />
<span>{t('same_as_last', lang)}</span>
</button>
{/if}
<div class="same-toggle"> <div class="same-toggle">
<Toggle bind:checked={pv.same} label={t('same_both_sides', lang)} /> <Toggle bind:checked={pv.same} label={t('same_both_sides', lang)} />
</div> </div>
@@ -448,6 +506,12 @@
<Plus size={20} /> <Plus size={20} />
</button> </button>
</div> </div>
{#if lastForStep?.value != null}
<button type="button" class="same-value-btn" onclick={() => { useLastValue(step.key); next(); }}>
<History size={15} />
<span>{t('same_as_last', lang)}</span>
</button>
{/if}
{/if} {/if}
</section> </section>
{/key} {/key}
@@ -841,13 +905,15 @@
align-self: center; align-self: center;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.35rem; gap: 0.5rem;
padding: 0.25rem 0.7rem; margin-top: 0.5rem;
padding: 0.55rem 1.1rem;
border: 1px dashed var(--color-border); border: 1px dashed var(--color-border);
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: transparent; background: transparent;
font-size: 0.7rem; font-size: 0.88rem;
color: var(--color-text-tertiary); font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
transition: all 150ms; transition: all 150ms;
} }
@@ -858,6 +924,28 @@
} }
.copy-btn:disabled { opacity: 0.45; cursor: not-allowed; } .copy-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.same-value-btn {
align-self: center;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1.1rem;
border: 1px dashed color-mix(in oklab, var(--color-primary) 40%, transparent);
border-radius: var(--radius-pill);
background: color-mix(in oklab, var(--color-primary) 5%, transparent);
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color var(--transition-fast, 120ms), background var(--transition-fast, 120ms), color var(--transition-fast, 120ms);
}
.same-value-btn:hover {
border-style: solid;
border-color: var(--color-primary);
background: color-mix(in oklab, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.same-toggle { .same-toggle {
display: inline-flex; display: inline-flex;
} }
@@ -1226,42 +1314,6 @@
font-weight: 700; font-weight: 700;
} }
.totals {
list-style: none;
margin: 0;
padding: 0;
}
.totals li { border-bottom: 1px dashed var(--color-border); }
.totals li:last-child { border-bottom: none; }
.totals-item {
width: 100%;
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 0.42rem 0.2rem;
background: transparent;
border: none;
color: inherit;
text-align: left;
cursor: pointer;
transition: color 150ms;
}
.totals-item:hover { color: var(--color-text-primary); }
.totals-label {
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.totals-val {
font-size: 0.82rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.totals li.dim .totals-val { color: var(--color-text-tertiary); font-weight: 400; }
.totals li.focused .totals-label {
color: var(--color-primary);
font-weight: 700;
}
.hero { width: 300px; height: 300px; } .hero { width: 300px; height: 300px; }
.hero-pic { width: 200px; height: 200px; } .hero-pic { width: 200px; height: 200px; }
.title { font-size: 3.2rem; } .title { font-size: 3.2rem; }
@@ -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);
@@ -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>
@@ -38,6 +38,9 @@
const primary = $derived(dark ? '#88C0D0' : '#5E81AC'); const primary = $derived(dark ? '#88C0D0' : '#5E81AC');
const primaryFill = $derived(dark ? 'rgba(136, 192, 208, 0.15)' : 'rgba(94, 129, 172, 0.15)'); const primaryFill = $derived(dark ? 'rgba(136, 192, 208, 0.15)' : 'rgba(94, 129, 172, 0.15)');
// Purple trend + orange raw so BF reads differently from weight at a glance
const bfAccent = $derived('#B48EAD');
const bfAccentFill = $derived(dark ? 'rgba(180, 142, 173, 0.2)' : 'rgba(180, 142, 173, 0.16)');
const stats = $derived(data.stats ?? {}); const stats = $derived(data.stats ?? {});
@@ -111,6 +114,91 @@
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null)); const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
const hasSmaBf = $derived(stats.bfChart?.sma?.some((/** @type {any} */ v) => v !== null));
const bfChartData = $derived({
labels: stats.bfChart?.labels ?? [],
dates: stats.bfChart?.dates,
datasets: [
...(hasSmaBf ? [
{
label: '± 1σ',
data: stats.bfChart.upper,
borderColor: 'transparent',
backgroundColor: bfAccentFill,
fill: '+1',
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: '± 1σ (lower)',
data: stats.bfChart.lower,
borderColor: 'transparent',
backgroundColor: 'transparent',
fill: false,
pointRadius: 0,
borderWidth: 0,
tension: 0.3,
order: 2
},
{
label: 'Trend',
data: stats.bfChart.sma,
borderColor: bfAccent,
pointRadius: 0,
borderWidth: 3,
tension: 0.3,
order: 1
}
] : []),
{
label: 'Body fat Δ (pp)',
data: stats.bfChart?.data ?? [],
borderColor: '#D08770',
borderWidth: hasSmaBf ? 1 : 2,
pointRadius: 3,
order: 0
}
]
});
/**
* Tooltip: show signed delta + absolute %, and for the trend line the ±1σ range.
* @param {number} v
* @param {number} _datasetIndex
* @param {number} dataIndex
* @param {string} label
*/
function bfTooltipFormatter(v, _datasetIndex, dataIndex, label) {
const baseline = stats.bfChart?.baseline ?? 0;
const abs = baseline + v;
const sign = v > 0 ? '+' : v < 0 ? '' : '±';
const base = `${sign}${v.toFixed(2)} pp · ${abs.toFixed(1)}%`;
if (label === 'Trend') {
const upper = stats.bfChart?.upper?.[dataIndex];
const lower = stats.bfChart?.lower?.[dataIndex];
if (upper != null && lower != null) {
const sigma = (upper - lower) / 2;
const absLower = baseline + lower;
const absUpper = baseline + upper;
return `${sign}${v.toFixed(2)} ±${sigma.toFixed(2)} pp · ${abs.toFixed(1)}% (${absLower.toFixed(1)}${absUpper.toFixed(1)}%)`;
}
}
return base;
}
const bfChartTitle = $derived.by(() => {
const baseline = stats.bfChart?.baseline;
const label = t('body_fat', lang).replace(' %', '').replace(' (%)', '');
if (baseline == null) return label;
const suffix = lang === 'en'
? `Δ from ${baseline.toFixed(1)}%`
: `Δ von ${baseline.toFixed(1)}%`;
return `${label} · ${suffix}`;
});
const weightChartData = $derived({ const weightChartData = $derived({
labels: stats.weightChart?.labels ?? [], labels: stats.weightChart?.labels ?? [],
dates: stats.weightChart?.dates, dates: stats.weightChart?.dates,
@@ -253,6 +341,16 @@
/> />
{/if} {/if}
{#if (stats.bfChart?.data?.length ?? 0) > 1}
<FitnessChart
data={bfChartData}
title={bfChartTitle}
yUnit=" pp"
height="220px"
tooltipFormatter={bfTooltipFormatter}
/>
{/if}
<div class="muscle-nutrition-layout"> <div class="muscle-nutrition-layout">
{#if ns} {#if ns}
<div class="lifetime-card protein-card"> <div class="lifetime-card protein-card">
@@ -262,7 +360,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)}
@@ -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)}
/> />