Every keystroke the filter rebuilt the lowercased + diacritic-stripped
+ soft-hyphen-stripped concat of name/description/tags per recipe. For
a 200+ recipe catalogue that's a lot of regex work on the hot path.
Cache the normalised string in a WeakMap keyed by the recipe object;
first keystroke still pays the full cost, every subsequent one is a
single indexOf per recipe.
Picked client-side memoisation over the audit's suggested server-side
`_searchKey` to avoid duplicating every recipe's text over the wire.
rand_array seeds with Math.floor(time / 86400000), i.e. the same
shuffle for every caller during a UTC day — so every list endpoint
that runs through it is safe to share publicly:
- /items/all_brief, /items/category/[c], /items/tag/[t],
/items/icon/[i], /items/in_season/[m]
→ public, max-age=28800 (8h), s-maxage=28800, SWR=1d
The distinct-value lists (no shuffle, change only on recipe edit):
- /items/category, /items/tag, /items/icon
→ public, max-age=3600 (1h), s-maxage=86400 (1d), SWR=1w
Individual recipes change when their author edits them:
- /items/[name]
→ public, max-age=300 (5m), s-maxage=3600 (1h), SWR=1d
Fitness exercise-picker filters are identical for every logged-in
user but require auth:
- /fitness/exercises/filters
→ private, max-age=3600
Skipped the calendar page itself: its HTML embeds data.session via the
faith layout's <UserHeader>, so public caching would leak identity.
List endpoint previously returned full session documents minus GPS
tracks — two months × up to 200 sessions means ~60 KB of payload per
month with a lot of fields (notes, templateId/Name, mode,
activityType, endTime, session-level gpsPreview) that SessionCard
never reads.
Narrow the projection to exactly what the history page + SessionCard
use, and switch the query to .lean() so we skip the Mongoose document
overhead on deserialisation.
Detail view (/fitness/history/[id]) hits a separate endpoint that
keeps the full document.
yearDays was a 365-entry array (one per day in the LY window) with
{iso, name, rank, color, seasonKey} on each — the client only needed
the color (for the needle pin on the currently-selected day; RingView
re-did the feast filter itself). Split into:
- yearDays: {iso, color} — unchanged count, but ~60% smaller per entry
(drops name, rank, seasonKey)
- feastDots: {iso, name, rank, color} — new, pre-filtered to
rank > ferial server-side (~150 entries instead of 365)
RingView's `feastDots` derivation shrinks to filtering out just the
currently-selected day, and `activeFeasts` filters `feastDots` by arc
bounds instead of re-scanning yearDays. needleDay's color lookup still
works with the trimmed YearDay.
Also collapses a stray `locals.session ?? (locals.session ?? …)` the
earlier #5 sweep introduced in both calendar page loaders.
Endpoint previously pulled full WorkoutSession documents (including
gpsTrack, notes, kcalEstimate etc.) to count sets per muscle group.
Adds a projection that keeps only startTime + exercises.exerciseId +
whole set objects — safe (avoids the malformed-sub-array issue the
earlier narrower projection caused in the stats overview handler),
but still drops the bulky session-level fields.
Also swaps the per-session findIndex() over the weekly bucket array
for direct date-math against the first bucket's Monday, turning
bucket lookup from O(sessions × weeks) into O(sessions).
The $state + $effect pattern I used for the muscle heatmap in
bb0895c didn't propagate the streamed promise into the component's
internal $derived(data.totals) chain — the hover counts stayed at
zero even after the data arrived.
Switch just the heatmap to an {#await} block so it mounts once with
the fully-resolved object. The nutrition card shells, periods, and
shared periods keep their $state pattern because the card templates
read individual fields directly (which gracefully fall through to
the "—" branches while pending) and re-rendering once the value
arrives is fine.
Also drops the two reverted commits for the set-subfield projection
(4d1fed6, fe8d036); those are replaced later with a safer narrowing
that keeps whole set objects.
Four panel fetches (muscle heatmap, nutrition stats, own periods, shared
periods) are now returned as unawaited promises from load() and resolved
into $state-backed locals on the client via $effect. The load function
keeps awaiting only stats/goal/latest since the main charts, goal
header, and body-part cards depend on them immediately.
Rationale for the $state-backed resolution rather than {#await}: the
user wants the nutrition card shells and the muscle heatmap container
to render their skeleton shape on first paint and only fill in the
numbers once the data arrives. Defaults (`{}`, empty heatmap, `[]`)
match the previous error-fallback shapes so the existing `!= null`
checks inside each card cascade naturally to the "—" branches while
the promise is in flight. No template restructuring beyond dropping
the outer `{#if ns}` (which already hid everything when null).
stats (overview) is intentionally still awaited: it feeds ~30 $derived
chart expressions and wrapping it would mean recreating every Chart.js
instance after the promise settles.
Extends the previous loader-only sweep across the full tree: every
remaining `await locals.auth()` now falls back through
`locals.session ?? await locals.auth()`, so the hook's cached result
is reused.
68 files, 107 sites touched — loaders, form actions, and API
endpoints across cospend / tasks / fitness / faith / recipe / admin.
hooks.server.ts is intentionally left alone since it's the originating
call that populates locals.session in the first place.
hooks.server.ts already awaits auth() once and stores the result on
locals.session. In-scope loaders (recipe list + filter views, rosary,
prayers, calendar — already done — and fitness stats) were awaiting
locals.auth() a second time per request.
Switched to the existing `locals.session ?? await locals.auth()` pattern
so the hook's result is reused. Also pulls session out of Promise.all
legs since it's now synchronous when the hook ran.
Scope: loaders only — actions, /admin, /edit, /add intentionally skipped.
Favorites page fetched both /favorites/recipes and /items/all_brief, then
stitched isFavorite flags onto allRecipes so Search could filter across
the full catalogue. But Search is invoked with favoritesOnly={true} and
hideFavoritesFilter (so it's pinned on), so it only ever returns matches
that are already in the favorites list — allRecipes was dead weight.
- drop allRes / allRecipes / favoriteIds / allRecipesWithFavorites
- Search now receives data.favorites directly
- filteredFavorites filters data.favorites by matchedRecipeIds
- use locals.session ?? locals.auth() to reuse the hook's auth lookup
Every recipe list endpoint wrapped its result in
`JSON.parse(JSON.stringify(...))` before handing it to `json()`, which
then serialises again — a full extra stringify+parse cycle per response.
`lean()` already returns plain objects and ObjectIds/Dates serialise
correctly through `json()`'s single `JSON.stringify`, so the extra round
trip was pure waste.
Removed from the 9 output-side call sites (all_brief, category,
category/[cat], tag, tag/[tag], icon, icon/[icon], in_season/[month],
search, favorites/recipes, offline-db, translate/untranslated).
Kept the two deep-clone-before-mutation usages in items/[name] and
json-ld/[name] — those are load-bearing.
Shuffle stays server-side: moving it to the client would need a hero
preload + hydration rework that's bigger than a perf tweak.
Chart.js (~244 KB) was a top-level import, so every route that referenced
FitnessChart.svelte transitively pulled it. Defer it to an async block
inside onMount so non-stats fitness routes (workout, check-in, nutrition,
history list) no longer ship chart.js.
- `ChartCtor` holds the async-loaded constructor
- `disposed` guard handles unmount during the import
- theme MutationObserver / matchMedia wiring moved inside the async
block so it only attaches once the chart actually exists
Barrel `from '@lucide/svelte'` imports pulled every referenced icon into
one shared 748 KB client chunk. Switch every call site to per-icon
subpaths (`@lucide/svelte/icons/<kebab-name>`) so Vite tree-shakes each
icon independently. Also logs the TODO list for the perf audit so we
don't lose track.
- 46 files, 106 unique icons
- single `Minus as MinusIcon` alias preserved
- Lucide-internal aliases (`AlertTriangle`, `BarChart3`) resolve through
Lucide's own re-export shims; no behavioral change
Four pages had their own hardcoded `measureSlug = lang === 'en' ? 'measure' : 'messen'`
derived — all still pointing at the old route. Bumped the value to
check-in / erfassung and renamed the variable so future drift of
this kind is easier to grep for.
Affects links from:
- /fitness/check-in → body-parts card, inline "Edit all fields"
- body-parts flow → back / cancel navigation
- full-edit page → save / delete navigation
- /fitness/stats/history/[part] → "measure this now" CTA
The button was gated on `showWeightHistory`, which stays false on
desktop since the history-list uses CSS (`.collapsed` override at
≥1024 px) instead of the toggle. Move the gating to a `.collapsed`
class on the button too, mirroring the list — hidden on mobile until
the user expands, always visible on desktop.
Body-measurement variation of <4 cm used to stretch the full chart
height, making normal weekly noise look dramatic. Now the y-axis
enforces a 4 cm floor centered on the data's midpoint; wider swings
render at their actual range as before.
- FitnessChart: new optional `yMin` / `yMax` props mapped to Chart.js
`suggestedMin` / `suggestedMax` — soft bounds, so data that exceeds
them still widens the axis.
- `/fitness/stats/history/[part]`: computes min/max across available
values (both sides if paired), enforces the 4 cm floor, passes to
FitnessChart. Tick distance stays on Chart.js auto — small ranges
get 0.5 cm ticks, wider ones scale up naturally.
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.
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%)").
- 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").
- 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).
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.
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.
- Lock +/- button positions by normalizing stepped weight/body-fat
values to .toFixed(1) so trailing zeros stay; placeholders also
normalized. Input width no longer jitters through a step sequence.
- Cap .history-section width on mobile/tablet to match .main-col
(480px / 760px) so "Past measurements" aligns with the metric cards.
- Body-parts page:
- Remove the "Running totals" list from the right panel.
- Hide the keyboard-shortcut legend by default; show on `?` (toggle)
or Escape (dismiss), with a small `?` pill hint in its place.
Added kbd_hint i18n string.
- Push skip + back/next toward the edges of the bottombar; pull
progress dots + close button inward symmetrically.
- Center the keyboard legend / hint on the screen width rather than
between the skip and nav buttons (position: absolute + translate).
- Weight + body fat cards share a unified .metric-card component with wheel
+ keyboard (Arrow/Shift+Arrow) stepping. Side-by-side on tablet and up.
- Replaced body-parts accordion with a prominent card showing a cropped
muscle-front silhouette and overlay dots/bands marking which regions
have measurements. Shoulders + chest render as dotted tape-measure
bands; other parts as dots. "Last measured" now relative (N days ago).
- Desktop layout: .main-col (form + period tracker) left, history on
right. Two columns center together at wider widths instead of drifting
apart. Fitness layout detects measure index and bumps max-width to
1400px, matching nutrition.
- Inline history edit: pencil swaps the row for a compact date/kg/%
form (Enter saves, Escape cancels) via PUT /api/fitness/measurements.
Full-edit link preserved for body-parts tweaks.
- Body-parts history heading renamed to "Past measurements" /
"Frühere Messungen" to avoid collision with the period tracker's
own history.
- "Profil bearbeiten" moved to the top-left of the main column.
- Same-sides toggle in the body-parts flow now uses the shared Toggle
component.
Adds a forearm SVG in the same currentColor-stroked style as thigh.svg and
wires it into both the body-parts wizard and BODY_PART_CARDS so the step
no longer falls back to the ruler placeholder. Also refreshes the hips PNG.
Side list now tints the selected row (theme-aware color-mix on text-primary
into surface; gold variant for today), caps at the ring's height via pure
CSS (absolute-positioned aside in a relative slot so the ring alone drives
row height), and auto-centers the selected item — falling back to the
closest-dated feast when the selection is ferial.
Tauri WebView sessions (and long-lived browser tabs) persist
hydrated load() data indefinitely, so server-side changes never
surface until the user manually navigates across a depends()
boundary. Wire visibilitychange + focus to invalidateAll(),
throttled to once per 5 min to keep expensive loaders cheap.
Android step detector silently returns no events on API 29+
when ACTIVITY_RECOGNITION is ungranted, so cadence was always
absent from recorded tracks. Declare the permission, request
it at GPS start, guard sensor registration and retry it from
MainActivity.onRequestPermissionsResult when the user grants
mid-session, and toast a hint if they deny.
Export each cardio exercise's stored GPS track from the history
detail page. Cadence is emitted per-point via Garmin's
TrackPointExtension v1 so Strava/Garmin Connect preserve it.
Filename: YYYY-MM-DD-<workout> <mins>min <Activity>.gpx.
The store-picker read localStorage at component init, which crashed
SSR on full-page loads of /cospend/list with 'localStorage.getItem is
not a function'. Deferred the read to onMount and wrapped writes in
try/catch.
Long-press modal on /cospend/list now lets you change the item's name
and quantity (e.g. "500g", "3x") alongside category and icon. The
quantity is re-prepended to the name so the existing parser keeps
picking it up.
Replace /1962 and /1969 with /vetus and /novus — matches how Catholics
actually refer to the missals (Vetus Ordo / Novus Ordo), reads the same
across de/en/la, and sidesteps the value-laden old-vs-new framing.
Rite pill labels flip to "Vetus" / "Novus"; the year stays visible in
the subtitle. Legacy year-slug URLs 307-redirect to keep bookmarks alive.
Romcal's liturgical scope emits LY N with a stale post-Pentecost tail
~3 weeks into December; dates from Advent I onward belong to LY N+1.
Month/ring views already shift — port the same rollover to the detail
page so Dec 1–20 stop showing "After Pentecost" data and Dec 21–31 stop
404'ing.
Server runs from build output dir where CWD-relative `static/*.tsv`
misses — adapter-node ships static assets at build/client/. New
resolveStaticAsset() helper uses import.meta.url to find the bundled
location, falls back to <cwd>/static/ in dev.
Fixes ENOENT on drb.tsv/allioli.tsv after deploy.
The projection gate required the date to be strictly after today,
so the current day never showed a projected burn even before any
workout had been logged. Loosened to >= today and removed a
now-duplicate isTodayOrFuture/today declaration introduced by the
earlier round-off flicker fix.
PeriodTracker gains an optional mode prop ('entry' | 'projection' |
'full') that gates which sections render. The measure page keeps the
full tracker for the user's own cycle (logging plus calendar). The
stats page now mirrors it in projection mode and is the sole home
for shared cycles, which used to clutter the measure page.
Unifies PNG and SVG body-part images behind a single CSS-mask
render path, so both now colorize with a --accent CSS variable.
Accent splits by measurement type: --blue for proportion parts
(chest, shoulders, waist, hips) and --nord8 for muscle parts
(neck, biceps, forearms, thighs, calves). Stats cards gain a
matching 8%-tint fill and accent-colored hover border. History
page header image enlarged. Thigh SVG stroke-width bumped to 11
for better mask legibility.
Placeholders and +/- fallback now use the most recent recorded
value per part; previously placeholders were hardcoded "—" and
+/- bumped from 0. Buttons step by 0.5 cm (manual input still
accepts 0.1 resolution).
Body-parts grid now lives on /fitness/stats, and per-part history
pages moved from /fitness/measure/history/<part> to
/fitness/stats/history/<part>. Measure page keeps weight/BF entry
and the body-parts measure launcher.
Replaced the auto-selecting $effect that was clobbering manual slot
picks with explicit init in onMount and advancement inside pray().
Selecting lunch/evening after praying morning now works.
- LanguageSelector: add kalender/calendar/calendarium mappings so swapping
language from /glaube/kalender/... produces /faith/calendar/... instead
of the broken /faith/kalender/... URL.
- HeroCard: move margin-bottom off the anchor so the plain (1969) variant
keeps the same bottom spacing as the linked (1962) variant.
- Calendar overview: omit detail href on 1969 so the hover chevron /
hover elevation don't appear when no detail page exists.
- Detail route: 404 any /detail/... under the 1969 rite — only 1962 has
day-detail pages.
- Auto-fill missing vernacular propers from Allioli (DE) or DRB (EN)
when the 1962 missal bundle lacks a translation, mapped per Latin slot
via romcal's scriptureRef blocks (compound refs split 1-to-1 when
segment count matches slot count).
- Strip Psalm superscriptions and trailing periods so lookups parse and
Bible text aligns with the Latin antiphon.
- Localize the section reference header (Marc → Mk, Vulgate→Hebrew
psalm shift for DE) instead of showing raw Latin.
- Add Latin / Parallel / Vernacular view toggle with localStorage
persistence; hide Allioli/DRB badge in Latin-only view.
- Latin column now takes primary text color; vernacular secondary,
matching the Prayer.svelte convention.
Removes decorative route-label h1s across fitness, recipe and cospend
pages — replaced with sr-only h1s for assistive tech and a shared
.sr-only utility in app.css. On the measure page, the tucked-away
profile chip becomes a dismissible setup banner that only appears
when sex/height/birth year are missing, with a permanent "Edit profile"
link at the foot of the page.
Macro split now always renders (faded rings + hint when no food logged),
and the calorie balance hint distinguishes missing demographics/weight
from missing food-log data with a warning style.
ActionButton now renders as <a> (href) or <button> (onclick), so
SaveFab wraps it to inherit the shake/hover/focus behavior already
used by AddButton/EditButton. Body-parts review replaces its inline
save button with SaveFab for consistency.