29 Commits

Author SHA1 Message Date
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 38330d7020 docs: tighten #10 summary in TODO 2026-04-23 15:46:39 +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 ff6a7ce01a docs: mark #9 done in TODO 2026-04-23 15:40:37 +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
Alexander abb59f46a6 perf: Lucide subpath imports to split 748 KB icon chunk
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
2026-04-23 14:52:39 +02:00
Alexander ebc59cbf6b fix(fitness): update slug + rename measureSlug → checkinSlug
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
2026-04-23 14:31:41 +02:00
Alexander 934d0d981b fix(fitness/check-in): show "Show more" button in desktop 2-col layout
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.
2026-04-23 14:24:02 +02:00
Alexander 5638913b1d feat(fitness/stats): 4 cm minimum y-axis range on body-part history charts
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.
2026-04-23 14:21:47 +02:00
157 changed files with 2803 additions and 850 deletions
+3
View File
@@ -12,6 +12,9 @@ vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# USDA bulk data downloads (regenerated by scripts/import-usda-nutrition.ts)
data/usda/
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
static/shopping/supercard.svg
static/shopping/cumulus.svg
src-tauri/target/
src-tauri/*.keystore
# Android: ignore build output and caches, track source files
+21 -1
View File
@@ -1,5 +1,21 @@
# TODO
## Perf (audit 2026-04-23)
Order = impact. Font items + app.html preload intentionally skipped.
- [x] 1. Lucide subpath imports — convert `from '@lucide/svelte'` barrel imports to `@lucide/svelte/icons/<kebab-name>` so Vite tree-shakes per-icon (current 748 KB shared chunk)
- [x] 2. Chart.js dynamic import in `FitnessChart.svelte` (drop 244 KB from non-stats fitness routes)
- [x] 3. Recipe API endpoints — drop `JSON.parse(JSON.stringify(...))` double-serialize (9 endpoints). Client-side shuffle / cache headers deferred (would require rethinking hero preload + hydration)
- [x] 4. Favorites page — drop unnecessary `all_brief` fetch (verified Search uses `favoritesOnly` so `allRecipes` was redundant)
- [x] 5. Replace redundant `locals.auth()` with `locals.session` across all routes (68 files, 107 sites — loaders, actions, API endpoints)
- [x] 6. Stream fitness stats loader — muscleHeatmap, nutritionStats, periods, sharedPeriods now stream via `{#await}`. `stats` still awaited (too many chart $deriveds depend on it)
- [x] 7. Muscle-heatmap endpoint — add projection + O(1) bucket math. Overview already had a projection; set-subfield narrowing was attempted but reverted (returned malformed sets). Timeseries cap not feasible: totals are lifetime-scoped.
- [x] 8. Calendar payload trim — `yearDays` narrowed to `{iso, color}` (needle lookup only), new pre-filtered `feastDots` array carries feast-specific metadata. Also fixed a stray double `locals.session ?? (locals.session ?? …)` in both calendar page loaders.
- [x] 9. History sessions endpoint — projection narrowed to exactly what SessionCard reads (drops notes, templates, mode, endTime, session-level gpsPreview); added `.lean()`.
- [x] 10. `Cache-Control` headers: 8 h public on the shuffled recipe list endpoints (`all_brief`, `category/[c]`, `tag/[t]`, `icon/[i]`, `in_season/[m]`) — rand_array is seeded per UTC day, safe to share. 1 h public on distinct-value lists (`category`, `tag`, `icon`). 5 min public on recipe detail. `private 1h` on fitness `/exercises/filters`. Calendar page skipped (session serialised into layout HTML).
- [x] 11. Search — debounce was already 100 ms. Instead of a server-side `_searchKey` (would duplicate text over the wire), memoise per-recipe normalized string in a `WeakMap` on the client — built lazily, reused across every subsequent keystroke.
## 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.
@@ -7,7 +23,11 @@
[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?)
[x] 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?)
[ ] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
[ ] swap heart emoji on recipe favorites to lucide icon
[ ] coop and migros cards on shopping list for scanning
[ ] login icon from lucide in header
## Refactor Recipe Search Component
+3 -2
View File
@@ -1,11 +1,11 @@
{
"name": "homepage",
"version": "1.46.8",
"version": "1.48.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts",
"prebuild": "bash scripts/subset-emoji-font.sh && pnpm exec vite-node scripts/generate-mystery-verses.ts && pnpm exec vite-node scripts/download-models.ts && pnpm exec vite-node scripts/generate-loyalty-cards.ts",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@@ -37,6 +37,7 @@
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@vitest/ui": "^4.1.2",
"bwip-js": "^4.10.1",
"jsdom": "^27.2.0",
"svelte": "^5.55.1",
"svelte-check": "^4.4.6",
+9
View File
@@ -93,6 +93,9 @@ importers:
'@vitest/ui':
specifier: ^4.1.2
version: 4.1.2(vitest@4.1.2)
bwip-js:
specifier: ^4.10.1
version: 4.10.1
jsdom:
specifier: ^27.2.0
version: 27.2.0
@@ -1194,6 +1197,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bwip-js@4.10.1:
resolution: {integrity: sha512-I/cEPiXsu7dRCp78PpVY4gdIXmbH752n8dMC+DStM77XPkrzeathdYrjnZ/i/vZPIxXTUWc+JxgJ/MvbodqPLA==}
hasBin: true
cac@7.0.0:
resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
engines: {node: '>=20.19.0'}
@@ -2952,6 +2959,8 @@ snapshots:
buffer-from@1.1.2:
optional: true
bwip-js@4.10.1: {}
cac@7.0.0: {}
chai@6.2.2: {}
+62
View File
@@ -0,0 +1,62 @@
/**
* Build-time generation of loyalty-card barcode SVGs.
*
* Reads card numbers from env vars and writes static/shopping/supercard.svg
* + static/shopping/cumulus.svg. Skips cards whose env var is unset so the
* site still builds in environments without secrets.
*
* SHOPPING_COOP_SUPERCARD_NUMBER → Data Matrix (Coop Supercard)
* SHOPPING_MIGROS_CUMULUS_NUMBER → Code 128 (Migros Cumulus)
*
* Run: pnpm exec vite-node scripts/generate-loyalty-cards.ts
*/
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { toSVG } from 'bwip-js/node';
const HERE = dirname(fileURLToPath(import.meta.url));
const OUT_DIR = resolve(HERE, '..', 'static', 'shopping');
type CardSpec = {
envVar: string;
filename: string;
bcid: 'datamatrix' | 'code128';
scale: number;
parsefnc?: boolean;
};
const cards: CardSpec[] = [
// Coop Supercard uses GS1 Data Matrix with FNC1 separators between fields.
// Put ^FNC1 in the env value wherever the real symbol has a separator
// (dmtxread -G prints them as 0x1D); parsefnc: true turns each ^FNC1 into
// a genuine FNC1 codeword so the regenerated code matches the card.
{ envVar: 'SHOPPING_COOP_SUPERCARD_NUMBER', filename: 'supercard.svg', bcid: 'datamatrix', scale: 6, parsefnc: true },
{ envVar: 'SHOPPING_MIGROS_CUMULUS_NUMBER', filename: 'cumulus.svg', bcid: 'code128', scale: 3 }
];
mkdirSync(OUT_DIR, { recursive: true });
for (const card of cards) {
const value = process.env[card.envVar]?.trim();
const outPath = resolve(OUT_DIR, card.filename);
if (!value) {
try { rmSync(outPath); } catch { /* not present */ }
console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`);
continue;
}
const svg = toSVG({
bcid: card.bcid,
text: value,
scale: card.scale,
includetext: false,
paddingwidth: 8,
paddingheight: 8,
...(card.parsefnc ? { parsefnc: true } : {})
});
writeFileSync(outPath, svg, 'utf8');
console.log(`[loyalty-cards] wrote ${card.filename} (${card.bcid})`);
}
+13 -4
View File
@@ -16,14 +16,23 @@ export interface CalendarDay {
rite1962?: Rite1962Detail;
}
// Compact per-day shape returned for the full year so the ring / month-grid
// overview views can render without refetching. Kept small on purpose.
// Compact per-day shape returned for the full window of the liturgical year.
// Kept to the bare minimum needed client-side: the ring needs a color for the
// needle on the selected day (which may be a ferial with no rank metadata),
// everything else goes through the separate `feastDots` array.
export interface YearDay {
iso: string;
color: string; // primary color key (WHITE/RED/...)
}
// Pre-filtered list of days that render a feast dot on the ring — rank > feria
// — with the metadata the ring and side panel need for each. Sent alongside
// YearDay so clients don't have to filter 365 entries themselves.
export interface FeastDot {
iso: string;
name: string;
rank: string;
color: string; // primary color key (WHITE/RED/...)
seasonKey: string | null;
color: string;
}
export interface SeasonArc {
+3 -2
View File
@@ -1,6 +1,7 @@
<script>
import { ChevronLeft, ChevronRight, Calendar } from '@lucide/svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Calendar from '@lucide/svelte/icons/calendar';
let { value = $bindable(''), lang = 'en', min = '', max = '' } = $props();
let open = $state(false);
+5 -2
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import type { Snippet, Component } from 'svelte';
import { Lock, Ban, SearchX, TriangleAlert, CircleAlert } from '@lucide/svelte';
import Lock from '@lucide/svelte/icons/lock';
import Ban from '@lucide/svelte/icons/ban';
import SearchX from '@lucide/svelte/icons/search-x';
import TriangleAlert from '@lucide/svelte/icons/triangle-alert';
import CircleAlert from '@lucide/svelte/icons/circle-alert';
interface BibleQuote {
text: string;
reference: string;
+14 -4
View File
@@ -2,6 +2,7 @@
import { browser } from '$app/environment';
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import Heart from '@lucide/svelte/icons/heart';
let { recipeId, isFavorite = $bindable(false), isLoggedIn = false } = $props<{ recipeId: string, isFavorite?: boolean, isLoggedIn?: boolean }>();
@@ -44,14 +45,21 @@
<style>
.favorite-button {
all: unset;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: var(--transition-fast);
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.5));
filter:
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.6))
drop-shadow(0 0 3px rgba(0, 0, 0, 0.45));
position: absolute;
bottom: 0.5em;
right: 0.5em;
color: white;
}
.favorite-button.is-favorite {
color: #ff2d55;
}
.favorite-button:disabled {
@@ -72,11 +80,13 @@
<button
type="submit"
class="favorite-button"
class:is-favorite={isFavorite}
disabled={isLoading}
onclick={toggleFavorite}
aria-label={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
title={isFavorite ? 'Favorit entfernen' : 'Als Favorit speichern'}
>
{isFavorite ? '❤️' : '🖤'}
<Heart size={24} strokeWidth={2} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
</form>
{/if}
+3 -1
View File
@@ -1,6 +1,8 @@
<script>
import { themeStore } from '$lib/stores/theme.svelte';
import { Sun, Moon, SunMoon } from '@lucide/svelte';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import SunMoon from '@lucide/svelte/icons/sun-moon';
</script>
<style>
+1 -1
View File
@@ -1,5 +1,5 @@
<script>
import { X } from '@lucide/svelte';
import X from '@lucide/svelte/icons/x';
import { getToasts } from '$lib/js/toast.svelte';
const toasts = getToasts();
+15 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { page } from '$app/stores';
import LogIn from '@lucide/svelte/icons/log-in';
let { user, recipeLang = 'rezepte', lang = 'de' } = $props();
@@ -56,6 +57,12 @@
background-size: contain;
cursor: pointer;
}
.login-link {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0.4rem !important;
}
.options-wrap {
--menu-bg: rgba(46, 52, 64, 0.95);
--menu-border: rgba(255,255,255,0.08);
@@ -155,5 +162,12 @@
</div>
</button>
{:else}
<a class=entry href="/login?callbackUrl={encodeURIComponent($page.url.pathname + $page.url.search)}">Login</a>
<a
class="entry login-link"
href="/login?callbackUrl={encodeURIComponent($page.url.pathname + $page.url.search)}"
aria-label={lang === 'de' ? 'Anmelden' : 'Login'}
title={lang === 'de' ? 'Anmelden' : 'Login'}
>
<LogIn size={18} />
</a>
{/if}
@@ -2,7 +2,9 @@
import { browser } from '$app/environment';
import { getAngelusStreak, getCurrentTimeSlot, type TimeSlot } from '$lib/stores/angelusStreak.svelte';
import StreakAura from '$lib/components/faith/StreakAura.svelte';
import { Coffee, Sun, Moon } from '@lucide/svelte';
import Coffee from '@lucide/svelte/icons/coffee';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import { tick, onMount } from 'svelte';
let burst = $state(false);
@@ -3,7 +3,7 @@
import { getEnrichedExerciseById } from '$lib/data/exercisedb';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
let { exerciseId } = $props();
let { exerciseId, plain = false } = $props();
const lang = $derived(detectFitnessLang($page.url.pathname));
const exercise = $derived(getEnrichedExerciseById(exerciseId, lang));
@@ -11,7 +11,11 @@
</script>
{#if exercise}
{#if plain}
<span class="exercise-plain">{exercise.localName}</span>
{:else}
<a href="/fitness/{sl.exercises}/{exerciseId}" class="exercise-link">{exercise.localName}</a>
{/if}
{:else}
<span class="exercise-unknown">Unknown Exercise</span>
{/if}
@@ -25,6 +29,10 @@
.exercise-link:hover {
text-decoration: underline;
}
.exercise-plain {
color: inherit;
font: inherit;
}
.exercise-unknown {
color: var(--nord11);
font-style: italic;
@@ -1,7 +1,14 @@
<script>
import { getFilterOptionsAll, searchAllExercises } from '$lib/data/exercisedb';
import { translateTerm } from '$lib/data/exercises';
import { Search, X, Cable, Cog, Dumbbell, PersonStanding, Shapes, Weight } from '@lucide/svelte';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import Cable from '@lucide/svelte/icons/cable';
import Cog from '@lucide/svelte/icons/cog';
import Dumbbell from '@lucide/svelte/icons/dumbbell';
import PersonStanding from '@lucide/svelte/icons/person-standing';
import Shapes from '@lucide/svelte/icons/shapes';
import Weight from '@lucide/svelte/icons/weight';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
+29 -18
View File
@@ -1,7 +1,5 @@
<script>
import { onMount } from 'svelte';
import { Chart, registerables } from 'chart.js';
import 'chartjs-adapter-date-fns';
/**
* @type {{
@@ -11,16 +9,19 @@
* height?: string,
* yUnit?: string,
* goalLine?: number,
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string
* tooltipFormatter?: (value: number, datasetIndex: number, dataIndex: number, label: string) => string,
* yMin?: number,
* yMax?: number
* }}
*/
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined } = $props();
let { type = 'line', data, title = '', height = '250px', yUnit = '', goalLine = undefined, tooltipFormatter = undefined, yMin = undefined, yMax = undefined } = $props();
/** @type {HTMLCanvasElement | undefined} */
let canvas = $state(undefined);
/** @type {Chart | null} */
/** @type {import('chart.js').Chart | null} */
let chart = $state(null);
let registered = false;
/** @type {typeof import('chart.js').Chart | null} */
let ChartCtor = null;
const nordColors = [
'#88C0D0', '#A3BE8C', '#EBCB8B', '#D08770', '#BF616A',
@@ -35,11 +36,7 @@
}
function createChart() {
if (!canvas || !data?.datasets) return;
if (!registered) {
Chart.register(...registerables);
registered = true;
}
if (!canvas || !data?.datasets || !ChartCtor) return;
if (chart) chart.destroy();
const ctx = canvas.getContext('2d');
@@ -102,7 +99,7 @@
});
}
chart = new Chart(ctx, /** @type {any} */ ({
chart = new ChartCtor(ctx, /** @type {any} */ ({
type,
data: { labels: plainLabels, datasets: plainDatasets },
plugins,
@@ -125,6 +122,8 @@
},
y: {
beginAtZero: type === 'bar',
suggestedMin: yMin,
suggestedMax: yMax,
grid: { color: gridColor },
border: { display: false },
ticks: {
@@ -178,6 +177,20 @@
}
onMount(() => {
let disposed = false;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onTheme = () => setTimeout(createChart, 100);
/** @type {MutationObserver | undefined} */
let obs;
(async () => {
const [{ Chart, registerables }] = await Promise.all([
import('chart.js'),
import('chartjs-adapter-date-fns')
]);
if (disposed) return;
Chart.register(...registerables);
ChartCtor = Chart;
createChart();
requestAnimationFrame(() => {
if (chart) {
@@ -187,21 +200,19 @@
};
}
});
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onTheme = () => setTimeout(createChart, 100);
mq.addEventListener('change', onTheme);
const obs = new MutationObserver((muts) => {
obs = new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === 'data-theme') onTheme();
}
});
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
})();
return () => {
disposed = true;
mq.removeEventListener('change', onTheme);
obs.disconnect();
obs?.disconnect();
if (chart) chart.destroy();
};
});
+4 -1
View File
@@ -2,7 +2,10 @@
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { Heart, ExternalLink, ScanBarcode, X } from '@lucide/svelte';
import Heart from '@lucide/svelte/icons/heart';
import ExternalLink from '@lucide/svelte/icons/external-link';
import ScanBarcode from '@lucide/svelte/icons/scan-barcode';
import X from '@lucide/svelte/icons/x';
import { detectFitnessLang, fitnessSlugs, t } from '$lib/js/fitnessI18n';
import MacroBreakdown from './MacroBreakdown.svelte';
@@ -1,7 +1,9 @@
<script>
import { detectFitnessLang } from '$lib/js/fitnessI18n';
import { page } from '$app/stores';
import { Beef, Droplet, Wheat } from '@lucide/svelte';
import Beef from '@lucide/svelte/icons/beef';
import Droplet from '@lucide/svelte/icons/droplet';
import Wheat from '@lucide/svelte/icons/wheat';
import RingGraph from './RingGraph.svelte';
/**
@@ -1,5 +1,8 @@
<script>
import { Coffee, Sun, Moon, Cookie } from '@lucide/svelte';
import Coffee from '@lucide/svelte/icons/coffee';
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import Cookie from '@lucide/svelte/icons/cookie';
import { t } from '$lib/js/fitnessI18n';
/** @type {{ value?: 'breakfast' | 'lunch' | 'dinner' | 'snack', lang?: 'en' | 'de', onchange?: (meal: 'breakfast' | 'lunch' | 'dinner' | 'snack') => void }} */
+53 -58
View File
@@ -7,8 +7,8 @@
* @typedef {{ groups: string[], label: { en: string, de: string } }} MuscleRegion
*/
/** @type {{ selectedGroups?: string[], lang?: string, split?: boolean }} */
let { selectedGroups = $bindable([]), lang = 'en', split = false } = $props();
/** @type {{ selectedGroups?: string[], lang?: string }} */
let { selectedGroups = $bindable([]), lang = 'en' } = $props();
const isEn = $derived(lang === 'en');
@@ -78,7 +78,6 @@
/** Currently hovered region for tooltip */
/** @type {MuscleRegion | null} */
let hovered = $state(null);
let hoveredSide = $state('front');
const hoveredLabel = $derived.by(() => {
if (!hovered) return null;
@@ -108,9 +107,8 @@
/**
* @param {HTMLDivElement | null} container
* @param {Record<string, MuscleRegion>} map
* @param {string} side
*/
function setupEvents(container, map, side) {
function setupEvents(container, map) {
if (!container) return;
container.addEventListener('mouseover', (/** @type {Event} */ e) => {
@@ -118,7 +116,6 @@
const g = target?.closest('g[id]');
if (g && map[g.id]) {
hovered = map[g.id];
hoveredSide = side;
g.classList.add('highlighted');
}
});
@@ -143,66 +140,44 @@
}
onMount(() => {
setupEvents(frontEl, FRONT_MAP, 'front');
setupEvents(backEl, BACK_MAP, 'back');
setupEvents(frontEl, FRONT_MAP);
setupEvents(backEl, BACK_MAP);
});
</script>
{#if split}
<div class="muscle-filter-split">
<div class="split-left">
<div class="figure">
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
{#if hoveredLabel && hoveredSide === 'front'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div>
<div class="split-right">
<div class="figure">
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
{#if hoveredLabel && hoveredSide === 'back'}
<div class="hover-label">{hoveredLabel}</div>
{/if}
</div>
</div>
{:else}
<div class="muscle-filter">
<div class="muscle-filter">
<div class="body-figures">
<div class="figure">
<span class="figure-label">{isEn ? 'Front' : 'Vorne'}</span>
<div class="svg-wrap" bind:this={frontEl}>
{@html frontSvg}
</div>
</div>
<div class="figure">
<span class="figure-label">{isEn ? 'Back' : 'Hinten'}</span>
<div class="svg-wrap" bind:this={backEl}>
{@html backSvg}
</div>
</div>
</div>
{#if hoveredLabel}
<div class="hover-label">{hoveredLabel}</div>
{/if}
<div class="hover-label" aria-live="polite">
{hoveredLabel ?? (isEn ? 'Tap a muscle to filter' : 'Muskel antippen zum Filtern')}
</div>
{/if}
</div>
<style>
.muscle-filter {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
gap: 0.5rem;
width: 100%;
}
.body-figures {
display: flex;
gap: 0.5rem;
gap: 0.75rem;
justify-content: center;
width: 100%;
}
@@ -211,12 +186,47 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
flex: 1;
max-width: 150px;
min-width: 0;
max-width: 180px;
}
/* Tablet sidebar: narrow column, stack figures vertically */
@media (min-width: 900px) and (max-width: 1179px) {
.body-figures {
flex-direction: column;
align-items: center;
gap: 0.6rem;
}
.figure {
flex: initial;
width: 100%;
max-width: 170px;
}
}
/* Wide sidebar: let figures grow with the card width instead of capping at 180px */
@media (min-width: 1180px) {
.body-figures {
gap: 1rem;
}
.figure {
max-width: none;
}
}
.figure-label {
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--color-text-tertiary);
}
.svg-wrap {
width: 100%;
-webkit-tap-highlight-color: transparent;
}
.svg-wrap :global(svg) {
@@ -245,25 +255,10 @@
}
.hover-label {
font-size: 0.7rem;
min-height: 1.1em;
font-size: 0.72rem;
font-weight: 600;
color: var(--color-text-primary);
color: var(--color-text-secondary);
text-align: center;
}
/* Split mode: two independent columns for parent to position */
.muscle-filter-split {
display: contents;
}
.split-left, .split-right {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
}
.split-left .figure, .split-right .figure {
max-width: none;
}
</style>
+58 -11
View File
@@ -1,6 +1,13 @@
<script>
import { t } from '$lib/js/fitnessI18n';
import { Trash2, Plus, Pencil, UserPlus, X, ChevronLeft, ChevronRight } from '@lucide/svelte';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import UserPlus from '@lucide/svelte/icons/user-plus';
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import DatePicker from '$lib/components/DatePicker.svelte';
import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
@@ -601,7 +608,8 @@
{/if}
{#if showEntry && !readOnly}
<button class="end-btn" onclick={endPeriod} disabled={loading}>
{t('end_period', lang)}
<span class="end-btn-icon"><Check size={18} strokeWidth={2.5} /></span>
<span class="end-btn-label">{t('end_period', lang)}</span>
</button>
{/if}
</div>
@@ -1033,7 +1041,7 @@
color: var(--color-text-secondary);
}
.start-btn, .end-btn {
.start-btn {
padding: 0.45rem 0.9rem;
border: none;
border-radius: 7px;
@@ -1043,21 +1051,60 @@
white-space: nowrap;
align-self: flex-start;
margin-top: 0.6rem;
}
.start-btn {
background: var(--nord11);
color: white;
}
.end-btn {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border);
}
.start-btn:disabled, .end-btn:disabled {
.start-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Prominent end-period CTA — flat fill, full width */
.end-btn {
align-self: stretch;
margin-top: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.55rem;
padding: 0.8rem 1.1rem;
border: none;
border-radius: 10px;
cursor: pointer;
color: white;
background: var(--nord11);
box-shadow: var(--shadow-sm);
transition: background 140ms ease;
-webkit-tap-highlight-color: transparent;
}
.end-btn:hover {
background: color-mix(in srgb, var(--nord11) 88%, black);
}
.end-btn:active {
background: color-mix(in srgb, var(--nord11) 80%, black);
}
.end-btn:focus-visible {
outline: 2px solid var(--nord11);
outline-offset: 2px;
}
.end-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.end-btn-label {
font-weight: 700;
font-size: 0.85rem;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.end-btn-icon {
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 360px) {
.status-split { flex-direction: column; gap: 0.6rem; }
.status-side { border-left: none; padding-left: 0; border-top: 1px solid var(--color-border); padding-top: 0.6rem; flex-direction: row; gap: 1rem; }
@@ -1,5 +1,10 @@
<script>
import { Plus, ChevronDown, Sparkles, Beef, Droplet, Wheat } from '@lucide/svelte';
import Plus from '@lucide/svelte/icons/plus';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Beef from '@lucide/svelte/icons/beef';
import Droplet from '@lucide/svelte/icons/droplet';
import Wheat from '@lucide/svelte/icons/wheat';
import { untrack } from 'svelte';
import { toast } from '$lib/js/toast.svelte';
import { t } from '$lib/js/fitnessI18n';
@@ -1,7 +1,12 @@
<script>
import { page } from '$app/stores';
import { getExerciseById, getExerciseMetrics } from '$lib/data/exercises';
import { Clock, Weight, Trophy, Route, Gauge, Flame } from '@lucide/svelte';
import Clock from '@lucide/svelte/icons/clock';
import Weight from '@lucide/svelte/icons/weight';
import Trophy from '@lucide/svelte/icons/trophy';
import Route from '@lucide/svelte/icons/route';
import Gauge from '@lucide/svelte/icons/gauge';
import Flame from '@lucide/svelte/icons/flame';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang($page.url.pathname));
+4 -1
View File
@@ -1,5 +1,8 @@
<script>
import { Check, X, Play, Square } from '@lucide/svelte';
import Check from '@lucide/svelte/icons/check';
import X from '@lucide/svelte/icons/x';
import Play from '@lucide/svelte/icons/play';
import Square from '@lucide/svelte/icons/square';
import { METRIC_LABELS } from '$lib/data/exercises';
import RestTimer from './RestTimer.svelte';
import { page } from '$app/stores';
@@ -1,6 +1,8 @@
<script>
import { Cloud, CloudOff, RefreshCw, AlertTriangle } from '@lucide/svelte';
import Cloud from '@lucide/svelte/icons/cloud';
import CloudOff from '@lucide/svelte/icons/cloud-off';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
/** @type {{ status: string }} */
let { status } = $props();
</script>
@@ -1,6 +1,7 @@
<script>
import { getExerciseById } from '$lib/data/exercises';
import { EllipsisVertical, MapPin } from '@lucide/svelte';
import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
import MapPin from '@lucide/svelte/icons/map-pin';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
@@ -1,6 +1,5 @@
<script>
import { X } from '@lucide/svelte';
import X from '@lucide/svelte/icons/x';
let { src, poster = '', onClose } = $props();
/** @param {KeyboardEvent} e */
+273 -78
View File
@@ -1,6 +1,8 @@
<script>
import { goto } from '$app/navigation';
import { Play, Pause } from '@lucide/svelte';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import { page } from '$app/stores';
import { detectFitnessLang, t } from '$lib/js/fitnessI18n';
@@ -17,131 +19,324 @@ function formatRest(secs) {
return `${m}:${s.toString().padStart(2, '0')}`;
}
const restActive = $derived(restTotal > 0 && restSeconds > 0);
const restProgress = $derived(restTotal > 0 ? restSeconds / restTotal : 0);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="workout-bar" onclick={() => goto(href)} onkeydown={(e) => { if (e.key === 'Enter') goto(href); }}>
<div class="bar-left">
<button class="pause-btn" onclick={(e) => { e.stopPropagation(); onPauseToggle?.(); }} aria-label={paused ? 'Resume' : 'Pause'}>
{#if paused}<Play size={16} />{:else}<Pause size={16} />{/if}
<div
class="workout-fab"
class:rest-active={restActive}
role="button"
tabindex="0"
aria-label={t('active_workout', lang)}
onclick={() => goto(href)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); goto(href); } }}
>
<button
class="pause-btn"
onclick={(e) => { e.stopPropagation(); onPauseToggle?.(); }}
aria-label={paused ? 'Resume' : 'Pause'}
>
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
</button>
<span class="elapsed" class:paused>{elapsed}</span>
<SyncIndicator status={syncStatus} />
</div>
{#if restTotal > 0 && restSeconds > 0}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
<div class="rest-pill" onclick={(e) => e.stopPropagation()}>
<div class="rest-fill" style:width="{restProgress * 100}%"></div>
<div class="rest-controls">
<button class="rest-adj" onclick={() => onRestAdjust?.(-30)}>-30s</button>
<button class="rest-time" onclick={() => onRestSkip?.()}>{formatRest(restSeconds)}</button>
<button class="rest-adj" onclick={() => onRestAdjust?.(30)}>+30s</button>
</div>
<span class="fab-sync"><SyncIndicator status={syncStatus} /></span>
<span class="fab-divider" aria-hidden="true"></span>
{#if restActive}
<div class="rest-pill">
<div class="rest-fill" style:width="{restProgress * 100}%" aria-hidden="true"></div>
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(-30); }} aria-label="Remove 30 seconds">30s</button>
<button class="rest-time" onclick={(e) => { e.stopPropagation(); onRestSkip?.(); }} aria-label="Skip rest">{formatRest(restSeconds)}</button>
<button class="rest-adj" onclick={(e) => { e.stopPropagation(); onRestAdjust?.(30); }} aria-label="Add 30 seconds">+30s</button>
</div>
{:else}
<span class="bar-label">{t('active_workout', lang)}</span>
<span class="fab-label">{t('active_workout', lang)}</span>
<ChevronRight size={14} strokeWidth={2.4} class="fab-chevron" />
{/if}
</div>
<style>
.workout-bar {
/* ═══════════════════════════════════════════
FLOATING GLASS PILL — mirrors Header.svelte nav
═══════════════════════════════════════════ */
.workout-fab {
position: fixed;
bottom: 0;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
max-width: 900px;
margin: 0 auto;
padding: 0.75rem var(--space-md, 1rem);
background: var(--color-bg-primary);
border-top: 1px solid var(--color-border);
z-index: 100;
cursor: pointer;
}
.bar-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pause-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-secondary);
height: 3rem;
padding: 0 0.45rem 0 0.45rem;
width: -moz-fit-content;
width: fit-content;
max-width: calc(100% - 1.5rem);
margin-inline: auto;
border-radius: 100px;
background: var(--fab-bg, rgba(46, 52, 64, 0.82));
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--fab-border, rgba(255, 255, 255, 0.08));
box-shadow: 0 4px 24px var(--fab-shadow, rgba(0, 0, 0, 0.25));
cursor: pointer;
padding: 0.3rem;
display: flex;
transition: transform 200ms cubic-bezier(0.2, 0.8, 0.2, 1),
box-shadow 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
animation: fab-rise 380ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
/* token defaults (dark bar) */
--fab-text: #c9c9c9;
--fab-text-strong: #fff;
--fab-text-muted: rgba(255, 255, 255, 0.55);
--fab-btn-bg: rgba(255, 255, 255, 0.08);
--fab-btn-bg-hover: rgba(255, 255, 255, 0.16);
--fab-btn-border: rgba(255, 255, 255, 0.14);
--fab-divider: rgba(255, 255, 255, 0.12);
--fab-accent: var(--blue, #5e81ac);
--fab-paused: var(--nord13, #ebcb8b);
}
.workout-fab:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px var(--fab-shadow, rgba(0, 0, 0, 0.35));
}
.workout-fab:active {
transform: translateY(0);
}
@media (prefers-color-scheme: dark) {
.workout-fab {
--fab-bg: rgba(20, 20, 20, 0.78);
--fab-border: rgba(255, 255, 255, 0.06);
}
}
:global(:root[data-theme="dark"]) .workout-fab {
--fab-bg: rgba(20, 20, 20, 0.78);
--fab-border: rgba(255, 255, 255, 0.06);
}
/* Light theme */
:global(:root[data-theme="light"]) .workout-fab {
--fab-bg: rgba(255, 255, 255, 0.82);
--fab-border: rgba(0, 0, 0, 0.08);
--fab-shadow: rgba(0, 0, 0, 0.1);
--fab-text: #555;
--fab-text-strong: var(--nord0, #2e3440);
--fab-text-muted: rgba(0, 0, 0, 0.5);
--fab-btn-bg: rgba(0, 0, 0, 0.05);
--fab-btn-bg-hover: rgba(0, 0, 0, 0.1);
--fab-btn-border: rgba(0, 0, 0, 0.12);
--fab-divider: rgba(0, 0, 0, 0.1);
}
@media (prefers-color-scheme: light) {
:global(:root:not([data-theme])) .workout-fab {
--fab-bg: rgba(255, 255, 255, 0.82);
--fab-border: rgba(0, 0, 0, 0.08);
--fab-shadow: rgba(0, 0, 0, 0.1);
--fab-text: #555;
--fab-text-strong: var(--nord0, #2e3440);
--fab-text-muted: rgba(0, 0, 0, 0.5);
--fab-btn-bg: rgba(0, 0, 0, 0.05);
--fab-btn-bg-hover: rgba(0, 0, 0, 0.1);
--fab-btn-border: rgba(0, 0, 0, 0.12);
--fab-divider: rgba(0, 0, 0, 0.1);
}
}
/* ═══════════════════════════════════════════
PAUSE BUTTON — small pill icon button (matches nav hover tile)
═══════════════════════════════════════════ */
.pause-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 100px;
background: var(--fab-btn-bg);
border: 1px solid var(--fab-btn-border);
color: var(--fab-text-strong);
cursor: pointer;
padding: 0;
transition: background 140ms, color 140ms, transform 120ms;
}
.pause-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: var(--fab-btn-bg-hover);
color: var(--fab-accent);
}
.pause-btn:active {
transform: scale(0.94);
}
/* ═══════════════════════════════════════════
ELAPSED TIME — dominant numeric
═══════════════════════════════════════════ */
.elapsed {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 1.1rem;
color: var(--color-text-secondary);
font-weight: 700;
font-size: 1.05rem;
letter-spacing: 0.01em;
color: var(--fab-text-strong);
padding-inline: 0.15rem;
}
.elapsed.paused {
color: var(--nord13);
color: var(--fab-paused);
}
.bar-label {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text-secondary);
.fab-sync {
display: inline-flex;
align-items: center;
color: var(--fab-text-muted);
}
.fab-divider {
width: 1px;
height: 1.2rem;
background: var(--fab-divider);
flex-shrink: 0;
}
/* ═══════════════════════════════════════════
RIGHT-SIDE LABEL / CHEVRON — idle state
═══════════════════════════════════════════ */
.fab-label {
flex-shrink: 0;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--fab-text);
padding-right: 0.15rem;
}
.workout-fab :global(.fab-chevron) {
color: var(--fab-text-muted);
margin-right: 0.35rem;
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), color 140ms;
}
.workout-fab:hover :global(.fab-chevron) {
color: var(--fab-text-strong);
transform: translateX(3px);
}
/* ═══════════════════════════════════════════
REST PILL — inner pill with animated progress fill
═══════════════════════════════════════════ */
.rest-pill {
position: relative;
height: 2.2rem;
border-radius: 8px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
height: 2.1rem;
padding: 0 0.25rem;
border-radius: 100px;
background: var(--fab-btn-bg);
overflow: hidden;
background: var(--nord0);
min-width: 10rem;
isolation: isolate;
}
.rest-fill {
position: absolute;
inset: 0;
background: var(--blue);
border-radius: 8px;
right: auto;
background: linear-gradient(
90deg,
color-mix(in srgb, var(--fab-accent), transparent 55%) 0%,
var(--fab-accent) 100%
);
transition: width 1s linear;
z-index: -1;
}
.rest-controls {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 1;
}
.rest-time {
.rest-time,
.rest-adj {
appearance: none;
background: none;
border: none;
cursor: pointer;
color: var(--fab-text-strong);
font-family: inherit;
padding: 0.25rem 0.5rem;
border-radius: 100px;
transition: background 120ms;
}
.rest-time {
font-size: 0.9rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: white;
cursor: pointer;
padding: 0.2rem 0.5rem;
letter-spacing: 0.01em;
min-width: 3.2rem;
text-align: center;
}
.rest-adj {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 0.7rem;
font-weight: 600;
padding: 0.2rem 0.4rem;
border-radius: 4px;
opacity: 0.7;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.02em;
opacity: 0.85;
}
.rest-time:hover,
.rest-adj:hover {
background: rgba(255, 255, 255, 0.14);
opacity: 1;
}
:global(:root[data-theme="light"]) .rest-time:hover,
:global(:root[data-theme="light"]) .rest-adj:hover {
background: rgba(255, 255, 255, 0.5);
}
/* ═══════════════════════════════════════════
MOUNT ANIMATION
═══════════════════════════════════════════ */
@keyframes fab-rise {
from {
opacity: 0;
transform: translateY(24px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* ═══════════════════════════════════════════
NARROW SCREENS — tighten spacing
═══════════════════════════════════════════ */
@media (max-width: 420px) {
.workout-fab {
gap: 0.4rem;
padding: 0 0.35rem;
}
.fab-label {
font-size: 0.62rem;
letter-spacing: 0.08em;
}
.elapsed {
font-size: 0.95rem;
}
.rest-pill {
gap: 0.1rem;
}
.rest-adj {
padding: 0.25rem 0.35rem;
font-size: 0.62rem;
}
}
@media (prefers-reduced-motion: reduce) {
.workout-fab {
animation: none;
}
.workout-fab:hover {
transform: none;
}
}
</style>
@@ -0,0 +1,194 @@
<script>
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
/** @type {{
* exerciseId: string,
* bodyPart?: string | null,
* equipment?: string | null,
* detailsHref?: string | null,
* detailsLabel?: string,
* exerciseIndex: number,
* totalExercises: number,
* sets: Array<{ completed?: boolean }>,
* activeSetIdx: number,
* labels: { exerciseOf: (i: number, n: number) => string, setOf: (i: number, n: number) => string, done: (n: number) => string },
* }} */
let {
exerciseId,
bodyPart = null,
equipment = null,
detailsHref = null,
detailsLabel = 'Exercise details',
exerciseIndex,
totalExercises,
sets,
activeSetIdx,
labels
} = $props();
const totalSets = $derived(sets.length);
const doneSets = $derived(sets.filter((s) => s.completed).length);
const allDone = $derived(totalSets > 0 && doneSets === totalSets);
</script>
<section class="focus-card" aria-label="Current exercise">
<header class="focus-eyebrow">
<span class="focus-step">{labels.exerciseOf(exerciseIndex + 1, totalExercises)}</span>
{#if bodyPart}
<span class="focus-dot-sep" aria-hidden="true">·</span>
<span class="focus-meta">{bodyPart}</span>
{/if}
{#if equipment}
<span class="focus-dot-sep" aria-hidden="true">·</span>
<span class="focus-meta">{equipment}</span>
{/if}
</header>
<div class="focus-name-row">
<h2 class="focus-name"><ExerciseName {exerciseId} plain /></h2>
{#if detailsHref}
<a class="focus-details" href={detailsHref} aria-label={detailsLabel} title={detailsLabel}>
<ChevronRight size={18} strokeWidth={2.2} />
</a>
{/if}
</div>
<div class="focus-progress">
<span class="focus-set-label" class:complete={allDone}>
{allDone ? labels.done(totalSets) : labels.setOf(activeSetIdx + 1, totalSets)}
</span>
<span class="focus-dots" aria-hidden="true">
{#each sets as s, si (si)}
<span
class="focus-dot"
class:filled={s.completed}
class:current={si === activeSetIdx && !s.completed}
></span>
{/each}
</span>
</div>
</section>
<style>
.focus-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1.1rem 1.25rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
}
/* Eyebrow row: step counter + bodypart + equipment, controls on the right */
.focus-eyebrow {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--color-text-tertiary);
min-height: 1.5rem;
}
.focus-step {
color: var(--color-primary);
}
.focus-meta {
color: var(--color-text-secondary);
}
.focus-dot-sep {
color: var(--color-text-tertiary);
}
/* Big display name */
.focus-name-row {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.focus-name {
flex: 1;
margin: 0;
font-size: 1.55rem;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.15;
color: var(--color-text-primary);
min-width: 0;
}
.focus-details {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 100px;
color: var(--color-text-tertiary);
text-decoration: none;
transition: background 140ms, color 140ms, transform 140ms;
}
.focus-details:hover {
background: var(--color-bg-elevated);
color: var(--color-primary);
transform: translateX(2px);
}
/* Set progress line */
.focus-progress {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.focus-set-label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--color-text-secondary);
}
.focus-set-label.complete {
color: var(--nord14);
}
.focus-dots {
display: inline-flex;
gap: 6px;
}
.focus-dot {
width: 9px;
height: 9px;
border-radius: 100px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
transition: background 180ms, transform 180ms, border-color 180ms;
}
.focus-dot.filled {
background: var(--color-primary);
border-color: var(--color-primary);
}
.focus-dot.current {
background: color-mix(in srgb, var(--color-primary), transparent 55%);
border-color: var(--color-primary);
transform: scale(1.25);
animation: focus-dot-pulse 1.4s ease-in-out infinite;
}
@keyframes focus-dot-pulse {
0%, 100% { transform: scale(1.25); }
50% { transform: scale(1.05); }
}
@media (max-width: 560px) {
.focus-card {
padding: 0.9rem 1rem 0.85rem;
}
.focus-name {
font-size: 1.3rem;
}
}
</style>
@@ -0,0 +1,687 @@
<script>
import Plus from '@lucide/svelte/icons/plus';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import X from '@lucide/svelte/icons/x';
import Check from '@lucide/svelte/icons/check';
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
/**
* @typedef {{ exerciseId: string, sets: Array<{ completed?: boolean }> }} RailExercise
*/
/** @type {{
* exercises: RailExercise[],
* activeIdx: number,
* activeSetIdx: number,
* elapsedLabel: string,
* paused?: boolean,
* syncStatus?: string,
* setsDone: number,
* setsTotal: number,
* addLabel: string,
* pauseLabel?: string,
* resumeLabel?: string,
* removeLabel?: string,
* previousData?: Record<string, Array<{ weight?: number | null, reps?: number | null }>>,
* weightUnit?: string,
* onPauseToggle?: () => void,
* onFocus: (idx: number) => void,
* onAddExercise: () => void,
* onRemove?: (idx: number) => void,
* onReorder?: (fromIdx: number, toIdx: number) => void,
* }} */
let {
exercises,
activeIdx,
activeSetIdx,
elapsedLabel,
paused = false,
syncStatus = 'idle',
setsDone,
setsTotal,
addLabel,
pauseLabel = 'Pause',
resumeLabel = 'Resume',
removeLabel = 'Remove exercise',
previousData = {},
weightUnit = 'kg',
title,
onPauseToggle,
onFocus,
onAddExercise,
onRemove,
onReorder
} = $props();
/** Drag-and-drop state */
/** @type {number | null} */
let draggedIdx = $state(null);
/** @type {number | null} */
let dragOverIdx = $state(null);
/** @type {HTMLOListElement | null} */
let listEl = $state(null);
// Keep the active chip at the top of the scrollable list so the user sees current + the next two
$effect(() => {
if (!listEl) return;
const idx = activeIdx;
if (idx < 0) return;
const items = listEl.querySelectorAll('.rail-item');
const target = /** @type {HTMLElement | undefined} */ (items[idx]);
if (!target) return;
// Use scrollTop directly to keep the scroll local to the list (avoid page scroll)
const listTop = listEl.getBoundingClientRect().top;
const itemTop = target.getBoundingClientRect().top;
listEl.scrollTo({ top: listEl.scrollTop + (itemTop - listTop), behavior: 'smooth' });
});
/** @param {DragEvent} e @param {number} idx */
function onDragStart(e, idx) {
draggedIdx = idx;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Firefox requires data to be set to initiate a drag
e.dataTransfer.setData('text/plain', String(idx));
}
}
/** @param {DragEvent} e @param {number} idx */
function onDragOver(e, idx) {
if (draggedIdx == null || draggedIdx === idx) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverIdx = idx;
}
/** @param {DragEvent} e @param {number} idx */
function onDrop(e, idx) {
e.preventDefault();
if (draggedIdx != null && draggedIdx !== idx && onReorder) {
onReorder(draggedIdx, idx);
}
draggedIdx = null;
dragOverIdx = null;
}
function onDragEnd() {
draggedIdx = null;
dragOverIdx = null;
}
const progressPct = $derived(setsTotal > 0 ? (setsDone / setsTotal) * 100 : 0);
/**
* What to rack: starting weight × reps for the first set.
* Falls back to the previous session's first set if the current plan is blank.
* @param {RailExercise} ex
* @returns {string | null}
*/
function startingLoadLabel(ex) {
const first = ex.sets[0];
const prev = previousData[ex.exerciseId]?.[0];
/** @type {number | null | undefined} */
const w = (first && typeof first === 'object' && 'weight' in first ? /** @type {any} */(first).weight : null) ?? prev?.weight;
/** @type {number | null | undefined} */
const r = (first && typeof first === 'object' && 'reps' in first ? /** @type {any} */(first).reps : null) ?? prev?.reps;
if (w != null && w > 0 && r != null && r > 0) return `${w} ${weightUnit} × ${r}`;
if (w != null && w > 0) return `${w} ${weightUnit}`;
if (r != null && r > 0) return `× ${r}`;
return null;
}
</script>
<aside class="workout-rail" aria-label="Workout overview">
<header class="rail-header">
{#if title}
<div class="rail-title">{@render title()}</div>
{/if}
<div class="rail-timer-row">
<button
class="rail-pause"
onclick={() => onPauseToggle?.()}
aria-label={paused ? resumeLabel : pauseLabel}
type="button"
>
{#if paused}<Play size={14} strokeWidth={2.4} />{:else}<Pause size={14} strokeWidth={2.4} />{/if}
</button>
<span class="rail-elapsed" class:paused>{elapsedLabel}</span>
<span class="rail-sync"><SyncIndicator status={syncStatus} /></span>
</div>
<div class="rail-progress">
<div class="rail-progress-bar">
<div class="rail-progress-fill" style:width="{progressPct}%"></div>
</div>
<span class="rail-progress-label">{setsDone}<span class="rail-progress-sep">/</span>{setsTotal}</span>
</div>
</header>
<ol class="rail-list" bind:this={listEl}>
{#each exercises as ex, i (i)}
{@const isActive = i === activeIdx}
{@const done = ex.sets.filter((s) => s.completed).length}
{@const complete = done === ex.sets.length && ex.sets.length > 0}
{@const load = startingLoadLabel(ex)}
{@const isDragging = draggedIdx === i}
{@const isDragOver = dragOverIdx === i && draggedIdx !== i}
{@const dropAbove = isDragOver && (draggedIdx ?? i) > i}
{@const dropBelow = isDragOver && (draggedIdx ?? i) < i}
<li
class="rail-item"
class:dragging={isDragging}
class:drop-above={dropAbove}
class:drop-below={dropBelow}
class:active={isActive}
class:complete
draggable={onReorder ? 'true' : undefined}
ondragstart={(e) => onDragStart(e, i)}
ondragover={(e) => onDragOver(e, i)}
ondrop={(e) => onDrop(e, i)}
ondragend={onDragEnd}
>
<button
class="rail-chip"
class:active={isActive}
class:complete
onclick={() => onFocus(i)}
aria-current={isActive ? 'true' : undefined}
type="button"
>
<span class="rail-chip-index">{i + 1}</span>
<span class="rail-chip-body">
<span class="rail-chip-name"><ExerciseName exerciseId={ex.exerciseId} plain /></span>
{#if load}
<span class="rail-chip-load" aria-label="Starting load">{load}</span>
{/if}
<span class="rail-chip-dots" aria-hidden="true">
{#each ex.sets as s, si (si)}
<span
class="rail-dot"
class:filled={s.completed}
class:current={isActive && si === activeSetIdx && !s.completed}
></span>
{/each}
</span>
</span>
{#if complete}
<span class="rail-chip-count done" aria-label="Exercise complete">
<Check size={14} strokeWidth={2.8} />
</span>
{:else}
<span class="rail-chip-count">{done}/{ex.sets.length}</span>
{/if}
</button>
{#if onRemove}
<button
class="rail-chip-remove"
onclick={(e) => { e.stopPropagation(); onRemove?.(i); }}
aria-label={removeLabel}
title={removeLabel}
type="button"
draggable="false"
ondragstart={(e) => e.stopPropagation()}
>
<X size={14} strokeWidth={2.6} />
</button>
{/if}
</li>
{/each}
</ol>
<button class="rail-add" onclick={onAddExercise} type="button">
<Plus size={14} strokeWidth={2.4} />
<span>{addLabel}</span>
</button>
</aside>
<style>
.workout-rail {
display: flex;
flex-direction: column;
gap: 0.75rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-card);
padding: 1rem 0.85rem 0.85rem;
}
/* Header: elapsed + progress */
.rail-header {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0 0.25rem 0.6rem;
border-bottom: 1px solid var(--color-border);
}
.rail-title {
min-width: 0;
}
.rail-title :global(input) {
width: 100%;
background: transparent;
border: none;
padding: 0.1rem 0;
color: var(--color-text-primary);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.01em;
outline: none;
}
.rail-title :global(input::placeholder) {
color: var(--color-text-tertiary);
font-weight: 600;
}
.rail-title :global(input:focus) {
color: var(--color-primary);
}
.rail-timer-row {
display: flex;
align-items: center;
gap: 0.55rem;
}
.rail-pause {
all: unset;
-webkit-tap-highlight-color: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
border-radius: 100px;
background: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
color: var(--color-text-primary);
cursor: pointer;
flex-shrink: 0;
transition: background 140ms, color 140ms, border-color 140ms, transform 120ms;
}
.rail-pause:hover {
background: var(--color-bg-elevated);
color: var(--color-primary);
border-color: var(--color-primary);
}
.rail-pause:active {
transform: scale(0.94);
}
.rail-elapsed {
flex: 1;
font-size: 1.25rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
color: var(--color-text-primary);
line-height: 1.1;
min-width: 0;
}
.rail-elapsed.paused {
color: var(--nord13);
}
.rail-sync {
display: inline-flex;
align-items: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
.rail-progress {
display: flex;
align-items: center;
gap: 0.55rem;
}
.rail-progress-bar {
flex: 1;
height: 6px;
border-radius: 100px;
background: var(--color-bg-tertiary);
overflow: hidden;
}
.rail-progress-fill {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, color-mix(in srgb, var(--color-primary), transparent 40%), var(--color-primary));
transition: width 350ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rail-progress-label {
font-size: 0.72rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
letter-spacing: 0.03em;
}
.rail-progress-sep {
color: var(--color-text-tertiary);
margin-inline: 0.1rem;
}
/* Chip list */
.rail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.rail-list::-webkit-scrollbar {
width: 4px;
}
.rail-list::-webkit-scrollbar-track {
background: transparent;
}
.rail-list::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 100px;
}
.rail-list::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-text-tertiary), transparent 50%);
}
/* Row wrapper holds chip + remove button, carries drag state */
.rail-item {
position: relative;
display: flex;
align-items: stretch;
border-radius: var(--radius-md, 0.5rem);
transition: opacity 140ms;
}
.rail-item[draggable='true'] {
cursor: grab;
}
.rail-item[draggable='true']:active {
cursor: grabbing;
}
.rail-item.dragging {
opacity: 0.35;
}
.rail-item.drop-above::before,
.rail-item.drop-below::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 2px;
border-radius: 100px;
background: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary), transparent 70%);
}
.rail-item.drop-above::before {
top: -3px;
}
.rail-item.drop-below::after {
bottom: -3px;
}
.rail-chip {
all: unset;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.55rem;
padding: 0.55rem 0.55rem 0.55rem 0.45rem;
border-radius: var(--radius-md, 0.5rem);
background: transparent;
border: 1px solid transparent;
cursor: pointer;
transition: background 140ms, border-color 140ms, transform 120ms;
}
.rail-chip:hover {
background: var(--color-bg-elevated);
}
.rail-chip:active {
transform: scale(0.98);
}
.rail-chip.active {
background: color-mix(in srgb, var(--color-primary), transparent 82%);
border-color: transparent;
}
.rail-chip.active:hover {
background: color-mix(in srgb, var(--color-primary), transparent 76%);
}
.rail-chip.complete {
opacity: 0.72;
}
.rail-chip.complete.active {
opacity: 1;
}
/* × remove — overlays the set counter on hover (same spot) */
.rail-chip-remove {
all: unset;
-webkit-tap-highlight-color: transparent;
position: absolute;
top: 50%;
right: 0.4rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.55rem;
height: 1.55rem;
border-radius: 100px;
color: var(--nord11);
opacity: 0;
pointer-events: none;
cursor: pointer;
transition: opacity 140ms, background 140ms, transform 120ms;
z-index: 1;
}
.rail-item:hover .rail-chip-remove,
.rail-chip-remove:focus-visible {
opacity: 1;
pointer-events: auto;
}
.rail-chip-remove:hover {
background: color-mix(in srgb, var(--nord11), transparent 82%);
transform: translateY(-50%) scale(1.08);
}
.rail-chip-remove:active {
transform: translateY(-50%) scale(0.94);
}
.rail-chip-index {
font-size: 0.65rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: var(--color-text-tertiary);
width: 1.25rem;
text-align: right;
letter-spacing: 0.04em;
}
.rail-chip.active .rail-chip-index {
color: var(--color-primary);
}
.rail-chip-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.rail-chip-name {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-chip.active .rail-chip-name {
font-weight: 700;
}
/* Starting weight hint — "what to rack" */
.rail-chip-load {
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
color: var(--color-text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-chip.active .rail-chip-load {
color: var(--color-primary);
}
.rail-chip.complete .rail-chip-load {
text-decoration: line-through;
text-decoration-color: color-mix(in srgb, currentColor, transparent 60%);
}
.rail-chip-dots {
display: inline-flex;
gap: 3px;
flex-wrap: wrap;
}
.rail-dot {
width: 6px;
height: 6px;
border-radius: 100px;
background: var(--color-border);
transition: background 180ms, transform 180ms;
}
.rail-dot.filled {
background: color-mix(in srgb, var(--color-primary), transparent 15%);
}
.rail-dot.current {
background: var(--color-primary);
transform: scale(1.3);
animation: dot-pulse 1.4s ease-in-out infinite;
}
@keyframes dot-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
/* Set counter — visible by default, fades out when × takes over on hover */
.rail-chip-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.55rem;
font-size: 0.7rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--color-text-tertiary);
flex-shrink: 0;
transition: opacity 140ms;
}
.rail-chip.active .rail-chip-count {
color: var(--color-text-secondary);
}
/* Completed exercise: matches the set-complete check button from SetTable */
.rail-chip-count.done {
width: 1.55rem;
height: 1.55rem;
border-radius: 50%;
border: 2px solid var(--nord14);
background: var(--nord14);
color: white;
}
.rail-item:hover .rail-chip-count {
opacity: 0;
}
/* Add exercise button */
.rail-add {
all: unset;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.75rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius-md, 0.5rem);
color: var(--color-text-secondary);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.03em;
cursor: pointer;
transition: border-color 140ms, color 140ms;
}
.rail-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
/* Narrow viewports: vertical list, compact chip, dots inline next to name */
@media (max-width: 899px) {
.workout-rail {
gap: 0.6rem;
padding: 0.75rem 0.75rem 0.6rem;
}
/* Title full-width row, timer + progress share a second row */
.rail-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
grid-template-areas:
"title title"
"timer progress";
column-gap: 0.75rem;
row-gap: 0.5rem;
padding: 0 0.15rem 0.5rem;
align-items: center;
}
.rail-title { grid-area: title; }
.rail-timer-row {
grid-area: timer;
gap: 0.45rem;
}
.rail-elapsed {
flex: initial;
font-size: 1.05rem;
}
.rail-progress {
grid-area: progress;
min-width: 0;
}
/* Dots jump up next to the name; load sits below on its own row */
.rail-chip-body {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
"name dots"
"load load";
column-gap: 0.5rem;
row-gap: 0.1rem;
align-items: center;
}
.rail-chip-name { grid-area: name; }
.rail-chip-dots { grid-area: dots; flex-wrap: nowrap; }
.rail-chip-load { grid-area: load; }
.rail-chip {
padding: 0.5rem 0.55rem 0.5rem 0.4rem;
}
/* Scrollable only on mobile — desktop lets the rail grow */
.rail-list {
max-height: 10.5rem;
overflow-y: auto;
}
/* Smaller completion checkmark */
.rail-chip-count {
min-width: 1.25rem;
font-size: 0.65rem;
}
.rail-chip-count.done {
width: 1.25rem;
height: 1.25rem;
border-width: 2px;
}
.rail-chip-count.done :global(svg) {
width: 11px;
height: 11px;
}
.rail-add {
padding: 0.5rem 0.6rem;
font-size: 0.72rem;
}
}
</style>
@@ -1,5 +1,6 @@
<script>
import { UtensilsCrossed, X } from '@lucide/svelte';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import X from '@lucide/svelte/icons/x';
import { toast } from '$lib/js/toast.svelte';
let {
+11 -5
View File
@@ -2,6 +2,7 @@
import "$lib/css/shake.css";
import "$lib/css/icon.css";
import { onMount } from "svelte";
import Heart from '@lucide/svelte/icons/heart';
let {
recipe,
@@ -182,10 +183,13 @@ function preloadHeroImage() {
.favorite-indicator{
position: absolute;
font-size: 2rem;
top: 0.1em;
left: 0.1em;
filter: drop-shadow(0 0 3px rgba(0, 0, 0, 0.8));
top: 0.4em;
left: 0.4em;
display: flex;
color: #ff2d55;
filter:
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
}
.translation-badge{
@@ -240,7 +244,9 @@ function preloadHeroImage() {
<img class="image" class:loaded={isloaded} src={'https://bocken.org/static/rezepte/thumb/' + img_name} loading={loading_strat} alt="{img_alt}" onload={() => isloaded=true}/>
</div>
{#if showFavoriteIndicator && isFavorite}
<div class="favorite-indicator">❤️</div>
<div class="favorite-indicator" aria-label="Favorit">
<Heart size={28} strokeWidth={2} fill="currentColor" />
</div>
{/if}
{#if translationStatus !== undefined}
<div class="translation-badge {translationStatus || 'none'}">
@@ -1,5 +1,6 @@
<script lang="ts">
import "$lib/css/shake.css";
import Heart from '@lucide/svelte/icons/heart';
let {
recipe,
@@ -143,9 +144,11 @@
position: absolute;
top: 0.5em;
left: 0.5em;
font-size: 1.1rem;
font-family: "Noto Color Emoji", "Noto Color Emoji Subset", emoji, sans-serif;
filter: drop-shadow(0 0 3px rgba(0,0,0,0.8));
display: flex;
color: #ff2d55;
filter:
drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
z-index: 2;
pointer-events: none;
}
@@ -156,7 +159,9 @@
<div class="compact-card" onclick={activateTransitions}>
<a href="{routePrefix}/{recipe.short_name}" class="card-link" aria-label={recipe.name}></a>
{#if showFavoriteIndicator && isFavorite}
<span class="favorite">❤️</span>
<span class="favorite" aria-label="Favorit">
<Heart size={18} strokeWidth={2} fill="currentColor" />
</span>
{/if}
<div class="img-wrap" style:background-color={img_color}>
<img
@@ -4,8 +4,12 @@ import Pen from '$lib/assets/icons/Pen.svelte'
import Cross from '$lib/assets/icons/Cross.svelte'
import Plus from '$lib/assets/icons/Plus.svelte'
import Check from '$lib/assets/icons/Check.svelte'
import { Timer, Wheat, Croissant, Flame, CookingPot, UtensilsCrossed } from '@lucide/svelte';
import Timer from '@lucide/svelte/icons/timer';
import Wheat from '@lucide/svelte/icons/wheat';
import Croissant from '@lucide/svelte/icons/croissant';
import Flame from '@lucide/svelte/icons/flame';
import CookingPot from '@lucide/svelte/icons/cooking-pot';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import "$lib/css/action_button.css"
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
@@ -1,5 +1,10 @@
<script>
import { Timer, Wheat, Croissant, Flame, CookingPot, UtensilsCrossed } from '@lucide/svelte';
import Timer from '@lucide/svelte/icons/timer';
import Wheat from '@lucide/svelte/icons/wheat';
import Croissant from '@lucide/svelte/icons/croissant';
import Flame from '@lucide/svelte/icons/flame';
import CookingPot from '@lucide/svelte/icons/cooking-pot';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
let { data } = $props();
// svelte-ignore state_referenced_locally
+29 -17
View File
@@ -122,6 +122,32 @@
});
}
/** @param {string} s */
function normalizeSearchText(s) {
return s.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.replace(/&shy;|­/g, '');
}
// Memoised normalized search string per recipe. Building it is the hot
// path (NFD + regex replace for every recipe × every keystroke), so we
// compute it once per recipe array and reuse across keystrokes. Shipping
// a pre-normalized `_searchKey` from the server would duplicate the text
// fields over the wire — this keeps the payload small and amortises the
// cost on the client instead.
/** @type {WeakMap<object, string>} */
const searchIndex = new WeakMap();
/** @param {any} recipe */
function searchStringFor(recipe) {
const cached = searchIndex.get(recipe);
if (cached !== undefined) return cached;
const raw = [recipe.name || '', recipe.description || '', ...(recipe.tags || [])].join(' ');
const norm = normalizeSearchText(raw);
searchIndex.set(recipe, norm);
return norm;
}
// Perform search directly (no worker)
/** @param {string} query */
function performSearch(query) {
@@ -138,25 +164,11 @@
}
// Normalize and split search query
const searchText = query.toLowerCase().trim()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "");
const searchTerms = searchText.split(" ").filter((/** @type {string} */ term) => term.length > 0);
const searchText = normalizeSearchText(query.trim());
const searchTerms = searchText.split(' ').filter((/** @type {string} */ term) => term.length > 0);
// Filter recipes by text
const matched = filteredByNonText.filter((/** @type {any} */ recipe) => {
// Build searchable string from recipe data
const searchString = [
recipe.name || '',
recipe.description || '',
...(recipe.tags || [])
].join(' ')
.toLowerCase()
.normalize('NFD')
.replace(/\p{Diacritic}/gu, "")
.replace(/&shy;|­/g, ''); // Remove soft hyphens
// All search terms must match
const searchString = searchStringFor(recipe);
return searchTerms.every((/** @type {string} */ term) => searchString.includes(term));
});
@@ -0,0 +1,178 @@
<script lang="ts">
import X from '@lucide/svelte/icons/x';
type CardType = 'supercard' | 'cumulus' | null;
let { card = $bindable(null), hasSupercard = false, hasCumulus = false } = $props<{
card?: CardType;
hasSupercard?: boolean;
hasCumulus?: boolean;
}>();
function close() { card = null; }
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') close();
}
const showSupercard = $derived(card === 'supercard' && hasSupercard);
const showCumulus = $derived(card === 'cumulus' && hasCumulus);
</script>
<svelte:window onkeydown={onKeydown} />
{#if card}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={close}>
<div
class="modal"
class:is-supercard={showSupercard}
class:is-cumulus={showCumulus}
role="dialog"
aria-modal="true"
aria-label={showSupercard ? 'Coop Supercard' : 'Migros Cumulus'}
tabindex="-1"
onclick={(e) => e.stopPropagation()}
>
<button class="close-button" onclick={close} aria-label="Schliessen">
<X />
</button>
{#if showSupercard}
<div class="brand-head">
<span class="brand">SUPERCARD</span>
<span class="sub">Coop</span>
</div>
<div class="barcode barcode-square">
<img src="/shopping/supercard.svg" alt="Supercard Data Matrix" />
</div>
{:else if showCumulus}
<div class="brand-head">
<span class="brand">CUMULUS</span>
<span class="sub">Migros</span>
</div>
<div class="barcode barcode-linear">
<img src="/shopping/cumulus.svg" alt="Cumulus barcode" />
</div>
{/if}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal {
position: relative;
border-radius: 24px;
padding: 1.5rem 1.25rem 1.25rem;
width: 100%;
max-width: 440px;
color: white;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal.is-supercard {
background: linear-gradient(135deg, #0a6fc2 0%, #055a9e 100%);
}
.modal.is-cumulus {
background: linear-gradient(135deg, #ff6a00 0%, #e55300 100%);
}
/* Red cross button — same pattern as BibleModal */
.close-button {
position: absolute;
top: -1rem;
right: -1rem;
background-color: var(--nord11);
border: none;
cursor: pointer;
padding: 0.75rem;
border-radius: var(--radius-pill);
color: white;
transition: var(--transition-normal);
box-shadow: 0 0 1em 0.2em rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.close-button :global(svg) {
width: 1.5rem;
height: 1.5rem;
}
.close-button:hover {
background-color: var(--nord0);
transform: scale(1.1);
box-shadow: 0 0 1em 0.4em rgba(0, 0, 0, 0.35);
}
.close-button:active {
transition: 50ms;
scale: 0.9 0.9;
}
.brand-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
padding: 0 0.25rem;
}
.brand {
font-weight: 800;
font-size: 1.4rem;
letter-spacing: 0.1em;
}
.sub {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.16em;
opacity: 0.9;
}
.barcode {
background: white;
border-radius: 14px;
padding: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.barcode img {
display: block;
image-rendering: pixelated; /* crisp barcode modules at any scale */
}
.barcode-square img {
width: 100%;
max-width: 360px;
height: auto;
aspect-ratio: 1 / 1;
}
.barcode-linear img {
width: 100%;
height: auto;
min-height: 140px;
max-height: 30vh;
}
@media (max-width: 480px) {
.backdrop { padding: 0.5rem; }
.modal { padding: 1.25rem 1rem 1rem; border-radius: 20px; }
.brand { font-size: 1.25rem; }
.barcode { padding: 0.75rem; }
.barcode-square img { max-width: none; }
.barcode-linear img { min-height: 160px; }
}
</style>
@@ -1,5 +1,6 @@
<script>
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import { getStickerById } from '$lib/utils/stickers';
import {
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
+14 -2
View File
@@ -1,6 +1,18 @@
<script>
import { X, Sparkles, Wind, Bath, UtensilsCrossed, CookingPot, WashingMachine,
Flower2, Droplets, Leaf, ShoppingCart, Trash2, Shirt, Brush } from '@lucide/svelte';
import X from '@lucide/svelte/icons/x';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Wind from '@lucide/svelte/icons/wind';
import Bath from '@lucide/svelte/icons/bath';
import UtensilsCrossed from '@lucide/svelte/icons/utensils-crossed';
import CookingPot from '@lucide/svelte/icons/cooking-pot';
import WashingMachine from '@lucide/svelte/icons/washing-machine';
import Flower2 from '@lucide/svelte/icons/flower-2';
import Droplets from '@lucide/svelte/icons/droplets';
import Leaf from '@lucide/svelte/icons/leaf';
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Shirt from '@lucide/svelte/icons/shirt';
import Brush from '@lucide/svelte/icons/brush';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import Toggle from '$lib/components/Toggle.svelte';
import DatePicker from '$lib/components/DatePicker.svelte';
+1 -1
View File
@@ -23,7 +23,7 @@ export const BODY_PART_CARDS: BodyPartCard[] = [
{ key: 'chest', slugDe: 'brust', labelKey: 'chest', img: 'shoulders.png', paired: false, db: 'chest' },
{ key: 'biceps', slugDe: 'bizeps', labelKey: 'biceps', img: 'bicep.png', paired: true, dbLeft: 'leftBicep', dbRight: 'rightBicep' },
{ key: 'forearms', slugDe: 'unterarme', labelKey: 'forearms', img: 'forearm.svg', paired: true, dbLeft: 'leftForearm', dbRight: 'rightForearm' },
{ key: 'waist', slugDe: 'taille', labelKey: 'waist', img: 'waist.png', paired: false, db: 'waist' },
{ key: 'waist', slugDe: 'taille', labelKey: 'waist', img: 'waist.svg', paired: false, db: 'waist' },
{ key: 'hips', slugDe: 'huefte', labelKey: 'hips', img: 'hips.png', paired: false, db: 'hips' },
{ key: 'thighs', slugDe: 'oberschenkel', labelKey: 'thighs', img: 'thigh.svg', paired: true, dbLeft: 'leftThigh', dbRight: 'rightThigh' },
{ key: 'calves', slugDe: 'waden', labelKey: 'calves', img: 'calves.png', paired: true, dbLeft: 'leftCalf', dbRight: 'rightCalf' }
+4 -4
View File
@@ -8,7 +8,7 @@ import type { Session } from '@auth/sveltekit';
type BriefRecipeWithFavorite = BriefRecipeType & { isFavorite: boolean };
export async function getUserFavorites(fetch: typeof globalThis.fetch, locals: App.Locals, recipeLang = 'rezepte'): Promise<string[]> {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
return [];
@@ -47,10 +47,10 @@ export async function loadRecipesWithFavorites(
recipeLoader: () => Promise<BriefRecipeType[]>,
recipeLang = 'rezepte'
): Promise<{ recipes: BriefRecipeWithFavorite[], session: Session | null }> {
const [recipes, userFavorites, session] = await Promise.all([
const session = locals.session ?? await locals.auth();
const [recipes, userFavorites] = await Promise.all([
recipeLoader(),
getUserFavorites(fetch, locals, recipeLang),
locals.auth()
getUserFavorites(fetch, locals, recipeLang)
]);
return {
+3 -3
View File
@@ -31,7 +31,7 @@ export interface AuthenticatedUser {
export async function requireAuth(
locals: RequestEvent['locals']
): Promise<AuthenticatedUser> {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session || !session.user?.nickname) {
throw json({ error: 'Unauthorized' }, { status: 401 });
@@ -53,7 +53,7 @@ export async function requireGroup(
locals: RequestEvent['locals'],
group: string
): Promise<AuthenticatedUser> {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session || !session.user?.nickname) {
throw json({ error: 'Unauthorized' }, { status: 401 });
@@ -92,7 +92,7 @@ export async function requireGroup(
export async function optionalAuth(
locals: RequestEvent['locals']
): Promise<AuthenticatedUser | null> {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session || !session.user?.nickname) {
return null;
+1 -1
View File
@@ -12,7 +12,7 @@ export async function getShoppingUser(
url: URL
): Promise<string | null> {
// Check session first
const auth = await locals.auth();
const auth = locals.session ?? await locals.auth();
if (auth?.user?.nickname) return auth.user.nickname;
// Check share token
+1 -1
View File
@@ -3,7 +3,7 @@ import type { Actions, PageServerLoad } from "./$types"
import { error } from "@sveltejs/kit"
export const load: PageServerLoad = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
const user = session?.user ?? null;
return {user}
}
@@ -8,7 +8,10 @@
import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Wallet from '@lucide/svelte/icons/wallet';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import ShoppingCart from '@lucide/svelte/icons/shopping-cart';
import { detectCospendLang, cospendRoot, cospendLabels } from '$lib/js/cospendI18n';
let { data, children } = $props();
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, fetch, url }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -1,5 +1,7 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import { existsSync } from 'node:fs';
import { resolveStaticAsset } from '$lib/server/staticAsset';
import { getShoppingUser } from '$lib/server/shoppingAuth';
import { dbConnect } from '$utils/db';
import { ShoppingList, type IShoppingItem } from '$models/ShoppingList';
@@ -12,8 +14,15 @@ function serializeItems(items: IShoppingItem[]): ShoppingItem[] {
}));
}
function loyaltyCards() {
return {
hasSupercard: existsSync(resolveStaticAsset('shopping/supercard.svg')),
hasCumulus: existsSync(resolveStaticAsset('shopping/cumulus.svg'))
};
}
export const load: PageServerLoad = async ({ locals, url }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
const token = url.searchParams.get('token');
// Allow access with valid share token even without session
@@ -25,7 +34,8 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return {
session: null,
shareToken: token,
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] },
loyalty: loyaltyCards()
};
}
}
@@ -37,6 +47,7 @@ export const load: PageServerLoad = async ({ locals, url }) => {
return {
session,
shareToken: null,
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] }
initialList: list ? { version: list.version, items: serializeItems(list.items) } : { version: 0, items: [] as ShoppingItem[] },
loyalty: loyaltyCards()
};
};
@@ -2,7 +2,22 @@
import { onMount, onDestroy, untrack } from 'svelte';
import { getShoppingSync } from '$lib/js/shoppingSync.svelte';
import { SHOPPING_CATEGORIES } from '$lib/data/shoppingCategoryItems';
import { Plus, ListX, Apple, Beef, Milk, Croissant, Wheat, FlameKindling, GlassWater, Candy, Snowflake, SprayCan, Sparkles, Package, Search, Store } from '@lucide/svelte';
import Plus from '@lucide/svelte/icons/plus';
import ListX from '@lucide/svelte/icons/list-x';
import Apple from '@lucide/svelte/icons/apple';
import Beef from '@lucide/svelte/icons/beef';
import Milk from '@lucide/svelte/icons/milk';
import Croissant from '@lucide/svelte/icons/croissant';
import Wheat from '@lucide/svelte/icons/wheat';
import FlameKindling from '@lucide/svelte/icons/flame-kindling';
import GlassWater from '@lucide/svelte/icons/glass-water';
import Candy from '@lucide/svelte/icons/candy';
import Snowflake from '@lucide/svelte/icons/snowflake';
import SprayCan from '@lucide/svelte/icons/spray-can';
import Sparkles from '@lucide/svelte/icons/sparkles';
import Package from '@lucide/svelte/icons/package';
import Search from '@lucide/svelte/icons/search';
import Store from '@lucide/svelte/icons/store';
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
import { flip } from 'svelte/animate';
import { slide } from 'svelte/transition';
@@ -10,7 +25,15 @@
import catalogData from '$lib/data/shoppingCatalog.json';
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
import { Share2, X, Copy, Check } from '@lucide/svelte';
import Share2 from '@lucide/svelte/icons/share-2';
import CreditCard from '@lucide/svelte/icons/credit-card';
import LoyaltyCards from '$lib/components/shopping/LoyaltyCards.svelte';
import X from '@lucide/svelte/icons/x';
import Copy from '@lucide/svelte/icons/copy';
import Check from '@lucide/svelte/icons/check';
import { page } from '$app/stores';
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n';
@@ -272,6 +295,12 @@
editSaving = false;
}
// --- Loyalty cards ---
/** @type {'supercard' | 'cumulus' | null} */
let activeCard = $state(null);
const hasSupercard = $derived(!!data.loyalty?.hasSupercard);
const hasCumulus = $derived(!!data.loyalty?.hasCumulus);
// --- Share links ---
let showShareModal = $state(false);
/** @type {{ id: string, token: string, expiresAt: string, createdBy: string, createdAt: string }[]} */
@@ -400,6 +429,16 @@
<div class="header-row">
<h1 class="sr-only">{t('shopping_list_title', lang)}</h1>
<SyncIndicator status={sync.status} />
{#if hasSupercard}
<button class="btn-card btn-card-coop" onclick={() => activeCard = 'supercard'} title="Coop Supercard" aria-label="Coop Supercard">
<CreditCard size={16} />
</button>
{/if}
{#if hasCumulus}
<button class="btn-card btn-card-migros" onclick={() => activeCard = 'cumulus'} title="Migros Cumulus" aria-label="Migros Cumulus">
<CreditCard size={16} />
</button>
{/if}
{#if !isGuest}
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
<Share2 size={16} />
@@ -496,6 +535,8 @@
</div>
<LoyaltyCards bind:card={activeCard} {hasSupercard} {hasCumulus} />
{#if editingItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -669,6 +710,31 @@
background: var(--color-bg-elevated);
color: var(--color-text-primary);
}
.btn-card {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
transition: transform 150ms ease, filter 150ms ease, box-shadow 150ms ease;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.btn-card:hover {
transform: translateY(-1px);
filter: brightness(1.08);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
}
.btn-card:active { transform: translateY(0); filter: brightness(0.95); }
.btn-card-coop {
background: linear-gradient(135deg, #0a6fc2 0%, #055a9e 100%);
}
.btn-card-migros {
background: linear-gradient(135deg, #ff6a00 0%, #e55300 100%);
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, fetch, url }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -3,7 +3,7 @@ import { redirect, fail } from '@sveltejs/kit';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
export const load: PageServerLoad = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -18,7 +18,7 @@ export const load: PageServerLoad = async ({ locals }) => {
export const actions: Actions = {
default: async ({ request, locals, fetch, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session || !session.user?.nickname) {
throw redirect(302, '/login');
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, params, fetch, url }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -3,7 +3,7 @@ import type { PageServerLoad, Actions } from './$types';
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
export const load: PageServerLoad = async ({ fetch, locals, request, url }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session) {
throw redirect(302, '/login');
@@ -13,16 +13,17 @@ import {
type Diocese1969,
type Rite
} from '../../../../calendarI18n';
import { seasonColorFor } from '../../../../calendarColors';
import { rankDotSize, seasonColorFor } from '../../../../calendarColors';
import {
getYear,
getYear1962,
isoFor
} from '$lib/server/liturgicalCalendar';
import type { CalendarDay, SeasonArc, YearDay } from '$lib/calendarTypes';
import type { CalendarDay, FeastDot, SeasonArc, YearDay } from '$lib/calendarTypes';
export type {
CalendarDay,
FeastDot,
ProperSection,
Rite1962Commem,
Rite1962Detail,
@@ -234,13 +235,24 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
}
}
const yearDays: YearDay[] = sortedYear.map((d, i) => ({
// `yearDays` only carries what the ring's needle-color lookup needs for any
// day (feast or ferial). Feast metadata (name, rank) moves into `feastDots`
// below so the client can iterate it directly without filtering 365 entries.
const yearDays: YearDay[] = sortedYear.map((d) => ({
iso: d.iso,
color: d.colorKeys[0] ?? 'GREEN'
}));
const feastDots: FeastDot[] = [];
for (const d of sortedYear) {
if (rankDotSize(d.rank) === 0) continue;
feastDots.push({
iso: d.iso,
name: d.name,
rank: d.rank,
color: d.colorKeys[0] ?? 'GREEN',
seasonKey: filledSeasons[i]
}));
color: d.colorKeys[0] ?? 'GREEN'
});
}
const seasonArcs: SeasonArc[] = [];
let cur: SeasonArc | null = null;
@@ -282,6 +294,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
month,
monthDays,
yearDays,
feastDots,
seasonArcs,
windowStart,
windowEnd,
@@ -291,6 +304,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
todayIso,
selected: selectedEntry,
selectedIso,
session: locals.session ?? (await locals.auth())
session: locals.session ?? await locals.auth()
};
};
@@ -28,6 +28,7 @@
const month = $derived(data.month);
const monthDays = $derived(data.monthDays);
const yearDays = $derived(data.yearDays);
const feastDots = $derived(data.feastDots);
const seasonArcs = $derived(data.seasonArcs);
const today = $derived(data.today);
const todayIso = $derived(data.todayIso);
@@ -267,6 +268,7 @@
{year}
{liturgicalYear}
{yearDays}
{feastDots}
{seasonArcs}
{todayIso}
{selectedIso}
@@ -1,5 +1,5 @@
<script lang="ts">
import type { YearDay, SeasonArc } from './+page.server';
import type { FeastDot, YearDay, SeasonArc } from './+page.server';
import type { CalendarLang } from '../../../../calendarI18n';
import { litBg, litInk, rankDotSize } from '../../../../calendarColors';
import { Tween, prefersReducedMotion } from 'svelte/motion';
@@ -11,6 +11,7 @@
year,
liturgicalYear,
yearDays,
feastDots: feastDotsProp,
seasonArcs,
todayIso,
selectedIso = null,
@@ -25,6 +26,7 @@
year: number;
liturgicalYear: number;
yearDays: YearDay[];
feastDots: FeastDot[];
seasonArcs: SeasonArc[];
todayIso: string;
selectedIso?: string | null;
@@ -161,20 +163,10 @@
return out;
});
// Feast dots: keep only the highest-ranking feast per ISO date, skip ferias.
// The currently-selected feast is omitted because the static needle pin at
// the top of the ring represents it.
const feastDots = $derived.by(() => {
const byDate = new Map<string, YearDay>();
for (const d of yearDays) {
const size = rankDotSize(d.rank);
if (size === 0) continue;
if (d.iso === needleIso) continue;
const cur = byDate.get(d.iso);
if (!cur || rankDotSize(d.rank) > rankDotSize(cur.rank)) byDate.set(d.iso, d);
}
return [...byDate.values()];
});
// Feast dots come pre-filtered from the server (rank > ferial, one per ISO).
// Only strip the currently-selected day here since the needle pin at the top
// already represents it.
const feastDots = $derived(feastDotsProp.filter((d) => d.iso !== needleIso));
// A season can split into multiple arcs within one gregorian year (e.g.
// ChristmasTide spans both Dec 2531 and Jan 113 of the civil year). Each
@@ -206,11 +198,8 @@
);
const activeFeasts = $derived.by(() => {
if (!active) return [] as YearDay[];
return yearDays.filter(
(d) =>
rankDotSize(d.rank) > 0 && d.iso >= active.start && d.iso <= active.end
);
if (!active) return [] as FeastDot[];
return feastDotsProp.filter((d) => d.iso >= active.start && d.iso <= active.end);
});
$effect(() => {
@@ -96,6 +96,6 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
iso,
todayIso,
day1: entry,
session: locals.session ?? (await locals.auth())
session: locals.session ?? await locals.auth()
};
};
@@ -36,7 +36,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
// Fetch angelus streak data for angelus/regina-caeli pages
if (angelusSlugs.has(params.prayer)) {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (session?.user?.nickname) {
try {
const res = await fetch('/api/glaube/angelus-streak');
@@ -54,7 +54,7 @@ export const load: PageServerLoad = async ({ params, url, locals, fetch }) => {
export const actions: Actions = {
'pray-angelus': async ({ request, locals, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
}
@@ -43,7 +43,7 @@ function getMysteryForWeekday(date: Date, includeLuminous: boolean): string {
}
export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
// Read toggle/mystery state from URL search params (for no-JS progressive enhancement)
const luminousParam = url.searchParams.get('luminous');
@@ -105,7 +105,7 @@ export const load: PageServerLoad = async ({ url, fetch, locals, params }) => {
export const actions: Actions = {
pray: async ({ locals, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
return { success: false };
}
@@ -27,7 +27,7 @@ import MysteryImageColumn from "./MysteryImageColumn.svelte";
import { mysteries, mysteriesLatin, mysteriesEnglish, mysteryTitles, mysteryTitlesEnglish, mysteryTitlesLatin, allMysteryImages, getLabels, getLabelsLatin, getMysteryForWeekday, BEAD_SPACING, DECADE_OFFSET, sectionPositions } from "./rosaryData.js";
import { isEastertide, getLiturgicalSeason } from "$lib/js/easter.svelte";
import { setupScrollSync } from "./rosaryScrollSync.js";
import { BookOpen } from "@lucide/svelte";
import BookOpen from '@lucide/svelte/icons/book-open';
let { data } = $props();
// Toggle for including Luminous mysteries (initialized from URL param or default)
@@ -1,5 +1,6 @@
<script>
import { ArrowDown, ArrowLeft } from '@lucide/svelte';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { page } from '$app/stores';
/** @type {number | string | null} */
let expanded = $state(null);
@@ -45,7 +45,12 @@ onNavigate((navigation) => {
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import OfflineSyncButton from '$lib/components/OfflineSyncButton.svelte';
import { BookOpen, Heart, Leaf, LayoutGrid, Palette, Tag } from '@lucide/svelte';
import BookOpen from '@lucide/svelte/icons/book-open';
import Heart from '@lucide/svelte/icons/heart';
import Leaf from '@lucide/svelte/icons/leaf';
import LayoutGrid from '@lucide/svelte/icons/layout-grid';
import Palette from '@lucide/svelte/icons/palette';
import Tag from '@lucide/svelte/icons/tag';
let { data, children } = $props();
let user = $derived(data.session?.user);
@@ -4,12 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const currentMonth = new Date().getMonth() + 1;
const session = locals.session ?? await locals.auth();
// Fetch all_brief, favorites, and session in parallel
const [res_all_brief, userFavorites, session] = await Promise.all([
const [res_all_brief, userFavorites] = await Promise.all([
fetch(`${apiBase}/items/all_brief`).then(r => r.json()),
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
getUserFavorites(fetch, locals, params.recipeLang)
]);
const all_brief = addFavoriteStatusToRecipes(res_all_brief, userFavorites);
@@ -19,8 +19,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals, url }) => {
const strippedName = stripHtmlTags(item.name);
const strippedDescription = stripHtmlTags(item.description);
// Get session for user info
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
return {
item,
@@ -34,7 +33,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals, url }) => {
export const actions: Actions = {
toggleFavorite: async ({ request, locals, url, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
@@ -16,7 +16,7 @@ export const load: PageServerLoad = async ({locals, params}) => {
throw redirect(301, '/rezepte/add');
}
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
return {
user: session?.user
};
@@ -25,7 +25,7 @@ export const load: PageServerLoad = async ({locals, params}) => {
export const actions = {
default: async ({ request, locals, params }) => {
// Check authentication
const auth = await locals.auth();
const auth = locals.session ?? await locals.auth();
if (!auth) {
return fail(401, {
error: 'You must be logged in to add recipes',
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
// Redirect to login if not authenticated
if (!session?.user?.nickname) {
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
const callbackUrl = encodeURIComponent(url.pathname);
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ fetch, locals, url, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
// Redirect to login if not authenticated
if (!session?.user?.nickname) {
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
import { errorWithVerse } from '$lib/server/errorQuote';
export const load: PageServerLoad = async ({ locals, params, url, fetch }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
// Redirect to login if not authenticated
if (!session?.user?.nickname) {
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const [res, allRes, userFavorites, session] = await Promise.all([
const session = locals.session ?? await locals.auth();
const [res, allRes, userFavorites] = await Promise.all([
fetch(`${apiBase}/items/category/${params.category}`),
fetch(`${apiBase}/items/all_brief`),
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
getUserFavorites(fetch, locals, params.recipeLang)
]);
const [items, allRecipes] = await Promise.all([res.json(), allRes.json()]);
@@ -68,7 +68,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
const apiRes = await fetch(`/api/rezepte/items/${params.name}`);
const recipe = await apiRes.json();
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
return {
recipe: recipe,
user: session?.user
@@ -78,7 +78,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals}) => {
export const actions = {
default: async ({ request, locals, params }) => {
// Check authentication
const auth = await locals.auth();
const auth = locals.session ?? await locals.auth();
if (!auth) {
return fail(401, {
error: 'You must be logged in to edit recipes',
@@ -3,7 +3,7 @@ import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
const callbackUrl = encodeURIComponent(`/${params.recipeLang}/favorites`);
@@ -11,43 +11,24 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
}
try {
const [res, allRes] = await Promise.all([
fetch(`${apiBase}/favorites/recipes`),
fetch(`${apiBase}/items/all_brief`)
]);
const res = await fetch(`${apiBase}/favorites/recipes`);
if (!res.ok) {
return {
favorites: [],
allRecipes: [],
error: 'Failed to load favorites'
};
}
const [favorites, allRecipes] = await Promise.all([res.json(), allRes.json()]);
// Mark all favorites with isFavorite flag for filter compatibility
const favoritesWithFlag = favorites.map((recipe: any) => ({
...recipe,
isFavorite: true
}));
// Get favorite IDs for marking in allRecipes
const favoriteIds = new Set(favoritesWithFlag.map((r: any) => r._id));
const allRecipesWithFavorites = allRecipes.map((recipe: any) => ({
...recipe,
isFavorite: favoriteIds.has(recipe._id)
}));
const favorites = await res.json();
return {
favorites: favoritesWithFlag,
allRecipes: allRecipesWithFavorites,
favorites: favorites.map((recipe: any) => ({ ...recipe, isFavorite: true })),
session
};
} catch (e) {
return {
favorites: [],
allRecipes: [],
error: 'Failed to load favorites'
};
}
@@ -33,12 +33,12 @@
function handleSearchResults(ids: Set<string>, categories: Set<string>) {
matchedRecipeIds = ids;
hasActiveSearch = ids.size < (data.allRecipes?.length || data.favorites.length);
hasActiveSearch = ids.size < data.favorites.length;
}
const filteredFavorites = $derived.by(() => {
if (!hasActiveSearch) return data.favorites;
return data.allRecipes.filter((r: any) => matchedRecipeIds.has(r._id));
return data.favorites.filter((r: any) => matchedRecipeIds.has(r._id));
});
</script>
@@ -93,7 +93,7 @@
<p class="to-try-link"><a href="/{data.recipeLang}/to-try">{labels.toTry} &rarr;</a></p>
<Search favoritesOnly={true} lang={data.lang} recipes={data.allRecipes || data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
<Search favoritesOnly={true} lang={data.lang} recipes={data.favorites} isLoggedIn={!!data.session?.user} onSearchResults={handleSearchResults}></Search>
{#if data.error}
<p class="empty-state">{labels.errorLoading} {data.error}</p>
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const [item_season, icons, userFavorites, session] = await Promise.all([
const session = locals.session ?? await locals.auth();
const [item_season, icons, userFavorites] = await Promise.all([
fetch(`${apiBase}/items/icon/` + params.icon).then(r => r.json()),
fetch(`${apiBase}/items/icon`).then(r => r.json()),
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
getUserFavorites(fetch, locals, params.recipeLang)
]);
return {
@@ -8,11 +8,8 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res_season = await fetch(`${apiBase}/items/in_season/` + current_month);
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
]);
const session = locals.session ?? await locals.auth();
const userFavorites = await getUserFavorites(fetch, locals, params.recipeLang);
return {
season: addFavoriteStatusToRecipes(item_season, userFavorites),
@@ -7,11 +7,8 @@ export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const res_season = await fetch(`${apiBase}/items/in_season/` + params.month);
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
]);
const session = locals.session ?? await locals.auth();
const userFavorites = await getUserFavorites(fetch, locals, params.recipeLang);
return {
month: params.month,
@@ -4,11 +4,11 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`;
const [res_tag, allRes, userFavorites, session] = await Promise.all([
const session = locals.session ?? await locals.auth();
const [res_tag, allRes, userFavorites] = await Promise.all([
fetch(`${apiBase}/items/tag/${params.tag}`),
fetch(`${apiBase}/items/all_brief`),
getUserFavorites(fetch, locals, params.recipeLang),
locals.auth()
getUserFavorites(fetch, locals, params.recipeLang)
]);
const [items_tag, allRecipes] = await Promise.all([res_tag.json(), allRes.json()]);
@@ -4,7 +4,7 @@ import { ToTryRecipe } from '$models/ToTryRecipe';
import { dbConnect } from '$utils/db';
export const load: PageServerLoad = async ({ locals, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user) {
const callbackUrl = encodeURIComponent(`/${params.recipeLang}/to-try`);
@@ -6,7 +6,7 @@ import { error } from '@sveltejs/kit';
import mongoose from 'mongoose';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
@@ -29,7 +29,7 @@ export const GET: RequestHandler = async ({ locals }) => {
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
@@ -67,7 +67,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
@@ -5,7 +5,7 @@ import { dbConnect } from '$utils/db';
import { error } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals, params }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
return json({ isFavorite: false });
@@ -7,7 +7,7 @@ import { error } from '@sveltejs/kit';
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Authentication required');
@@ -65,10 +65,10 @@ export const GET: RequestHandler = async ({ params, locals }) => {
germanShortName: recipe.short_name,
translationStatus: t?.translationStatus
}});
return json(JSON.parse(JSON.stringify(englishRecipes)));
return json(englishRecipes);
}
return json(JSON.parse(JSON.stringify(recipes)));
return json(recipes);
} catch (e) {
throw error(500, 'Failed to fetch favorite recipes');
}
@@ -42,10 +42,15 @@ function resolveNutritionData(mappings: any[]): any[] {
});
}
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
await dbConnect();
const en = isEnglish(params.recipeLang!);
// Individual recipes change when the author edits them. 5 min browser + 1 h
// edge cache with SWR lets proxies keep hot recipes fresh without blocking
// on the DB; stale content beyond max-age is tolerable here.
setHeaders({ 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=86400' });
const query = en
? { 'translations.en.short_name': params.name }
: { short_name: params.name };
@@ -4,12 +4,19 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
const dbRecipes = await Recipe.find(approvalFilter, projection).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
// rand_array is seeded by `floor(now / 86400000)` — stable for a full UTC
// day across every caller — so the response is safe to share. 8 h browser +
// 8 h edge cache means at worst the shuffle rolls into the next day a few
// hours late; with SWR the stale payload still ships while a fresh one is
// computed.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -3,12 +3,17 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix } = briefQueryConfig(params.recipeLang!);
await dbConnect();
const field = `${prefix}category`;
const categories = await Recipe.distinct(field, approvalFilter).lean();
return json(JSON.parse(JSON.stringify(categories)));
// Distinct category list changes only on recipe add/edit. 1 h browser cache +
// 1 d edge cache with SWR keeps the chip bar snappy; worst case a newly
// added category shows up an hour late.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
return json(categories);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
return json(JSON.parse(JSON.stringify(recipes)));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(recipes);
};
@@ -3,10 +3,12 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import type {BriefRecipeType} from '$types/types';
export const GET: RequestHandler = async ({params}) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
await dbConnect();
let icons = (await Recipe.distinct('icon').lean());
const icons = await Recipe.distinct('icon').lean();
// Same cache budget as /items/category.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
icons = JSON.parse(JSON.stringify(icons));
return json(icons);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = rand_array(dbRecipes.map(r => toBrief(r, params.recipeLang!)));
return json(JSON.parse(JSON.stringify(recipes)));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(recipes);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -3,10 +3,14 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
import { isEnglish, briefQueryConfig } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter } = briefQueryConfig(params.recipeLang!);
await dbConnect();
// Same cache budget as /items/category — distinct-values list that only
// changes on recipe edit.
setHeaders({ 'Cache-Control': 'public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800' });
if (isEnglish(params.recipeLang!)) {
const recipes = await Recipe.find(approvalFilter, 'translations.en.tags').lean();
const tagsSet = new Set<string>();
@@ -15,9 +19,9 @@ export const GET: RequestHandler = async ({ params }) => {
recipe.translations.en.tags.forEach((tag: string) => tagsSet.add(tag));
}
});
return json(JSON.parse(JSON.stringify(Array.from(tagsSet).sort())));
return json(Array.from(tagsSet).sort());
}
const tags = await Recipe.distinct('tags').lean();
return json(JSON.parse(JSON.stringify(tags)));
return json(tags);
};
@@ -4,7 +4,7 @@ import { dbConnect } from '$utils/db';
import { rand_array } from '$lib/js/randomize';
import { briefQueryConfig, toBrief } from '$lib/server/recipeHelpers';
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ params, setHeaders }) => {
const { approvalFilter, prefix, projection } = briefQueryConfig(params.recipeLang!);
await dbConnect();
@@ -14,5 +14,7 @@ export const GET: RequestHandler = async ({ params }) => {
).lean();
const recipes = dbRecipes.map(r => toBrief(r, params.recipeLang!));
return json(JSON.parse(JSON.stringify(rand_array(recipes))));
// rand_array is seeded per UTC day, same for every caller → cacheable.
setHeaders({ 'Cache-Control': 'public, max-age=28800, s-maxage=28800, stale-while-revalidate=86400' });
return json(rand_array(recipes));
};
@@ -9,7 +9,7 @@ import type { NutritionMapping } from '$types/types';
/** PATCH: Update individual nutrition mappings (manual edit UI) */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
await locals.auth();
locals.session ?? await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
@@ -5,7 +5,7 @@ import { isEnglish } from '$lib/server/recipeHelpers';
import { generateNutritionMappings } from '$lib/server/nutritionMatcher';
export const POST: RequestHandler = async ({ params, locals, url }) => {
await locals.auth();
locals.session ?? await locals.auth();
await dbConnect();
const en = isEnglish(params.recipeLang!);
@@ -72,8 +72,8 @@ export const GET: RequestHandler = async () => {
});
return json({
brief: JSON.parse(JSON.stringify(briefRecipes)),
full: JSON.parse(JSON.stringify(processedFullRecipes)),
brief: briefRecipes,
full: processedFullRecipes,
syncedAt: new Date().toISOString()
});
};
@@ -49,7 +49,7 @@ export const GET: RequestHandler = async ({ url, params, locals }) => {
let recipes: BriefRecipeType[] = dbRecipes.map(r => toBrief(r, params.recipeLang!));
// Handle favorites filter
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (favoritesOnly && session?.user) {
const { UserFavorites } = await import('$models/UserFavorites');
const userFavorites = await UserFavorites.findOne({ username: session.user.nickname });
@@ -71,7 +71,7 @@ export const GET: RequestHandler = async ({ url, params, locals }) => {
});
}
return json(JSON.parse(JSON.stringify(recipes)));
return json(recipes);
} catch (e) {
return json({ error: 'Search failed' }, { status: 500 });
}
@@ -3,7 +3,7 @@ import { ToTryRecipe } from '$models/ToTryRecipe';
import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.groups?.includes('rezepte_users')) {
throw error(403, 'Forbidden');
@@ -20,7 +20,7 @@ export const GET: RequestHandler = async ({ locals }) => {
};
export const POST: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.groups?.includes('rezepte_users')) {
throw error(403, 'Forbidden');
@@ -51,7 +51,7 @@ export const POST: RequestHandler = async ({ request, locals }) => {
};
export const PATCH: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.groups?.includes('rezepte_users')) {
throw error(403, 'Forbidden');
@@ -96,7 +96,7 @@ export const PATCH: RequestHandler = async ({ request, locals }) => {
};
export const DELETE: RequestHandler = async ({ request, locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.groups?.includes('rezepte_users')) {
throw error(403, 'Forbidden');
@@ -3,7 +3,7 @@ import { Recipe } from '$models/Recipe';
import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async ({ locals }) => {
const session = await locals.auth();
const session = locals.session ?? await locals.auth();
if (!session?.user?.nickname) {
throw error(401, 'Anmeldung erforderlich');
@@ -40,7 +40,7 @@ export const GET: RequestHandler = async ({ locals }) => {
translationStatus: recipe.translations?.en?.translationStatus || undefined
}));
return json(JSON.parse(JSON.stringify(result)));
return json(result);
} catch (e) {
console.error('Error fetching untranslated recipes:', e);
throw error(500, 'Fehler beim Laden der unübersetzten Rezepte');
+1 -1
View File
@@ -5,7 +5,7 @@ import { dbConnect } from '$utils/db';
import { error, json } from '@sveltejs/kit';
export const GET: RequestHandler = async ({ locals, url }) => {
const auth = await locals.auth();
const auth = locals.session ?? await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}
+1 -1
View File
@@ -19,7 +19,7 @@ interface DebtSummary {
}
export const GET: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
const auth = locals.session ?? await locals.auth();
if (!auth || !auth.user?.nickname) {
throw error(401, 'Not logged in');
}

Some files were not shown because too many files have changed in this diff Show More