Represent a multi-day hike as separate named GPX <trk> elements, one per
stage, while still treating the whole thing as one route on the overview.
GPX & build:
- gpx.ts: parseGpxStages (one stage per <trk>) + multi-track buildGpx.
- build-hikes.ts: per-stage stats with totals summed across stages so the
overnight gaps (distance, time) and the altitude jump between stages are
excluded; previewBreaks recorded where stages sit >1 km apart.
- types: HikeStage, manifest `stages?` and `previewBreaks?` (both optional —
single-stage hikes are unchanged).
Detail page:
- HikeStageNav: a light itinerary-stepper switcher (numbered nodes, active
glows in the accent) writing a shared stageStore.
- Selecting a stage scopes the metrics, elevation profile (x-window),
map (highlight + zoom, dim the rest) and photo strip/markers; "Alle
Etappen" shows the whole route.
Overview: live map and the prerendered static composite both break the
preview line across >1 km inter-stage transfers (previewBreaks).
Route builder:
- Mark any placed waypoint as a stage start (named) from the waypoint list
or the detail panel; export assembles each stage independently into its
own <trk>; import re-marks stage boundaries from a multi-track GPX.
Work-in-progress route-builder checkpoint:
- New RouteStatsBar and WaypointDetailPanel components.
- EditMap / ImageDropzone / WaypointTable / builderStore updates.
- Hoist the elevation gain/loss/range helpers out of build-hikes.ts into
src/lib/hikes/elevation.ts so the builder and the build share one
implementation.
Also bundled here (same file, couldn't be split cleanly): build-hikes.ts
now detects each hike's country at build time — 'CH' when a Swiss canton
matched, otherwise an OSM/Nominatim reverse-geocode — and writes it to the
manifest, feeding the new Kanton/Land filter.
Add two filters to the /hikes filter panel:
- "Nur Touren in der aktuellen Saison" toggle — keeps only hikes whose
recommended season window covers the current month (year-wrap aware;
hikes without a window count as year-round).
- "Kanton / Land" — a typeahead that abstracts the hike's area over the
border: Swiss hikes group by canton (coat-of-arms), hikes abroad by
country (flag). Generalised the tag typeahead into ChipTypeahead
(optional icon + label mapping) and reused it for both tags and areas.
Supporting bits: countries.ts (ISO/name → flag), hikeArea.ts (the
canton-or-country resolver, namespaced so codes can't collide), prepared
flag SVGs for CH/DE/IT/AT/FR, and an optional `country` field on the hike
manifest type (populated by the build script; the app falls back to canton
for Swiss hikes until a rebuild).
Map interaction:
- Overview map: widen the canvas renderer hit-test (tolerance) so a route
can be hovered/clicked from a comfortable margin instead of demanding a
pixel-perfect click on the thin line.
- Detail map: drive the elevation cursor from a whole-map mousemove that
snaps to the nearest track point within ~70 px (track cached in
layer-point space, refreshed on zoom/move), instead of requiring the
pointer to ride exactly on the trail. The hover pin now renders for
map-sourced hovers too, and is recoloured to nord red as a distinct
"you are here" marker. Trail polyline made non-interactive.
Detail page:
- Move the photo strip above the stats row and trim it (3:2 cards).
- Add a fullscreen lightbox: an expand button on each card opens the
full-res image with prev/next, arrow keys, Esc, backdrop-close and a
body-scroll lock; opening/stepping syncs the map + strip. The card's
existing click (map-position sync) is preserved.
- Cap inline prose images at 680 px (centered) so they don't blow up to
full width in the single-column layout on wider screens; the desktop
two-column layout is unaffected.
Replace the flat always-open filter slab with a collapsed command bar:
a result/totals summary, removable active-filter chips, and a Filter
toggle (with active-count badge). The control panel expands in-flow
below the bar (slide), pushing the card grid down instead of overlaying
it, with breathing room added between the hero map and the bar.
- Range filters are now dual-thumb sliders (lower + upper bound) for
distance, duration, ascent and descent, via a new RangeSlider
component (pointer + keyboard, crossover hand-off). Shared bounds math
lives in filterBounds.ts so slider extents and the page's default
filter state can't drift.
- Difficulty selection renders the actual SAC trail-sign markers
(T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-white),
matching the hike cards; selected signs light up in full colour while
unselected ones are dimmed.
- Tag selection uses a typeahead (input + dropdown + removable chips),
mirroring the recipe filter but themed with the semantic variables.
Trail polyline now uses the same SAC white-red-white red as the /hikes
overview and detail pages so the live preview reads as the final
published track. Dropping geolocated images now reframes the edit map
to the new bounds via a shared `mapView.fitTick` signal — covers GPX
imports too if they ever wire it up.
After a GPX import the image-anchor waypoints carry just their
content hash (no thumbnail). Drop the source JPEGs into the same
image dropzone and they're now hashed and matched against existing
waypoints first — on a hit, the thumbnail + full-resolution cache
are patched onto the matched row instead of creating a duplicate
waypoint marker on the map.
A blue 'Bildvorschau ergänzt' status row + contextual hint at the
top of the dropzone ('N Bild-Wegpunkte warten auf eine Vorschau —
Originale hier ablegen') makes the round-trip workflow discoverable.
Existing fresh-upload behaviour (new image → new chronologically-
inserted waypoint with EXIF GPS) is unchanged for any image whose
hash isn't already known to the builder.
Lets the user re-load a previously-exported GPX and keep iterating
on the same route — add a waypoint, fix a turn, retag an image —
without rebuilding from scratch.
The exported GPX interleaves user-anchor waypoints with densified /
snapped intermediates in a single `<trkseg>`. The importer doesn't
try to perfectly round-trip "manual waypoint vs intermediate";
instead it recovers the *image* anchors by matching `<wpt>`
coordinates against the trkpt sequence (1e-5° tolerance, ≈1 m),
plus the start + end trkpts, and reconstructs routedSegments from
the trkpts between adjacent anchors. The intermediate geometry is
preserved verbatim — no re-routing, no second elevation pass.
Image waypoints carry their `imageHash` + `imageVisibility` across
the round-trip so the build script can still re-attach the source
JPEGs on the next publish. Visual previews from those hashes are
deferred to a follow-up — for now an image anchor renders as a
hash-only badge in the waypoint table.
Auto-snap is forced off after import so the freshly-loaded geometry
isn't immediately overwritten by a routing API call. UI: a "GPX
laden" link-style button next to the existing Reset, confirms
before replacing a non-empty draft.
The pure parsers (`parseGpx`, `parseGpxImageRefs`) move from
`$lib/server/gpx` to `$lib/gpx` so the browser-side importer can
use them; the server module re-exports for back-compat.
Two upstream constraints were causing the densified route-builder
tracks to ship without `<ele>` on most points:
1. api3.geo.admin.ch's elevation services reject `sr=4326` outright
(HTTP 400: "Please provide a valid number for the spatial
reference system model: 21781, 2056"). Add an in-process
WGS84→LV95 converter using Swisstopo's published approximation
(≈1 m positional accuracy, well below the DTM grid resolution)
and switch both `height` and `profile.json` to sr=2056.
2. profile.json GET silently 414s once the URL crosses ~8 KB. At
our densified-track sizes a 200-coord chunk hit ~9.6 KB and got
dropped — that's why only a handful of segments came back with
elevations. Switch to POST + form-encoded body; chunk size can
safely go to 500 coords.
UX: extend the busy state to cover the densify+elevate path
(previously only set during snap-to-route) and add a mode-agnostic
status chip in the header that pulses amber while the elevation
request is in flight. GPX download button is now disabled while
busy so the file can't be exported half-finished.
With "snap to route" off, every waypoint pair shipped as a 2-point
linear segment with no `<ele>` anywhere — the page only enriched
elevations inside the snap path. The resulting GPX had a flat
altitude profile and the build script's gain/loss/min/max metrics
came out all zero.
Two changes:
* New `densifyLinearSegments` (default 25 m, matches Swisstopo's
coarsest DTM) walks every 2-point segment and seeds intermediate
vertices along the great circle. Snapped segments (already many
points from BRouter) are left alone.
* Page reactor now runs the same Swisstopo elevation enrichment in
the autoSnap-off path, so the GPX carries per-trkpt `<ele>` even
for fully manual / cross-country routes.
Elevation source unchanged: Swisstopo profile.json (COMB → DTM2 →
DTM25 fallback) is already the highest-resolution provider for our
Swiss-coverage hikes; no point swapping.
Also unifies the snap-path's inline enrichment call into the same
helper so there's one elevation code path instead of two.
Live and static heroes on the detail page were rendered in Nord red,
while the same trail on the /hikes overview map already used the
high-contrast SAC palette (orange/red/blue). The mismatch made the
detail trail look muted against the Pixelkarte; the overview's choice
also doubles as a navigational hint ("the orange trail you saw on
the map is this one").
Introduce $lib/data/sacColors as the single source of truth so
HikeMap, HikesOverviewMap, and the build-side static renderer all
pull the same palette. Bump HERO_RENDER_VERSION to v6 so stale
Nord-red static heroes get re-rendered on the next build.
Two bugs:
* Toggling a tag in the filter bar didn't update `?tag=` in the URL,
so the page wasn't shareable / back-button-restorable past the
initial deep-link state. Add a writer $effect that mirrors
`filter.tags` into the URL via `replaceState` (no history churn).
* The overview map's polylines + camera were built once on mount and
never refreshed when the `hikes` prop changed, so filtering left
the map showing the full set and zoomed for it. Extract polyline
rendering into a function called both on mount and from a
prop-watching $effect; on change, smoothly fly to the new union.
Detail-page tag chips become anchor links to `/hikes?tag=<name>`.
HikesFilterBar grows a tags fieldset (sorted by frequency, with the
hash prefix the chips use) so the user can keep narrowing from there.
Multi-tag filtering is OR — a hike matching any selected tag stays
visible. AND would shrink the listing fast given how few tags most
hikes carry; OR matches what "show me more like this" feels like.
The overview page reads `tag` query params on mount and pre-fills the
filter — supports repeated params (`?tag=winter&tag=easy`).
Tag chips slot between the metric tiles and the elevation profile —
muted pills with a hash prefix so they read as framing context, not
another row of glance-info competing with the numbers.
The standalone "GPX herunterladen" pill (centred, primary-styled,
above the photo strip) moves into a small footer at the bottom of
the article, grouped with waypoint count, publish date, and the
required swisstopo attribution. GPX is a power-user export, not a
CTA — placing it with other ancillary metadata matches its weight.
26 public-domain coats of arms fetched once from Wikimedia Commons
via scripts/download-cantons.ts and committed under static/cantons/.
$lib/data/cantons.ts maps Swisstopo's free-form name (German default,
French/Italian alternates for Romandie / Ticino) to the ISO code +
emblem URL.
Card shows an 18×22 emblem, detail page a 24×30 one — both with a
drop-shadow so they read against the dark hero gradient. Unknown
canton names fall back to plain text without the emblem.
The downloaded SVGs are written verbatim — earlier draft prepended a
provenance HTML comment but that breaks the leading `<?xml … ?>` and
browsers refuse to render the image. Provenance lives in the script's
CANTONS table instead.
The wide static hero picks its zoom for a desktop-sized container
(fitWidth 1920), so on phones the bbox lands too zoomed-in: most of
the route falls outside the visible 400 CSS px band.
Build now emits a second pose per hero — rendered with fitWidth 400 /
fitHeight 480 onto a 1200² canvas — so the auto-fit zoom matches what
Leaflet picks at the same container size. Per-hike hero gains four
variants total (theme × viewport); overview hero gains two.
The page picks which `<img>` to show via a `max-width: 560px` media
query (no JS needed for the swap), and `matchMedia` decides which
pose to hand to Leaflet's first `setView` so the static→live cross-
fade aligns regardless of viewport.
Drive-by: replace the long-stale `hike.heroMapUrl` reference in the
detail page's track-loading fallback with `hike.heroMapUrlLight`.
Mirrors the per-hike detail-page hero on the /hikes index. Build emits one
WebP at the union bbox of every visible hike with each preview polyline
drawn in its SAC-tier colour; page renders it under the live Leaflet map
and fades it out once the first tile batch loads.
Tile fetcher now distinguishes HTTP 4xx ("intentionally blank — outside
Switzerland") from real network errors, so the larger overview canvas
that extends into DE/IT/FR doesn't trip the network-failure abort.
Each hike now ships two SSR-friendly hero images (light + dark theme),
composited at build time from Swisstopo tiles plus an SVG overlay of the
trail polyline, start/end markers, and per-photo camera badges. The
detail page renders the right variant immediately at first paint, then
hands over to live Leaflet without visible jumps.
Renderer (scripts/staticHikeMap.ts):
- Parallel tile fetcher with on-disk cache (scripts/.cache/swisstopo-
tiles/) for re-build idempotency.
- `computeStaticMapPose` picks the zoom + centre Leaflet's fitBounds
would land on at a reference 1920x640 viewport, so the static frames
the full route on every typical desktop hero.
- Canvas rendered at 3840x2400 — large enough to fully cover ultrawide
/ 4K displays at native pixel size, so `object-fit: none` keeps the
trail pixel-aligned with Leaflet's tile pane.
- SVG overlay: trail in Nord red, start dot Nord green, end dot Nord
red, Lucide `camera` icon inside each photo badge. Photo badge
fill / border / icon-stroke colours are passed per theme so light and
dark variants match the live `.hike-photo-marker .badge` styling
exactly (Nord10/Nord8 fill, Nord6/Nord1 border, white/Nord0 icon
stroke). Map tiles themselves are identical across themes — no naive
invert (it mangles the Pixelkarte palette).
- Public photo markers only — private positions are filtered out so
they don't leak in the SSR image.
Build wiring (scripts/build-hikes.ts):
- `processHero` renders both variants in parallel, hashes inputs per
theme, skips on cache hit. Output filenames carry the content hash so
changes invalidate cleanly via the existing orphan sweep.
- `HikeManifestEntry` gains `heroMapUrlLight`, `heroMapUrlDark`,
`heroMapZoom`, `heroMapCenter`.
Detail page (src/routes/hikes/[slug]/+page.svelte):
- Reserves the hero box height up front (kills CLS).
- Renders both `<img>` tags; CSS picks the right one via `data-theme`
with `prefers-color-scheme` as the fallback.
- `object-fit: none; object-position: center` so the image displays at
native pixel size, perfectly aligned with Leaflet's tile rendering.
- `isolation: isolate` on the hero gives Leaflet's z-index:200+ panes
a stacking context so they can't bleed over the sticky nav.
HikeMap (src/lib/components/hikes/HikeMap.svelte):
- New `initialCenter` / `initialZoom` props — when set, the map opens
with `setView` at the static hero's pose instead of `fitBounds`.
- New `onReady` callback — fires after the post-fly-to-bounds tile
batch finishes loading (or a 350 ms safety timeout), letting the
detail page fade the static out onto fully-painted tiles instead of
onto a brief grey gap.
- Sequence: render static -> Leaflet `setView` to match -> first tile
load -> `flyToBounds(track)` to the natural fit -> wait for new
tiles -> fade static out.
Build pipeline (scripts/build-hikes.ts) parses per-hike GPX, encodes
images via sharp, reverse-geocodes the centroid against Swisstopo and
emits a typed manifest under src/lib/data/hikes.generated.ts (gitignored).
Track JSON + image binaries live outside /static; served in dev by a
small hike-images plugin in vite.config.ts, in prod by nginx (private/
images proxied through Node + X-Accel-Redirect for auth-gating).
/hikes overview: full-bleed Swisstopo hero map (HikesOverviewMap) sits
under the sticky nav, drawing one polyline per route coloured by SAC
tier (T1 yellow Wegweiser, T2/T3 white-red-white, T4-T6 white-blue-
white). Click navigates, hover thickens + tooltips. Layer toggle,
recenter, GPS controls mirror the detail map (minus images toggle).
Cards drop the trail SVG, gain a per-route icon + SAC marker
pictogram on the cover, altitude range, season label, and "Neu" badge
for recently-published hikes. Filter bar + totals strip recompute over
the currently-visible set.
/hikes/[slug]: hero map with elevation profile, photo strip with map
sync, scroll-position pin, GPX download, SAC marker stats + min/max
altitude + season.
Route-builder (/hikes/route-builder): client-side draft persisted to
localStorage, EXIF-driven image placement, snap-to-route via BRouter
(OSRM + linear fallback) and Swisstopo profile.json elevation
enrichment that handles degenerate same-coord segments via the height
endpoint.
Filter init switched from a script-time snapshot of data.hikes (which
sporadically returned a one-hike subset during dev hydration and
locked the page to that single hike) to a post-mount \$effect.
Content under src/content/hikes/ intentionally not included (WIP).
Reapplies the e87b8bd8 mirror-finish behaviour (saved session is
broadcast on Finish so other devices can render the completion overview)
behind a _finishingLocally guard. workout.finish() flips workout.active
synchronously, but completionData is only set after the awaited POST
resolves — without the guard, the redirect effect fired in that gap and
navigated away before the summary could render on desktop.
Last set of an exercise auto-advances to the next unfinished exercise.
View Transitions API animates the swap: vertical slide on the exercise
name and set-counter row, horizontal slide on the set table at >=900px.
Three Web Audio cues — per-set tick, exercise-complete two-note rise,
and workout-complete fanfare on the completion overview.
Switches the active-workout finish button to "FINISH EARLY" /
"VORZEITIG BEENDEN" with an orange tint when any set in the workout is
still incomplete, so users can tell at a glance whether they're
wrapping up cleanly or cutting it short.
Other devices were left on a blank active-workout page when one device
hit Finish. The finish broadcast now carries the saved session document,
and the receiving page builds the completion overview from it. If the
remote end-of-workout has no session payload (cancel, or the finishing
device couldn't post the session), receivers redirect to the workout
home instead of stranding on a blank page.
Rest timer was inlined as a row inside SetTable, tied to a specific set.
Switching to another exercise hid it from view. Now lives below the
exercise focus card, renders whenever a rest is active regardless of
focused exercise, and labels which set/exercise it belongs to when
looking at a different one.
In PWA / Tauri shells with a notch or status bar, the header bar was
stacking 12px on top of the safe-area inset, leaving the bar far below
the system chrome. Use max(12px, inset + 4px) so the constant gap only
applies when there is no inset; otherwise the bar sits 4px below the
inset edge.
Two-hill silhouette (Christmas + Easter peaks) rendered horizontally
with season fills, feast dots riding the curve, dividers between
adjacent same-color seasons, and chevron prev/next year navigation.
Breaks out of the page content width to span the viewport, scrolls
horizontally below 1300px with the selected/today feast auto-centered
on mount and viewport resize.
Floor fertile/peak windows at the prior period's end + 1 day so a
short cycle + long period combo can't predict peak fertility starting
during or right after bleeding. Future cycles also widen the outer
fertile range using observed shortest/longest cycle (Ogino-style),
keeping the peak band narrow around the mean ovulation estimate.
Precache:
- Service worker now precaches /fitness/workout(/active) shells (DE+EN)
on install so a fresh device can log workouts offline without first
visiting /fitness online.
- Layout-level precache adds /fitness/__data.json itself plus the
static sub-routes nutrition/meals and check-in/body-parts (DE+EN).
Unsynced workouts on history:
- History page reads the offline outbox via getQueuedSessions on mount
and merges queued sessions into the displayed list, sorted by
startTime. Duration is computed locally so the Clock stat still
shows.
- SessionCard gains an 'unsynced' prop: renders as a non-clickable
div with an orange-accent border and a CloudOff badge labelled
'Unsynced' / 'Nicht synchronisiert'.
- On window 'online', the page waits briefly for the layout's
flushQueue to drain the outbox, then re-reads the queue and calls
invalidateAll to swap unsynced placeholders for the now-saved
server sessions.
These two pages only fell back to IndexedDB when navigator.onLine was
false. On mobile the device often reports online while the origin is
flaky (502 / slow cellular / cached shell with stale connectivity), so
the API call returned nothing and the pages rendered 0 recipes. Now
both also fall back to IndexedDB when the API attempt yields an empty
list, matching the pattern already used by [name]/+page.ts and
icon/[icon]/+page.ts.
Service worker previously only fell back to cache when fetch threw
(network unreachable). A 502/503/504 from the origin returned
successfully with !response.ok, so the bad page was passed through to
the user. Now upstream 5xx is treated like a network failure: try
cached page, then offline-shell redirect for recipe routes, then the
styled offline page. 4xx still passes through unchanged.
Force showLatin=false in Prayer wrapper when hasLatin is false so spacing
and red rubric icons stay correct regardless of toggle state. Also hide
the Latin toggle on individual non-bilingual prayer routes.
New shared StreakInfoButton component — small (i) pip in the corner
of the rosary, Angelus, and Regina Cæli streak counters that opens a
modal with a short reflection on what the counter is for.
The text frames the streak as a tool for forming the *habit* of
regular prayer, not as a metric of piety; warns against mechanical
repetition with Mt 6:7 ("do not heap up empty phrases"); and grounds
the rest in CCC 2698 (rhythms of prayer), 2700 (heart present to him
to whom we are speaking), 2702 (body+spirit, habit forms us), and
2728 (the wounded pride that comes from treating prayer as personal
accomplishment).
Available in DE/EN/LA. Modal dismissable via X, click-outside, or
Escape; honours prefers-color-scheme.
Refactoring:
- StreakCounter and AngelusStreakCounter both render
<StreakInfoButton {lang} /> instead of duplicating the pip+modal.
Parents just declare position:relative as the anchor.
- AngelusStreakCounter is also used for Regina Cæli, so eastertide
visitors get the same explanation there for free.
Bump 1.67.0 -> 1.67.1.
Six new prayers from the German prayer book images, with Latin and
English where canonical versions exist:
43 Jungfrau, Mutter Gottes mein — German Marian devotional. No
canonical Latin/English; best-effort EN translation provided so
the EN route renders rather than blanking. bilingue: false.
40 O meine Gebieterin / O Domina Mea / O My Queen — Marian act of
self-dedication with the standard Latin and English forms.
41 Memorare — attributed to St. Bernard. Standard Latin
(Memento O piissima Virgo Maria) and the traditional English
translation.
42 Hilf, Maria, es ist Zeit — German Marian invocation. No
canonical Latin; EN follows the user-provided translation
("Help, Blessed Mother, it is highest time…"). bilingue: false.
37 Tischgebet vor dem Essen — composite of four sub-prayers:
"O Gott von dem wir alles haben" (DE rhyme, EN best-effort),
"Komm Herr Jesus sei unser Gast" (DE rhyme + standard EN
"Come Lord Jesus be our guest"), Psalm 144:15-16 with Gloria
Patri (full Latin/EN), and Benedic Domine (full Latin/EN).
38 Tischgebet nach dem Essen — three sub-prayers: "Dir sei o Gott"
(DE rhyme + best-effort EN), Agimus tibi gratias (full Latin/EN),
and Retribuere (full Latin/EN).
New 'meal' prayer category (Tischgebete / Meal / Mensae) added to the
filter pills on the prayers index. Replaces the long-standing TODO
that gated meal prayers on the existence of a category for them.
Wiring:
- prayerSlugs.ts gets DE+EN slugs for each prayer (validates
/faith/prayers/<slug> URLs and feeds the offline sync precache list
+ sitemap).
- de/en/la i18n files get six new prayer-name keys plus category_meal.
- [prayer]/+page.svelte: imports, prayerDefs entries, render block.
- [prayers]/+page.svelte: imports, labels, categories array (new meal
pill), prayerCategories, prayers list with searchTerms, getPrayerName
map, prayerMeta, render block.
Eastertide seasonal badge on the prayers index (Regina Cæli card)
moved from a bottom inline-block to absolute top-right, matching the
placement of the same badge on the rosary mystery cards. Adds a
position:relative anchor on the gebet_wrapper.
Bump 1.66.0 -> 1.67.0.
Move OfflineSyncIndicator (logo pip) and OfflineSyncBanner from the
[recipeLang] layout/page to (main)/+layout.svelte and (main)/+page.svelte.
Sync is an app-wide concern, not recipe-specific, and surfacing it on the
homepage gives the entry point users actually see when they install the
PWA. Indicator pulls language from languageStore since (main) doesn't
have data.lang from a recipe-scoped load.
Drop the now-unused .banner-wrap CSS and OfflineSyncIndicator/Banner
imports from the recipe routes.
Auto-sync cadence:
- AUTO_SYNC_INTERVAL 30 min -> 1 week. Recipes don't change often enough
to justify a half-hourly background download (the user explicitly
wanted this dialed back).
- Internal poll tick 5 min -> 1 hour. Polling 12x an hour for a weekly
event is wasted work; hourly is fine and still responsive when the
weekly window opens.
Bump 1.65.3 -> 1.66.0.
MysterySelector: Tempus Paschale badge on the rosary mystery card now
hardcodes white background + dark text, was rendering as muted grey
in dark mode via --color-bg-elevated. Liturgical white doesn't change
between themes anyway.
Faith layout nav: when eastertide is active and the Angelus link is
swapped to Regína Cæli, add a small pulsating white dot in the link's
top-right corner — same pattern as the recipe header sync indicator,
just white (Tempus Paschale color) and slow-breathing (4s). Dark mode
gets a bright white halo; light mode gets a dark drop shadow so the
white pip stays visible against the light nav bar. Honors
prefers-reduced-motion.
Bump 1.65.2 -> 1.65.3.
Service worker:
- Strip version suffix from CACHE_PAGES and CACHE_IMAGES so cached pages
and recipe thumbs survive SW updates. Each deploy used to wipe both,
forcing a re-sync before the user could open the app offline. Build
and static caches stay version-suffixed (entries are hash-fingerprinted).
- Install precaches the offline shells: /, /rezepte, /recipes, both
offline-shell pages, /glaube, /faith, /fitness — best-effort with
Promise.allSettled so a single 5xx can't fail SW install.
- Bulk thumbnail precache now skips URLs already in CACHE_IMAGES.
Recipe thumb URLs embed a content hash, so a cache hit guarantees
identical bytes; subsequent syncs after small recipe edits no longer
redownload every image.
- Activate cleanup deletes only stale versioned build/static entries
and obsolete versioned pages/images caches.
Universal load migration:
- [recipeLang]/+layout.server.ts removed; logic in universal +layout.ts.
Session fetched from /auth/session, nulled when offline.
- [recipeLang]/+page.server.ts and season/[month]/+page.server.ts
removed; merged into universal +page.ts. Drops the __data.json
round-trip entirely for these routes — IndexedDB fallback now runs
even when the SW page cache is empty (fresh install, hash mismatch,
etc.) instead of getting blocked by a 503 from the data handler.
Other:
- /static/rezepte/thumb URLs in sync.ts and the SW thumb fallback now
use the absolute https://bocken.org origin. Dev/preview servers don't
host /static/rezepte and were 404ing on themselves; production keys
resolve to the same string so existing caches stay valid.
- Root +layout.svelte invalidateAll() now bails when !navigator.onLine.
Resume-while-offline used to refetch every load() and surface the
error page instead of the still-viewable cached content.
Bump 1.64.2 -> 1.65.2.
Collapses /errors/<n>.html and /errors/en/<n>.html into a single
prerendered page that shows both languages and reveals the right one
via <html data-lang>. Build script injects an inline bootstrap that
sets data-lang from localStorage before paint and wires the lang +
theme buttons (no Svelte hydration).
Layout already sets Cache-Control; SvelteKit throws when the same
header is set twice in the load chain, 500ing /glaube/apologetik/pro
and the parallel routes.
Add X-Robots-Tag noindex,nofollow handler in hooks.server.ts for /api,
/login, /logout, /register, /settings, /tasks, /fitness, /cospend,
/expenses, and the recipe admin/edit/add/search/favorites/to-try paths.
Header-based so the rule lives in one place and covers JSON responses.
Recipe detail pages now emit a self-canonical pointing at the bare slug —
the layout helper deliberately skipped detail pages, leaving query-param
variants (?multiplier=2, ?utm=…) as duplicate URLs in Google's index.
Per-page Seo on list pages so each ranks for its category-level query:
- Apologetik contra/pro indices now use localized heading + lede instead
of hardcoded English descriptions
- Calendar month view title includes month + rite ("April 2026 ·
Liturgical Calendar (Vetus Ordo) — Bocken")
- Recipe /category, /tag, /icon, /season hub + detail pages get
descriptions via new *_meta_description and *_meta_prefix i18n keys
(added in both DE and EN locales)
Sitemap now declares the image:image namespace and emits an entry per recipe
photo (loc, title from recipe name, caption from alt text) — Google Image
Search can discover all recipe images directly instead of relying on crawl.
Pro arg pages get Article JSON-LD (headline, claim as description, layer as
articleSection, voice names as keywords, deduped voice cites as citations)
plus BreadcrumbList. Katechese/zehn-gebote gets inline Article + Breadcrumb
with a Thing reference to "Dekalog" and CreativeWork citation of P. Martin
Ramm FSSP's Glaubenskurs.
Static-content load functions now set Cache-Control:
public,max-age=300,s-maxage=3600,stale-while-revalidate=86400 — applied to
apologetik layout, contra index/arg, pro index/arg, and the new katechese
+page.ts files. Recipe detail uses s-maxage=1800. Picked HTTP caching over
SvelteKit prerender to avoid baking session=null into the navbar of routes
shared with the auth-aware faithLang layout.
Set <html lang> from URL prefix via handle hook (was hardcoded "en" despite
mostly German content). Add Person + WebSite + SearchAction graph to root
layout — enables Google sitelinks search box and clusters identity across
git.bocken.org and github.com/AlexBocken via sameAs.
Build apologetikJsonLd.ts: contra args now emit QAPage with one suggestedAnswer
per voiced archetype, citations as CreativeWork. Build breadcrumbJsonLd.ts and
wire BreadcrumbList into recipe detail, contra args, prayer detail, and
calendar day. Calendar day also emits Event schema.
Sitemap now reads recipes directly from MongoDB to populate <lastmod> from
dateModified; static URLs use server-startup ISO date. English recipe URLs
only emitted when translation status is approved.
Add sitemap.xml route enumerating recipes, apologetik args, prayers, and
faith hubs. Drop /static/ from robots.txt — was blocking JSON-LD recipe
images from Google. Add reusable Seo component (OG/Twitter/canonical) and
wire into homepage, faith hub, recipes hub, and apologetik index.
Faith and recipe layouts now emit canonical + hreflang automatically by
swapping known lang slugs; deeper paths whose inner segments aren't safely
translatable (recipe [name], prayer [prayer], apologetik [argId]) are
skipped at the layout and may opt-in per page.
Recipe JSON-LD HowToStep names and baking instructions now resolve via
the recipes i18n table (jsonld_step / jsonld_bake / jsonld_for_duration +
existing at_temp) instead of being hardcoded German — English /recipes/
pages were emitting "Schritt N" in their schema.
Adds prerendered, JS-less, self-contained error pages for nginx
error_page use — served directly from /var/www/errors/ when the
SvelteKit upstream is unreachable or any nginx-originated 4xx/5xx
fires (including the catch-all default_server for unknown hosts).
- /errors/[status] (DE default) + /errors/en/[status] (EN), each
with a header language toggle linking absolute to bocken.org so
the switch works even on unknown-host fallbacks.
- httpStatus param matcher restricts entries to 401/403/404/500/
502/503/504; entries() drives prerender output.
- generate-error-quotes.ts looks up curated bilingual references
in the existing allioli/drb TSV bibles at prebuild time and
writes src/lib/data/errorQuotes.json.
- build-error-page.ts (postbuild) inlines all CSS, strips module
preloads/scripts, rewrites the home-link to canonical https URL,
and emits .html + .gz + .br per status under build/client/errors.
- deploy.sh syncs build/client/errors → /var/www/errors with
http:http ownership for nginx access.