Commit Graph

223 Commits

Author SHA1 Message Date
Alexander 4ed0251bb4 feat(branding): adaptive Android launcher icon
CI / update (push) Successful in 3m45s
Split the logo into foreground/background layers so Android can
apply system masks (circle, squircle, teardrop) and parallax
instead of rendering a flat composited PNG.

- icons/logo_{foreground,background}.png: new canonical sources
- mipmap-*/ic_launcher_{foreground,background}.png: regenerated
  per density (108/162/216/324/432)
- mipmap-*/ic_launcher{,_round}.png: legacy pre-API-26 composites
- mipmap-anydpi-v26/ic_launcher.xml: background now points at
  @mipmap/ic_launcher_background instead of solid white @color
- mipmap-anydpi-v26/ic_launcher_round.xml: added so round
  launchers also get adaptive treatment
- drop unused @color/ic_launcher_background and the leftover
  Tauri-template drawable

Tauri app: 0.5.2 -> 0.5.3.
2026-05-02 15:21:50 +02:00
Alexander f02a11afd2 feat(branding): new app logo for Tauri + PWA install
CI / update (push) Successful in 4m26s
Replaced Tauri icon source (icon.png) with new 1024px wheat-stalk
mark on dark background. Regenerated all platform variants via
`tauri icon`: macOS .icns, Windows .ico, Linux PNGs, iOS AppIcon
set, Android adaptive icon foregrounds.

Web: PWA manifest icons (192/512) and apple-touch-icon now use the
new logo. Browser-tab favicon (favicon.svg) unchanged — keeps the
theme-aware wheat mark.

Tauri app: 0.5.1 → 0.5.2.
2026-05-02 14:45:17 +02:00
Alexander eb9d7a17b3 feat(favicon): single theme-aware SVG, drop legacy raster fallbacks
favicon.svg now uses currentColor + prefers-color-scheme so a single
asset adapts to light/dark. Removed unused .ico/.png and 512 raster;
192 PNG kept and regenerated for apple-touch-icon and PWA maskable.
2026-05-02 14:28:24 +02:00
Alexander 2e8685d02b style(recipes): unify custom multiplier pill with preset pills
CI / update (push) Successful in 5m48s
Make the custom-multiplier pill behave and look like a single input zone:

- Wrapper is now a <label> so clicking anywhere focuses the input.
- Replace the explicit \"x\" submit button with a passive <span> suffix and
  add a visually-hidden first-tree-order submit so no-JS Enter still
  submits with the typed value (rather than the first preset pill's value).
- Wrapper cursor: text end-to-end, no pointer flicker.
- Hover/focus selector now matches the wrapper alongside the preset
  buttons, and an isCustomMultiplier flag highlights the pill in primary
  whenever a non-preset value is active (e.g. ?multiplier=12).
- Input uses field-sizing: content (with min/max) so the pill collapses
  to fit the placeholder.
- align-items: center (was baseline) so the input doesn't sit high in
  its pill.
- Tighten the multipliers row (gap 0.5rem -> 0.3rem, button min-width
  2em -> 1.8em, matching paddings) so all six pills fit on one line in
  the ingredients column.
2026-05-01 14:50:13 +02:00
Alexander bcdb9a9c4b refactor(recipes): split base + cake-form multipliers
Cake-form scaling no longer overwrites the base multiplier (pill buttons
+ custom input). Both factors stay independent and compose as
effectiveMultiplier = multiplier * formMultiplier, which feeds ingredient
amounts, portions, nested-recipe links, HefeSwapper, and NutritionSummary.

Pills reflect the base only; the existing cake-form badge keeps showing
the form factor whenever it deviates from 1, so the two contributions
stay visually distinct. Drop the formDriven flag, the effect that wrote
formMultiplier into multiplier, and the now-redundant
oninput=applyFormMultiplier hooks (bind:value already triggers
recomputation). resetCakeForm only resets form fields now.
2026-05-01 14:27:21 +02:00
Alexander dbce9629a5 fix(recipes): coerce season month to string for resolve()
resolve() requires string params; season[0] is a number, which made
param_value.startsWith blow up on /[recipeLang]/[name] pages.
2026-05-01 14:20:24 +02:00
Alexander 79f4dbb101 i18n(common): bootstrap shared namespace + migrate top-level UI
Add a per-locale common dictionary at src/lib/i18n/common/{de,en}.ts and
the shim src/lib/js/commonI18n.ts. Migrate inline lang ternaries on the
homepage (welcome/sections/links), OfflineSyncButton (all label
ternaries), DatePicker (today/select date), ErrorView (Error/Fehler
eyebrow), and UserHeader (login aria/title) to use the shared dict.

The long marketing intro paragraphs on the homepage stay inline since
they're one-shot content with no drift risk and don't benefit from
per-key extraction.

Bump site version to 1.57.0 (new namespace).
2026-05-01 14:03:52 +02:00
Alexander 71f7322624 i18n(fitness): migrate inline ternaries across pages and components
Replace lang === 'en' string ternaries on the check-in, stats, workout,
exercises, history, and stats history detail pages, plus TemplateCard,
with t.<key> lookups against the fitness dictionary. Added new keys for
toast messages, body-part counts, body-fat label, clear/measure short
labels, "edit all fields", BF chart delta prefix, calorie balance and
adherence tooltips, actual/target legend labels, daily expenditure
prefix, height/birth/weight setup hint, exercise/workout/recent labels,
"starts with", and a {n}-template "X days ago" string.

URL slug ternaries (e.g. 'check-in' / 'erfassung') remain inline since
they encode route data, not UI text.

Bump site version to 1.56.2.
2026-05-01 14:01:06 +02:00
Alexander bd9e9b397f i18n(recipes): finish remaining ternaries across components and pages
Migrate FavoritesFilter, IconFilter, TagFilter, FilterPanel, HefeSwapper
and the offline-shell, season/[month], icon/[icon], favorites, search,
tips-and-tricks, and index pages to use the recipes i18n dictionary.
Add corresponding keys for filter toggles, filter placeholders, yeast
toggle title, recipes-growing suffix, search "for" preposition, and
favorites count labels. Strip unused isEnglish derivations from layout,
tag, and category landing pages.

Bump site version to 1.56.1.
2026-05-01 13:54:41 +02:00
Alexander ea1a85e935 i18n(recipes): migrate 13 pages and components
Bulk migration of the recipes namespace following the same pattern as
fitness/cospend/calendar/faith. Layout collapses its label-object into
t.foo lookups; NutritionSummary's 33 ternaries (incl. the
German-stem-plus-optional-e amino-acid pattern that read
`Lysin{isEnglish ? 'e' : ''}`) become straight dictionary references;
AddToFoodLogButton, IngredientsPage, to-try, search, favorites,
the index, and the small landing pages (category, tag, season, icon,
tips-and-tricks) all migrate the same way.

The recipes dict is now ~120 keys. Patterns kept intentionally:

  - Long page-specific marketing copy (subheading sentences, meta
    descriptions that include dynamic counts, hero alt text variants)
    stays inline as `lang === 'en' ? '...' : '...'` rather than
    bloating the dict with one-shot strings.
  - URL slug ternaries stay inline — those are URL data, not UI text.
  - The `recipes/admin/nutrition` page was deliberately skipped — admin
    tooling, ~18 ternaries that are mostly admin-jargon strings used
    in exactly one place.

Detail pages ([name]/+page, [name]/+error, IngredientsPage extras,
InstructionsPage, smaller components) and the admin page remain for
follow-up commits.
2026-05-01 13:34:44 +02:00
Alexander d7f96f35c2 i18n(faith): migrate prayers index + prayer detail
Adds prayer-name keys (sign_of_cross, pater_noster, fatima_prayer, …),
search/filter UI labels (search_prayers, clear_search, filter_by_category,
all_categories), the eastertide_badge, and the prayer-detail-only
nicene_creed / hail_mary aliases (German + Latin keep the Latin form,
English uses the English name).

Prayers index labels object collapses each name ternary into a t.foo
lookup; the language-invariant ones (Glória Patri, Credo, Ave Maria,
Salve Regina, Glória, Ánima Christi, Tantum Ergo, Angelus, Regína Cæli)
stay hardcoded as single strings since they're identical across all
three locales. The baseUrl building now uses faithSlugFromLang/prayersSlug
helpers instead of inline ternaries.

Prayer detail's prayerDefs routing table — every name field that was
isEnglish ? a : b now points at a t.* lookup. Painting captions for
the Velázquez/Murillo Angelus/Regina Cæli backgrounds become
t.painting_coronation_virgin / t.painting_annunciation. The
AngelusStreakCounter call site drops its three-way ternary in favor of
the typed `lang` derived value.

Slug-table ternaries (URL slug per locale) and the long gloriaIntro
paragraph are intentionally left inline — slugs are URL data, not UI
text, and gloriaIntro is page-unique marketing copy that doesn't
benefit from being in a shared dict.
2026-05-01 13:16:47 +02:00
Alexander 3dcb5c7f2b i18n(faith): migrate streak components, BibleModal, katechese notices
Adds streak/angelus and Bible-modal keys to the faith dictionary, plus
the three-fragment "this catechesis is only available in German" notice
used by both katechese pages. Pluralization for day/days handled by two
explicit keys (day_singular/day_plural) chosen at the call site —
Latin's "Dies" is invariant so both keys hold the same string.

StreakCounter and AngelusStreakCounter collapse their per-component
labels objects into direct t.foo lookups; the rosary page's BibleModal
call site now passes the typed `lang` derived value (was data.lang as
plain string, didn't satisfy the tightened FaithLang prop type).

BibleModal isn't actually used in Latin context, but the dict requires
every key in every locale, so reasonable Latin equivalents got filled
in for completeness.
2026-05-01 13:10:29 +02:00
Alexander 28b96a8dc0 feat(i18n): bootstrap faith namespace + migrate layout, homepage, apologetik
Three-locale faith dictionary lands at src/lib/i18n/faith/{de,en,la}.ts
with the same satisfies-based completeness enforcement we use for
fitness, cospend, and calendar. faithI18n.ts is the slim shim — exports
m, FaithLang, FaithKey, plus the URL-slug helpers (langFromFaithSlug,
faithSlugFromLang, prayersSlug, rosarySlug, calendarSlug, apologetikSlug)
needed because faith routes do bidirectional slug ↔ locale mapping that
the other namespaces don't.

[faithLang]/+layout.svelte and +page.svelte fully migrated. The
isEnglish/isLatin derived flag dance collapses into a single typed
`lang`; ten inline ternaries per file (display labels and slug
selection) become t.key lookups or slug-helper calls. The "DE" badge
condition for non-German faith locales tightened from
`isEnglish || isLatin` to `lang !== 'de'`. Apologetik latin-fallback
hops through the helpers instead of inline matchers.

Apologetik pages get the shared-label cut: all four pages (contra,
contra detail, pro, pro detail) now use t.objections, t.evidences,
t.alex_pick, t.objection_label, t.answered_by, t.voices_answering,
t.arguments_title, t.positive_case from the dict. Page-specific
marketing copy (the per-page heading/lede/eyebrow object literals)
stays inline — those strings live in exactly one place each, the
structure is already readable, and pulling them into a shared dict
would be noise.

Also: ImageUpload.svelte was the one stray cospend t() caller the
earlier codemod missed (it lives at lib/components/, outside the
codemod's --root scope). Now uses t.key with `as CospendLang` cast.
2026-05-01 13:01:25 +02:00
Alexander 3347619816 refactor(i18n): split cospend + calendar per-locale, adopt t.key syntax
Cospend translations move to src/lib/i18n/cospend/{de,en}.ts with
satisfies-based key-set enforcement, mirroring the fitness layout
shipped earlier. cospendI18n.ts becomes the same kind of slim shim
exporting m, CospendLang, CospendKey while keeping every existing
helper (detectCospendLang, paymentCategoryName, splitDescription,
formatNextExecutionI18n, etc.) on the same surface.

Calendar gets the same treatment but with three locales (de/en/la)
and two namespaces — `ui` and the rite-1962-specific `ui1962`.
calendarI18n.ts now imports both as m / m1962, types them as
CalendarKey / Calendar1962Key, and routes t() / t1962() through
them. The 1962 fallback is per-namespace dir with file-prefixed
locale files (de_1962.ts etc.) so they can co-exist.

19 cospend route/component files and 3 calendar pages migrated to
the t.key / t1962.key syntax. Two notable hand fixes: UsersList.svelte
needed `as CospendLang` because the `lang` prop default uses an `as`
cast that breaks TS narrowing of m[lang]; and a sed pass converted
codemod-emitted t['camelCase'] to t.camelCase since the static-key
regex initially only matched snake_case.

The split + codemod scripts are now generic — split-i18n.ts takes
namespace, locales, optional marker and basename for multi-table
modules; codemod-i18n-t-to-m.ts takes module basename, fn name, and
m alias name (so t1962 / m1962 share the same machinery as t / m).
The fitness-specific one-shots are deleted, superseded.
2026-05-01 12:47:46 +02:00
Alexander ac05367ee4 refactor(fitness): adopt t.key / t[expr] syntax across fitness pages
22 files migrated from t('key', lang) function calls to direct lookups
on a derived dictionary alias: const t = $derived(m[lang]) once per
file, then t.start_period or t[card.labelKey] at the call sites.

Cleaner read at the point of use, one less argument threaded through,
and TypeScript narrows on every key access (so a typo in a literal
key now errors at the call site, not silently falls back to the key
string).

The codemod handles both ways `lang` can enter scope — derived from
the URL via detectFitnessLang, or destructured from $props() (single
or multi-line). One file aliased the i18n table to `messages` to
avoid collision with a local `const m = data.measurement`.

The deprecated `t(key, lang)` function still exists in fitnessI18n.ts
for any remaining out-of-tree call sites — can be deleted once
nothing imports it.
2026-05-01 12:25:49 +02:00
Alexander 609405da81 refactor(i18n): split fitness translations into per-locale files
The fitness UI translation table previously lived as one combined
object in fitnessI18n.ts where every entry held both languages. That
hides drift (an English string can silently disappear without TypeScript
noticing) and makes adding strings a multi-edit dance.

Split into src/lib/i18n/fitness/{de,en}.ts. de.ts is the source of
truth for the key set; en.ts uses `as const satisfies
Record<keyof typeof de, string>` so any missing English translation is
a build-time error. fitnessI18n.ts now re-exports both as a typed
table m and adds FitnessLang/FitnessKey types — the existing
t/fitnessSlugs/fitnessLabels API stays so call sites don't churn.

The strict typing immediately surfaced one real bug: t('initializing_gps')
was being called from the active workout page but the key never existed
in the dictionary, so it had been rendering the literal string
'initializing_gps' through the fallback. Added the missing key in both
locales.

Tightened BodyPartCard.labelKey and the body-parts Step JSDoc to
FitnessKey instead of plain string so card data drift catches drift at
the data site, not the call site. Two dynamic-key sites (partKeyMap
fallbacks for unmapped measurement keys) are cast pragmatically.

The 360-entry split was done by a one-shot extraction script
(scripts/split-fitness-i18n.ts) — kept for re-use against
cospendI18n.ts and calendarI18n.ts in follow-up commits.
2026-05-01 12:15:27 +02:00
Alexander c521a9ec68 feat(fitness/period): long-press calendar day to start a period
Holding any past or current calendar cell (outside an existing period
record and unless one is already ongoing) for 600ms now opens a
confirmation dialog and starts a period on that day. Same POST as the
button-driven start; just a faster gesture for back-dating today or
yesterday.

Implemented as an inline {@attach longPress(handler)} attachment that
cancels on >8px movement, suppresses iOS contextmenu, and respects
pointer cancel/leave. The held cell scales 1.18× with a growing red
ring and rounded pill border for visual feedback (reduced-motion
falls back to a static ring). Eligibility is gated client-side
(canStartOn): no read-only mode, no projection-only mode, no future
dates, and no overlap with the current period.
2026-04-30 19:19:20 +02:00
Alexander 936c59debc refactor(fitness): use:action → {@attach}, harden streamed-data error paths
Two custom Leaflet actions converted to attachments: renderMap is now
a factory returning an attachment, mountMap is the attachment itself.
Four call sites updated. use:enhance left alone — still the canonical
SvelteKit form-action API.

The stats page's three streamed Promise.resolve(...).then(...) chains
now log on rejection instead of silently swallowing errors. The muscle
heatmap {#await} block gained pending and catch branches with a
lang-aware error message.
2026-04-30 19:13:06 +02:00
Alexander d8abcbf74b refactor(hooks): move server bootstrap into ServerInit hook
Module-level top-level await for db/scheduler init and the cache
warmup IIFE move into the canonical export const init hook. Same
ordering and non-blocking semantics; makes the lifecycle explicit
and works on environments without top-level await.
2026-04-30 19:07:42 +02:00
Alexander 4ad218cc39 i18n(apologetik): rename 'Alex's choice' chip to 'Alex's pick'
CI / update (push) Successful in 4m12s
English label and variable name now match the existing ALEX_PICKS
data convention. German keeps 'Alex' Wahl' (the natural translation).
Latin updated to 'Alexandri delectus' to mirror the pick semantics.
2026-04-30 19:00:14 +02:00
Alexander 3cd2a678a6 refactor: $app/stores → $app/state, legacy stores → runes
Codemod-driven migration of 55 .svelte files from the deprecated
$app/stores module to the rune-based $app/state ($page.x → page.x,
no auto-subscription wrapper). Two custom writable() stores converted
to .svelte.ts factory functions matching the existing theme store
pattern, with consumers updated to use .value getters and the explicit
.set() method.

UserHeader.svelte's login link now guards page.url.search behind
the browser flag — search-param access throws during prerender, and
this defensive change unblocks future prerender adoption on any page
that includes the header.
2026-04-29 22:31:16 +02:00
Alexander e5d218820b refactor: migrate hrefs to resolve()/asset() from $app/paths
Replace string-literal and template-literal hrefs across the codebase
with the modern SvelteKit 2.26+ resolve() and asset() APIs. Migration
makes route IDs explicit, type-checked against generated $app/types,
and base-path-aware. Two codemod scripts handle the bulk; remaining
ambiguous, query-bearing, and precomputed-href cases are converted
manually at the assignment sites.
2026-04-29 22:14:29 +02:00
Alexander 70506e169a feat(faith/apologetik): voice routing + Alex's choice chip
- contra/pro detail pages move from #voice-X hash to /[argId]/[archId]
  (and /[posArgId]/[voiceId]) optional path segments. SSR renders the
  selected voice directly — no hydration flash on deep links.
- Tab onclick uses replaceState to update path without a load roundtrip.
- Add Alex's choice chip on contra detail tabs: small circular pfp on
  picks, expanded label on the active tab. ALEX_PICKS map per argument.
- Answer-rail pills on contra index extend past 760px column into the
  right viewport gutter when space allows; wrap otherwise.
2026-04-29 21:32:02 +02:00
Alexander 538b70d139 i18n(apologetik/pro): translate scripture-prophecy to German
CI / update (push) Successful in 3m16s
Sync DE translation with current EN content for the 11th pro argument:
expanded claim/thesis (typology as test case), full Akedah meditation in
the hahn voice, canonical-shape argument in wright, doctrinal-development
expansion in newman. Also fixes the canon count from 66 to 73 books to
match the Catholic canon used elsewhere.
2026-04-28 21:36:09 +02:00
Alexander 58247dab89 style(faith/apologetik): pulse rings + larger labels in cumulative svg
CI / update (push) Successful in 1m24s
- Numbers move to left of dots (text-anchor end).
- ViewBox widened (W 700→820, H 240→320) so the converge label fits and
  bigger fonts/dots have breathing room.
- Strand thickness, dot/orb radii, and label font sizes bumped.
- Replace static rings with two pulse-out ripples (4.8s period, 2.4s
  offset) emanating from the orb; reduced-motion falls back to static.
2026-04-28 21:29:12 +02:00
Alexander f7ae3f20af chore: drop unused CSS selectors flagged by svelte compiler
- zehn-gebote: orphan `ul` rule left over from inline-toc removal.
- fitness/active: orphan .exercise-header*, .move-exercise*, .remove-exercise*,
  .add-exercise-btn rules left over from rail/focus refactor.
2026-04-28 21:24:16 +02:00
Alexander 71196c8b4b feat(faith/apologetik): add apologetics route
CI / update (push) Successful in 3m50s
Adds the entire /<faithLang>/{apologetik,apologetics} section:

- Landing page introducing the contra/pro split with shield/flame cards.
- Contra (objections): 23 objections, each answered by multiple archetype
  voices (Aquinas, Pascal, Augustine, Lewis, Chesterton, plus Logician,
  Mystic, Scientist, Pastor archetypes); index + per-argument detail pages
  with archetype filter and inter-argument navigation.
- Pro (positive case): 12 arguments across three layers (supernatural,
  theism, christianity) voiced by Habermas, Polkinghorne, Newman, Hart,
  Lewis, Wright, Hahn, Plantinga, Eliade, Feser, Chesterton, Guénon;
  cumulative-case visual + per-argument detail pages.
- DE/EN content via per-language data modules; LA stub layout 307-redirects
  to English.
- Per-language slug via apologetikSlug matcher; canonical-slug enforcement
  redirects mismatches.
- Shared ApologetikToc component (also reused on zehn-gebote katechese).
- CaseTabs component for contra/pro switching.
- DeepL translation script for regenerating DE data from EN source.
- Server-side scripture lookup helper.
2026-04-28 20:43:40 +02:00
Alexander ce42d70741 feat(fitness/measure): theme-adaptive waist svg
CI / update (push) Successful in 4m0s
Replaces static PNG with currentColor-stroked SVG matching
forearm/thigh pattern for light/dark theme adaptation.
2026-04-24 18:16:15 +02:00
Alexander e7293ac496 feat(fitness/active): rail + focus card layout
Redesign the active-workout page around a left-rail timeline and a
focus card on the right. The rail owns the workout title, pause,
elapsed time, sync indicator, progress bar, and a reorderable chip
per exercise (drag to reorder, × to delete, starting-weight hint so
you know what to rack, green checkmark when complete). Main stage
holds a hero focus card for the active exercise plus its SetTable.

- New WorkoutRail.svelte and WorkoutFocusCard.svelte
- Active exercise pinned to top of the scrollable rail (mobile only)
- Desktop: rail grows freely; mobile: compact vertical stack
- Finish + cancel share one row; cancel is a ghost action
- Drop the old sticky bottombar; its controls moved into the rail
- ExerciseName gains `plain` prop to opt out of the detail link
- Active workout route joins the 1400px max-width whitelist
2026-04-23 22:35:05 +02:00
Alexander 86ff4c5953 style(fitness/workout-fab): floating glass pill matching header
Redesign the active-workout footer as a floating glass pill that
mirrors the site header — same backdrop-blur recipe, same token set,
anchored to the bottom instead of the top. Inner controls recomposed:
icon-only pause button, dominant elapsed time, subtle divider, label +
chevron or rest-timer pill. Mount animation, hover-lift, reduced-
motion fallback.
2026-04-23 21:43:04 +02:00
Alexander 504a6f410f style(fitness/exercises): wrap muscle filter in card, widen layout
Align the muscle picker with the site card language (matches
/fitness/check-in and /fitness/stats) and unlock the full desktop
width via the 1400px container used by nutrition/check-in.

- Sidebar card layout at ≥900px (200/620 grid, sticky)
- Larger sidebar at ≥1180px (460/720) with figures uncapped
- Tablet tier (900–1179px) stacks figures vertically inside the card
- Below 900px the card sits on top of the content column
2026-04-23 21:33:05 +02:00
Alexander c73363e93d fix(shopping/loyalty): emit FNC1 codewords for Supercard
Coop Supercard uses GS1 Data Matrix — the payload contains FNC1
separators between fields, not literal 0x1D bytes. Enable bwip-js
parsefnc so ^FNC1 placeholders in the env value produce genuine
FNC1 codewords (codeword 232), matching the physical card and
letting checkout scanners parse the GS1 element string.
2026-04-23 21:04:56 +02:00
Alexander 43ea2cca22 style(shopping/loyalty): split buttons, enlarge barcodes
Replaces the single card button with two brand-colored buttons
(Coop blue, Migros orange) that each open only their own card.
Modal now wears the brand gradient directly, drops the red cross
close button pattern from BibleModal, and scales the Data Matrix
+ linear barcode to fill the modal on phones for easy scanning.
2026-04-23 16:39:54 +02:00
Alexander 0ab98690eb feat(shopping): loyalty-card modal with build-time barcodes
Adds a CreditCard button on the shopping list that opens a modal
showing the user's Coop Supercard (Data Matrix) and Migros Cumulus
(Code 128). Card numbers come from SHOPPING_COOP_SUPERCARD_NUMBER
and SHOPPING_MIGROS_CUMULUS_NUMBER env vars; a prebuild script
renders each to an SVG (~1-2 kB) in static/shopping/ so no barcode
library ships to the client. Cards missing their env var are
silently skipped, and the generated SVGs are gitignored to keep
personal numbers out of the repo.
2026-04-23 16:21:15 +02:00
Alexander a8b0d3c722 style(recipes): swap heart emoji for lucide icon
Replaces heart/black-heart emoji on the favorite button and the
card favorite indicators with the lucide Heart icon. Favorited
state uses a vivid #ff2d55 fill with layered drop-shadows so the
mark reads against colorful recipe photos; unfavorited button
shows an outlined white heart.
2026-04-23 16:05:19 +02:00
Alexander b8e5155e2d style(header): replace Login text with lucide icon
Logged-out users now see a LogIn icon in the header nav pill instead
of the "Login" text link. Avatar button still shown when logged in.
2026-04-23 16:00:05 +02:00
Alexander 8c75a2ddda style(fitness/period): make end-period button full-width
Flat nord11 fill, full-width CTA with check icon and uppercase label
replaces the muted tertiary-bg pill so ending an ongoing period is
the clear primary action on the status card.
2026-04-23 15:57:03 +02:00
Alexander c01dff197f perf(recipes/search): memoise per-recipe normalized search string
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.
2026-04-23 15:50:08 +02:00
Alexander 03875f2be6 perf: add Cache-Control to stable recipe & fitness API endpoints
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.
2026-04-23 15:46:04 +02:00
Alexander 87bf5d100e perf(fitness/history): slim session list projection
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.
2026-04-23 15:40:27 +02:00
Alexander 076c6efb38 perf(faith/calendar): trim yearDays, send pre-filtered feastDots
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.
2026-04-23 15:37:38 +02:00
Alexander 4112e38306 perf: add projection + O(1) bucket math to muscle-heatmap endpoint
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).
2026-04-23 15:31:53 +02:00
Alexander 0da3b130e4 fix(fitness/stats): wrap streamed muscle heatmap in {#await}
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.
2026-04-23 15:30:05 +02:00
Alexander bb0895c9b5 perf: stream secondary panels on fitness stats page
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.
2026-04-23 15:15:29 +02:00
Alexander c912afd46a perf: reuse locals.session from hook in all remaining routes
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.
2026-04-23 15:08:10 +02:00
Alexander 800a544190 perf: reuse locals.session from hook instead of re-awaiting locals.auth()
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.
2026-04-23 15:06:05 +02:00
Alexander dfeeeb5fdf perf: drop all_brief fetch from favorites page
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
2026-04-23 15:03:39 +02:00
Alexander eb3604f9ea perf: drop redundant JSON.parse(JSON.stringify()) in recipe API
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.
2026-04-23 15:00:37 +02:00
Alexander 3b4318206d perf: dynamic-import chart.js in FitnessChart
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
2026-04-23 14:56:19 +02:00
Alexander cf3fe84d95 chore: remove unused /measure-mock route 2026-04-23 14:53:50 +02:00