119 Commits

Author SHA1 Message Date
Alexander 467f9a4e71 feat(tasks): vinyl sticker album + fridge-calendar rewards redesign
CI / update (push) Has been cancelled
Replace the pokedex grid on /tasks/rewards with a scrapbook "sticker album":
category pages on warm paper, die-cut glossy vinyl stickers (debossed
silhouettes for missing ones), rarity-scaled holo shine/foil/glow, and a
large sticker-themed detail popup on click. Pages sort by rarity (rarest
category + sticker first; "Allerlei" catch-all last); each category has an
info popover explaining how its stickers drop, with the /tasks tag icons.

Restyle the calendar as a cozy fridge wall-calendar (paper, washi tape,
Fredoka month, cats stuck on dates as tilted die-cut stickers, weekend
tint). Shows only the current user by default; tap a name in the monthly
tally to fold in the other household member (per-person colour dots).

Export ALWAYS_CATEGORIES + getTagsForCategory from stickers util.
2026-06-01 23:42:47 +02:00
Alexander 8bd794bccb perf(build): replace adapter precompress with a parallel, filtered step
adapter-node's `precompress: true` brotli-q11 + gzips every file in
build/client single-threaded — ~150 MB including 91 MB of already-
compressed media (zero gain) and tens of MB of server-only data — adding
minutes to the build. The worst offenders were ML embedding JSONs
(nutrition 35 MB + bls 20 MB + shopping ~4 MB) that are `?url`-imported by
$lib/server/{nutritionMatcher,shoppingCategorizer}.ts and read server-side
via SvelteKit's read(); no browser ever fetches them, so compressing them
is pure waste.

- svelte.config.js: precompress: false.
- scripts/precompress.ts: postbuild step that only compresses text asset
  types, skips binaries and server-only data (bible TSVs by name, embedding
  JSONs by hashed-name pattern), tunes brotli quality down for large files,
  runs gzip+brotli in parallel, and never writes a larger-than-original or
  duplicate sibling.
- package.json: run precompress after build-error-page with
  UV_THREADPOOL_SIZE=12 so the async compression actually parallelizes.
- Delete static/allioli.json: 20 MB, unreferenced anywhere in the repo.
2026-06-01 16:11:52 +02:00
Alexander f52d6b4d4b perf(tasks): show completion sticker instantly
Roll the sticker client-side and render the popup immediately on completion
instead of waiting for the POST roundtrip + DB writes to return it. Preload
the sticker image so the cat is decoded before the bounce-in finishes. The
chosen stickerId is sent to the server, which persists it (falling back to a
server-side roll if missing/invalid).
2026-06-01 16:05:54 +02:00
Alexander 9b5cfe5e49 fix(recipes): build deploy against .env_prod; harden image save
Recipe image upload failed in prod with ENOENT writing the full image.
Root cause: the deploy built against the dev .env, whose relative
IMAGE_DIR="./imgs/" resolves under the service's dist/ working dir
instead of the real served image directory — and `$env/static/private`
is inlined at build time, so dev values shipped to prod.

- deploy.sh: source .env_prod (overridable via PROD_ENV) into the env
  before `pnpm build`, so prod values win over .env for the whole build
  lifecycle; abort if it's missing rather than ship a dev-env build.
- .gitignore: ignore .env_* so .env_prod (prod secrets) isn't committed
  (the existing .env.* dot pattern didn't match the underscore form).
- imageProcessing: mkdir -p the full/thumb dirs before writing. The
  WebP passthrough writes the full image with fs.writeFile, which (unlike
  sharp's toFile) does not create parent dirs.
- recipeFormHelpers: add serializableFormValues() and use it in the add/
  edit actions' fail() returns. Returning raw formData (now containing the
  recipe_image File) crashed the action response with a non-POJO devalue
  error, masking the real failure with an opaque 500.
2026-05-31 13:49:04 +02:00
Alexander 9fe9d95e36 feat(hikes): unify below-map view transition into one sliding panel
The detail-page enter/exit transition previously slid only the metric
tiles up from the bottom — the wrapper had no background, so its
snapshot was transparent and no containing panel moved. The photo strip
also animated separately, sliding in from the right.

Wrap everything below the hero map (stage nav, photo strip, metrics,
tags, elevation, scroll area, footer) in one `.below-map` element with
`view-transition-name: hike-below-map` and an opaque background, so the
whole sheet — background included — slides up on enter and down on exit
as a single panel. Drop the obsolete hike-strip right-slide rules and
keyframes; rename hike-below-strip → hike-below-map.
2026-05-31 13:29:15 +02:00
Alexander cd7912fa8f feat(recipes): client-side image editor + modernize /add to match /edit
CI / update (push) Has been cancelled
Add a from-scratch photo editor (crop, max-resolution scale-to-fit,
WebP quality with live final-size + dimensions readout) that opens on
image pick in the recipe add/edit flow. Conversion uses the browser's
canvas WebP encoder (sharp can't run client-side); crop, scale and the
size readout are built by hand.

Server now stores the client WebP full image byte-for-byte (passthrough)
so the on-disk file matches the user's chosen quality/size; sharp still
derives the 800px thumb and OKLAB colour. Non-WebP uploads keep the old
q90 re-encode fallback.

Rework /add to reuse EditTitleImgParallax (parallax hero +
titleExtras/below-hero layout, shape-tile Backform, SaveFab + optional
translation), replacing the antiquated CardAdd card. Move the edit/remove
image controls into the hero, below the fixed header. Delete now-dead
CardAdd and RecipeEditor.
2026-05-30 15:54:28 +02:00
Alexander fb54f6907f fix(prayers): correct Latin/German/English typos in prayer texts
CI / update (push) Has been cancelled
Spelling and grammar fixes across prayer components (e.g. sanctífica,
peccávi, nequítiam, Hoffnung, Menschen, Geheimnisse), merge cæ ligature
in Credo, restore traditional clause order and close Ps. citation in the
Prayer Before a Crucifix.
2026-05-28 12:23:59 +02:00
Alexander 94c8212078 feat(tile-proxy): Thunderforest Outdoors as foreign karte upstream
OpenTopoMap's hypsometric tint reads "red mountains / green flats" and
looks nothing like the Swisstopo Pixelkarte the proxy hands out
in-region — produced a jarring visual seam right at the CH/LI border.
Thunderforest Outdoors has a muted topo palette + subtle hillshade
that matches the swisstopo tile aesthetic much more closely, so use
it as the abroad `karte` upstream when an API key is available.

- `tile-proxy/build.rs`: reads `tile-proxy/.env` (gitignored) at build
  time and forwards each `KEY=VAL` line to rustc as `--env`, so the
  key is baked into the binary via `option_env!` and never touched at
  runtime. A shell env var of the same name wins over the .env entry
  (dotenv precedence). `cargo:rerun-if-changed=.env` +
  `cargo:rerun-if-env-changed` force a recompile whenever the value
  changes — no stale key cached in the binary.
- `main.rs`: `THUNDERFOREST_API_KEY` read via `option_env!`; foreign
  `karte` is Thunderforest Outdoors when set, OpenTopoMap fallback
  when absent. Behaviour unchanged for keyless builds.
- `mapTiles.ts`: page-footer attribution credits Thunderforest + OSM
  alongside the existing swisstopo / OpenTopoMap / Esri lines so the
  attribution stays correct regardless of which build is deployed.
- `.gitignore`: tile-proxy build artefacts (binary, `target/`, `.env`)
  moved to the root gitignore with fully-qualified paths so the
  source tree isn't hidden by a nested gitignore quirk; the per-dir
  `tile-proxy/.gitignore` is removed.
- README + systemd service: documentation refreshed for the new
  build-time key flow.
2026-05-26 22:56:32 +02:00
Alexander ac76bfba34 feat(hikes): view-transition exit flow off detail page
Fires the same strip-slide-out + below-strip-fly-down animation whenever
a hike detail page is left for anywhere other than another slug — not
just on the back-to-/hikes path. Layout adds a new `vt-exit-hike-detail`
class on the document root for that case; the css rules tag onto the
existing `vt-enter-hikes` selectors so both exits drive identical
animations.
2026-05-26 22:54:35 +02:00
Alexander 0f6c50f854 feat(hikes): no-JS elevation SVG + static trail-col map
- Render the elevation profile as an inline SVG at SSR (filled area
  + 5 ticks per axis + soft horizontal helplines). Chart.js takes
  over via a sticky `chartReady` flag once it imports and paints,
  fading the SVG out.
- Pre-rendered medium hero now underlays the desktop trail-col map,
  cover-cropped and `transform: scale(2.25)`d so the bbox fills the
  slot. Fades on first leaflet tile-paint, same handover as the
  hero map further up.
- Wrap everything below the photo strip in `.below-strip` so the
  view-transition into the detail page can slide the metrics,
  tags, charts, scroll-area and footer as a single block.
2026-05-26 22:48:41 +02:00
Alexander 8a67f5fba8 feat(hikes): medium hero variant + Switzerland-framed overview, drop static→live wobble
Three related improvements to the pre-rendered hero map system:

* New medium viewport variant (561–899 CSS px) for the per-hike detail
  hero and the /hikes overview. Tablet/split-pane viewports were
  getting the wide pose (chosen for ~1920 CSS px), which landed too
  zoomed in. Each variant is rendered at a pose matching its
  container, so the static→Leaflet handover aligns at every band.
  Manifest fields are optional — pages fall back to the wide variant
  on tablets until build-hikes regenerates the images.

* Overview frames on Switzerland (fixed center [46.82, 8.23]) with
  explicit per-variant zooms (wide=8, medium=8, narrow=7) rather than
  auto-fitting the union of hike bboxes. The previous behavior zoomed
  in on whichever corner the catalogue clustered in; this reads as
  "hikes across CH". Bumps OVERVIEW_RENDER_VERSION so cached overview
  images get invalidated on the next build.

* Removed the post-tile-load flyToBounds in both HikeMap.svelte and
  HikesOverviewMap.svelte. The map already opens at the static pose
  via setView; the second auto-fit was adding a visible wobble on
  routes whose bbox sits at an integer-zoom boundary (e.g. the
  Einsiedeln–Unteriberg detail), where the build-time fit and
  Leaflet's runtime fit disagree by one zoom step at the user's
  actual container size.
2026-05-26 11:51:48 +02:00
Alexander b49a299371 feat(hikes): view-transition flow across /hikes ↔ /hikes/[slug]
Cards + filter bar fly up from below when arriving at /hikes and drop
back down when leaving (in both directions of /hikes ↔ detail). Clicked
card morphs into the detail hero with a cross-fade so the thumbnail
dissolves into the map instead of snapping. Photo strip slides in from
the right. Root content cross-fades so metrics + content under the hero
phase in rather than appear at the end of the morph.

Track JSON moves from a client-side $effect into +page.ts so the strip
is in the DOM at view-transition snapshot time — also kills the brief
layout shift when it used to pop in post-load.
2026-05-26 10:34:00 +02:00
Alexander f1c0304b14 fix(hikes): spelling, grammar + copy-paste cleanup in new hike prose
Spelling/grammar across the six new hikes; plus content fixes: drop schlittelwanderung's pasted rain/spring lines, monte-generosa's stray 'S-Bahn ab Einsiedeln', rewrite morgartenberg's Anreise/Heimreise to Biberegg/Sattel, and correct Verzascatal's region (Zentralschweiz -> Tessin).
2026-05-25 15:00:46 +02:00
Alexander 164fdb2916 chore(hikes): track Morgartenberg content 2026-05-25 14:54:05 +02:00
Alexander ae4adc4023 chore(hikes): track Spaziergang Uetliberg content 2026-05-25 14:53:55 +02:00
Alexander 17ccfa1b41 chore(hikes): track Verzascatal content 2026-05-25 14:53:50 +02:00
Alexander dfc3142eeb chore(hikes): track Monte Generosa content 2026-05-25 14:53:36 +02:00
Alexander fdea8416a0 chore(hikes): track Schlittelausflug Brün content 2026-05-25 14:53:32 +02:00
Alexander 399d57217a chore(hikes): track Flims Gletschermühlen content 2026-05-25 14:53:06 +02:00
Alexander 0e70e30738 chore(hikes): swap a Siebengipfelwanderung photo + adjust return time
Replace the last three indexed photos with a named src image and correct the JourneyPlanner return time to 14:00.
2026-05-25 14:52:46 +02:00
Alexander 1918d240db chore(hikes): add Pfäffikersee + Siebengipfelwanderung icons 2026-05-25 14:52:04 +02:00
Alexander 316a340494 perf(build): raise hike image-encode concurrency cap to 10
Allow up to 10 parallel image pipelines (was 4) on hosts with the cores to spare.
2026-05-25 14:51:55 +02:00
Alexander 59b4630746 refactor(hikes): route via self-hosted BRouter instance
Point routeBrouter at a local BRouter server ($BROUTER_URL, default http://127.0.0.1:17777) instead of the public brouter.de, matching the bundled brouter package + systemd service.
2026-05-25 14:51:46 +02:00
Alexander 8459327717 feat(route-builder): show live elevation profile under the map
Render ElevationProfile below the route-builder map, fed by the routed segments (elevations fill in as the swisstopo enrichment resolves). Add a $effect in ElevationProfile so its dataset rebuilds when the track changes live during editing.
2026-05-25 14:51:32 +02:00
Alexander a4c2efe4f3 feat(hikes): re-derive track altitudes from swisstopo + pre-commit hook
Replace noisy phone-GPS <ele> in every committed track.gpx with swisstopo swissALTI3D heights at each exact lat/lon (coordinates unchanged; phone altitude was off by up to ~430m).

- scripts/fix-altitudes.ts: batched swisstopo profile.json lookup, WGS84->LV95, disk-cached, keeps original ele for any out-of-CH point.
- .githooks/pre-commit: auto-corrects any added/modified track.gpx on commit and re-stages it; wired via package.json prepare -> core.hooksPath.
2026-05-25 14:47:05 +02:00
Alexander cb16b25444 chore(hikes): track Walenseewanderung content
Add Walenseewanderung hike (index.svx + track.gpx); fix Anreise text that was copy-pasted from the Siebengipfelwanderung (route is to Amden, not Maschgenkamm).
2026-05-25 13:17:15 +02:00
Alexander 583d1b724c chore(hikes): track Siebengipfelwanderung content
Add Siebengipfelwanderung hike (index.svx + track.gpx); fix 'Flusmerberg' → 'Flumserberg' typo in the prose.
2026-05-25 12:20:06 +02:00
Alexander 3fc539e6fb chore(hikes): track Pfäffikersee content
Add Spaziergang um den Pfäffikersee hike (index.svx + track.gpx); fix German spelling/agreement in the prose.
2026-05-25 12:17:16 +02:00
Alexander c2862f4c21 chore(hikes): track Klausenpasswanderung content
Add the Klausenpass hike (index.svx, track.gpx, icon.svg). Cover photo
and source images stay gitignored, re-encoded into static assets at build.
2026-05-24 20:55:20 +02:00
Alexander 38c3df8187 feat(images): responsive <Image>, gated private images + prose
Build-time image optimization plus auth-gated private content.

- <Image> (src/lib/components/Image.svelte): wraps @sveltejs/enhanced-img
  for public images under src/lib/assets/images/ (AVIF/WebP, multiple
  widths, lazy by default), plus a `private` mode for auth-gated images.
- Private images: scripts/build-private-images.ts encodes sources from
  src/lib/assets/private-images/ into private-assets/ (outside the bundle)
  and a manifest; served only via the auth-checked /private-images/
  endpoint (X-Accel-Redirect in prod, disk read in dev).
- HikeImage gains a `src` prose mode: build-hikes encodes non-waypoint
  images referenced in .svx and exposes them by filename (imagesByName);
  a `private` attr routes them through the gated /hikes/<slug>/private/ path.
- <Private> (src/lib/components/Private.svelte): renders prose only to
  logged-in viewers (cosmetic gating — text still ships in the bundle).
- deploy.sh rsyncs private-assets/; prod needs an nginx internal
  /protected-images/ location.
2026-05-24 20:53:22 +02:00
Alexander 530308033b fix(build): disable prerender crawl so build stops OOMing
`/hikes` is `prerender = true` and carries the global nav, so the
prerender crawler followed those links and tried to statically render
the whole dynamic, DB-/ML-backed app. SvelteKit prerenders inside a
heap-capped worker_threads worker, so this exhausted its heap
(ERR_WORKER_OUT_OF_MEMORY) and failed the build.

- svelte.config.js: prerender.crawl = false. The intended static set is
  fully described by `prerender = true` (/hikes) + the /errors/[status]
  EntryGenerator, so crawling is unneeded. Add a defensive
  handleHttpError that ignores /hikes/*/images/* 404s (those binaries
  live in hikes-assets/, served by nginx/dev-middleware, not /static).
- hooks.server.ts: skip init when `building` so builds don't connect to
  Mongo, start the payment scheduler, or warm the romcal cache.
- hikes/[slug]: set `prerender = false`, enforcing the intent its
  comment already stated.

Version 1.86.1 -> 1.86.2.
2026-05-24 15:41:39 +02:00
Alexander c155fc33b4 fix(hikes): keep stat value + descriptor together in the metrics row
The metrics flex row stretched each tile to the tall route-icon's height,
so the grid's auto rows spread apart and pushed the value and its label to
opposite halves. Center the items instead of stretching.
2026-05-24 10:17:45 +02:00
Alexander a2869c1d87 feat(hikes): anonymize GPX timestamps to 08:00 today
Re-base every track + image timestamp so each hike starts at 08:00 on the
build date, preserving all relative timing (total duration, per-stage gaps,
photo "nach X"). The per-hike track JSON is the single source for the page
metrics and the client-built GPX download, so both come out anonymized; the
real recording times stay only in the private source track.gpx.

Also close two stale-data leaks that would otherwise still expose real
times: sweep prior-build track.*.json (keep only the current hash) and
remove orphan slug dirs from static/ (renamed/deleted hikes).
2026-05-24 10:14:01 +02:00
Alexander a8902dcf11 chore(hikes): track Einsiedeln–Spital–Unteriberg content
Version this hike's writing (index.svx) and route (track.gpx); source
photos (cover.jpg, images/) stay out of git per the .gitignore rules.
2026-05-23 17:08:04 +02:00
Alexander c9b2773de4 chore(hikes): rsync push/pull for source photos
Add scripts/hike-photos.sh (+ pnpm photos:push / photos:pull) to back up and
restore hike source photos over rsync, keeping them out of git. Transfers
only photo files (images/, private/, root cover.*) — matching the .gitignore
rules — so the versioned text (index.svx, track.gpx, icon.svg) is untouched.
Config via REMOTE / HIKE_PHOTOS_DIR with deploy.sh-style defaults; supports
--dry-run and --delete. No build changes: build-hikes still reads local files
and falls back gracefully when a photo is absent.
2026-05-23 17:05:47 +02:00
Alexander 909b02049d chore(hikes): track Rheinau + Muttertagswanderung content
src/content was untracked. Start versioning these two hikes' writing
(index.svx, track.gpx, icon.svg) and add a .gitignore that keeps source
photos out (images/, private/, root cover.*) — they're large and get
re-encoded into static assets at build time. Includes the proofreading pass
on both texts (typos, grammar, gender/agreement). Other hikes stay untracked.
2026-05-23 16:20:33 +02:00
Alexander d4a8288ecf fix(hikes): link journey planner to the current SBB deep-link format
Replace the search.ch link with an sbb.ch deep link that respects the
searched date/time and departure-vs-arrival. The live SBB timetable (2026)
uses stops=<from>~<to> with each stop as `<label>_I<stationId>`, time with
an underscore (09_10), a short dep/arr token, and a `day` mirror of date —
not the older von/nach + colon/ARRIVAL form. Station IDs (didok) are
captured during resolution (transport.opendata.ch returns SBB's IDs);
fall back to label-only stops when an ID can't be resolved.
2026-05-23 16:13:38 +02:00
Alexander 4114b0109f feat(hikes): show elapsed-since-start time on prose photos
Each <HikeImage> overlays a "nach X" badge (elapsed from the first
timestamped track point), matching the photo strip's formatElapsed wording.
Renders only when the photo carries a timestamp; bottom-right, or top-right
when a caption is present so it never overlaps.
2026-05-23 16:13:26 +02:00
Alexander 169f8798f3 feat(hikes): explicit cover.jpg for the listing card
A `cover.*` image (jpg/jpeg/png/webp/heic/heif) in a hike's images/ dir
(or hike root) now always becomes the overview-card cover, overriding the
"first public route photo" heuristic. Unlike route photos it needs no
track.gpx waypoint, is always public, and is excluded from the photo strip;
alt text falls back heroAlt → title → "Titelbild". The shared responsive
encoder is extracted from processImage into encodeImageVariant so the cover
reuses it; its outputs join the orphan-cleanup keep-set.
2026-05-23 16:09:14 +02:00
Alexander 8f843833e0 fix(route-builder): use Swisstopo elevations for snapped routes
routeWaypoints passed BRouter's coarse, SRTM-based inline elevations
straight through, and the client only queried Swisstopo when a point
lacked an altitude — so snapped routes never hit the Swiss terrain model
and shipped a jagged profile that disagreed with the densified off-trail
path. Overwrite every routed point with enrichElevations (geo.admin.ch),
keeping the router's value only as a fallback where Swisstopo returns null
(hikes abroad). Disk-cached by coordinate list, so repeat snaps stay free.
2026-05-22 18:51:02 +02:00
Alexander 35872d731a feat(hikes): SBB-style public-transport journey planner for hike prose
Add JourneyPlanner.svelte: a "Von / Nach" connections widget to drop into a
hike's prose (e.g. with a fixed trailhead destination) so readers can plan
the trip there by public transport. Backed by the free Swiss transport API
(transport.opendata.ch — the same one sbb-tui uses).

- From/To fields: prefillable + per-field lockable (fromFixed / toFixed),
  swap when both editable, and "Aktueller Standort" via geolocation
  (coarse accuracy → fast on laptops; resolves to the nearest stop).
- Station typeahead on both fields via /locations?type=station, so routes
  start/end at canonical stops instead of fuzzy-geocoded points (which was
  yielding roundabout connections); plus resolve-on-search fallback. The
  dropdown grows flush from the field as a route-line spur (matching dots),
  typed text highlighted.
- Separate date + time controls: date defaults to the next weekend day
  (Sat on weekdays, Sun on a Saturday); time + departure/arrival target are
  author-settable props (time="08:00" target="arrival") and reader-editable.
- Results: per-leg transport-type icons (bus/train/tram/ship/cable) and an
  expandable full itinerary (stations, times, platforms, line + direction,
  coloured rails, walk connectors); link to the full search.ch timetable.

Add TimePicker.svelte: a shared pill time picker (HH:MM string, chevron
nudges, hour/minute dropdown) in the same language as DatePicker /
DateTimePicker.
2026-05-22 18:33:23 +02:00
Alexander 2347a02fcb feat(hikes): worldwide maps via a region-switching tile proxy
Add tile-proxy/: a small Rust (axum) service behind nginx that serves one
canonical XYZ scheme (/{karte,luftbild,dufour}/{z}/{x}/{y}) and, per tile,
picks the provider by geometry — swisstopo when the tile overlaps a
swisstopo-covered region (Switzerland or Liechtenstein, each simplified +
2 km buffer; tile-bbox ∩ polygon at every zoom), otherwise OpenTopoMap
(schematic) / Esri World Imagery (satellite), with an auto-fallback for
border 404s. Includes the region generator (gen-regions.mjs), a Makefile,
nginx caching-proxy + systemd examples, and a README. Listen address is
env-driven (TILE_PROXY_ADDR).

App side:
- New mapTiles.ts is the single source for the proxy URLs + combined
  attribution; HikeMap / HikesOverviewMap / EditMap fetch through
  maps.bocken.org instead of swisstopo directly, on-map attribution
  controls removed, preconnect + footer credits updated (swisstopo /
  OpenStreetMap+OpenTopoMap / Esri).
- Region-aware schematic max zoom (isSwissRegion helper): detail map caps
  at z17 abroad and hides the CH/LI-only Dufour layer; overview caps at
  z18 when a shown hike is abroad.
- Route-builder: add the satellite layer via the same bottom-right layer
  popover as the other maps.
2026-05-22 16:26:22 +02:00
Alexander 5540d37c72 feat(route-builder): clearer map cursors, route click tolerance, swisstopo credit
- Pointing-hand cursor over the map (click adds a waypoint); grab/grabbing
  only on the draggable waypoint pins. Fixes the cursor living on the
  Leaflet container element itself (the `.edit-map` div), not a descendant.
- Route polylines render on a canvas renderer with a hit `tolerance`, so a
  click near the line inserts a waypoint without a pixel-perfect hit.
- Hide the on-map attribution control and show the required "Kartendaten ©
  swisstopo" credit in the page footer, matching /hikes.
2026-05-22 14:26:44 +02:00
Alexander 6483c55fce feat(hikes): multi-day stages (separate GPX tracks, stage nav, builder)
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.
2026-05-22 14:14:57 +02:00
Alexander 603240bf93 feat(route-builder): stats bar, waypoint detail panel, elevation refactor
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.
2026-05-22 13:07:24 +02:00
Alexander 53695b8244 feat(hikes): in-season toggle + unified canton/country 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).
2026-05-22 13:06:47 +02:00
Alexander 48d971c216 feat(hikes): hide on-map attribution control, add swisstopo credit
Drop Leaflet's bottom-right attribution control on both the overview and
detail maps for a cleaner frame. The swisstopo tile licence still requires
their credit, so keep it on the page: the detail page already shows it in
the meta footer, and the overview now gets a tiny "Kartendaten © swisstopo"
line at the bottom of the listing.
2026-05-22 12:42:37 +02:00
Alexander bb1d494c48 feat(hikes): forgiving map selection, photo lightbox, detail polish
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.
2026-05-22 12:36:06 +02:00
Alexander 896e42f5d9 feat(hikes): redesign /hikes filter as a quiet command bar
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.
2026-05-22 12:10:18 +02:00
Alexander 7bede8cd64 feat(route-builder): SAC-red trail + refit map on image drop
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.
2026-05-19 21:22:34 +02:00
Alexander 3b524e9c70 feat(route-builder): match dropped images to imported hash-only waypoints
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.
2026-05-19 17:36:38 +02:00
Alexander 59f40b9f05 feat(route-builder): import existing GPX (round-trip editing)
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.
2026-05-19 17:29:34 +02:00
Alexander 7b7fbed472 fix(hikes): repair Swisstopo elevation API (LV95 + POST), add busy chip
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.
2026-05-19 11:01:23 +02:00
Alexander e3ccd96c7b feat(route-builder): densify + elevate off-trail segments
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.
2026-05-19 10:40:24 +02:00
Alexander a1aa722512 feat(hikes): use SAC-tier colours for the detail-page trail
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.
2026-05-19 10:27:31 +02:00
Alexander 706dedbdc5 fix(hikes): sync tag filter to URL + re-fit overview map on filter change
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.
2026-05-19 10:19:08 +02:00
Alexander 2a8721fde0 feat(hikes): clickable tag chips + tag filter on the overview
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`).
2026-05-19 10:13:26 +02:00
Alexander 3331536ddd feat(hikes): show tags + relegate GPX download to a quiet meta footer
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.
2026-05-19 10:04:45 +02:00
Alexander d957c746d5 fix(hikes): decode URL-encoded slugs in dev image middleware (fix ü/ä/ö 404s) 2026-05-19 08:45:14 +02:00
Alexander cfdd58fb18 feat(hikes): inline cantonal Wappen next to region label
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.
2026-05-19 08:44:30 +02:00
Alexander 2c3886296c fix(hikes): square-ish SAC red/blue painted markers (44px → 28px) 2026-05-19 08:30:02 +02:00
Alexander c082da700d feat(hikes): phone-sized static hero variant for ≤560 px viewports
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`.
2026-05-19 08:27:08 +02:00
Alexander fe08e06a02 feat(hikes): pre-rendered overview hero map with same handover pattern
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.
2026-05-19 08:18:23 +02:00
Alexander fd2d8a58d9 feat(hikes): pre-rendered static hero map with smooth Leaflet handover
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.
2026-05-18 23:38:24 +02:00
Alexander f3d16d5187 feat(hikes): route-builder, overview map + cards, SAC-coloured pipeline
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).
2026-05-18 21:13:00 +02:00
Alexander 928774084f fix(fitness): restore SSE mirror-finish without racing local summary
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.
2026-05-12 17:40:41 +02:00
Alexander 8c09b0b2f4 Revert "fix(fitness): mirror finish overview to other devices via SSE"
This reverts commit e87b8bd864.
2026-05-12 17:30:02 +02:00
Alexander 5ac56db46c feat(fitness): auto-advance exercises with view transitions + audio cues
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.
2026-05-12 17:25:54 +02:00
Alexander 5fd8027d3e feat(fitness): label finish button "FINISH EARLY" with unfinished sets
CI / update (push) Has been cancelled
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.
2026-05-10 14:56:12 +02:00
Alexander e87b8bd864 fix(fitness): mirror finish overview to other devices via SSE
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.
2026-05-10 14:42:50 +02:00
Alexander eeed31aaf4 fix(fitness): hoist rest timer above set table, persist across exercise switches
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.
2026-05-10 14:17:36 +02:00
Alexander e59e9679da fix(faith): align calendar view tab labels on timespan + shape
Year/Hills/Month mixed two axes. Now: Year · Ring, Year · Hills,
Month · Grid (with de + la translations).
2026-05-10 14:06:40 +02:00
Alexander 685f4cc892 fix(header): drop extra 12px gap when safe-area inset is present
CI / update (push) Has been cancelled
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.
2026-05-10 13:00:25 +02:00
Alexander 60e651de72 feat(faith): add hills view of liturgical year as calendar tab
CI / update (push) Has been cancelled
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.
2026-05-10 12:48:28 +02:00
Alexander 98417046bc fix(fitness): fertile window no longer overlaps period bleed
CI / update (push) Has been cancelled
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.
2026-05-10 10:46:14 +02:00
Alexander 244050fa75 feat(fitness): cache more fitness shells & show unsynced workouts on history
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.
2026-05-08 16:28:26 +02:00
Alexander 0814803fc7 fix(offline): IndexedDB fallback when API returns empty on /recipes & /season/[month]
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.
2026-05-08 16:01:59 +02:00
Alexander eb2ffac536 fix(offline): fall back to cached shell on upstream 5xx
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.
2026-05-07 07:52:29 +02:00
Alexander 9a97e41c28 fix(faith): no-Latin prayers always render in monolingual style
CI / update (push) Successful in 1m2s
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.
2026-05-07 07:38:57 +02:00
Alexander 109ac8e13a feat(faith): add closing refrain to Jungfrau Mutter Gottes mein
Adds the traditional final stanza repeating the opening invocation.
2026-05-07 07:35:26 +02:00
Alexander 6275b526d8 feat(faith): info pip on streak counters explaining habit vs piety
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.
2026-05-05 18:15:50 +02:00
Alexander 6456804fc3 feat(faith): add 6 prayers (Marian devotions + meal blessings)
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.
2026-05-05 07:55:54 +02:00
Alexander 585c03a11e feat(offline): hoist sync UI to homepage, slow auto-sync to weekly
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.
2026-05-04 22:21:16 +02:00
Alexander 0372c50084 style(faith): eastertide indicators — fix badge in dark mode, pulse pip in nav
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.
2026-05-04 22:14:34 +02:00
Alexander 065c435d8b feat(offline)!: deploy-proof PWA cache + universal recipe loads
CI / update (push) Successful in 1m10s
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.
2026-05-04 21:32:59 +02:00
Alexander 1bceabe967 feat(errors): merge DE/EN into one page with client-side toggle
CI / update (push) Successful in 48s
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).
2026-05-03 21:42:41 +02:00
Alexander 86c72c2dc3 fix(apologetik): drop duplicate Cache-Control in child loads
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.
2026-05-03 21:42:05 +02:00
Alexander 4623d7a1f7 feat(seo): noindex hook, recipe self-canonical, list-page metadata
CI / update (push) Successful in 37s
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)
2026-05-02 22:23:15 +02:00
Alexander d59cc0a732 feat(seo): image sitemap, Article schemas on apologetik pro + katechese, edge caching
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.
2026-05-02 22:05:50 +02:00
Alexander ecbd24d7a4 feat(seo): per-route html lang, QAPage/Breadcrumb/Event/WebSite schemas, sitemap lastmod
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.
2026-05-02 21:48:05 +02:00
Alexander 7e33ea833e feat(seo): sitemap, OG/canonical/hreflang, JSON-LD i18n
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.
2026-05-02 21:32:06 +02:00
Alexander b10634f831 feat(errors): per-status static error pages for nginx fallback
CI / update (push) Successful in 39s
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.
2026-05-02 20:11:34 +02:00
Alexander e85a2508e8 fix(shopping/loyalty): fail build when card env missing instead of rmSync
CI / update (push) Successful in 52s
Previous behavior silently deleted static/shopping/*.svg when
SHOPPING_*_NUMBER env vars were unset, then rsync --delete propagated
the deletion to the prod server — the loyalty buttons disappeared on
deploys where the env didn't reach the build (or during the brief
rm→write window of a parallel run).

Now the script exits non-zero with a clear message; deploy.sh's set -e
aborts before any destructive sync.
2026-05-02 18:36:55 +02:00
Alexander 096d6e2868 feat(rezepte)!: liturgical-aware seasonality via date ranges
CI / update (push) Successful in 3m31s
Replace season: number[] (months 1-12) on Recipe with seasonRanges, a
list of date ranges where each endpoint is either a fixed MM-DD or a
movable liturgical anchor (Easter, Ash Wednesday, Palm Sunday,
Pentecost, Advent I) plus a day offset. The old month list couldn't
express liturgical seasons whose boundaries shift each year (Advent,
Lent, Easter Octave, Christmas Octave) nor sub-month windows.

The shared evaluator resolves anchors against [Y-1, Y, Y+1] so spans
that wrap the calendar year boundary (e.g. christmas + 0 to
christmas + 7) match correctly on both sides. SeasonSelect was
rewritten as a controlled bind:ranges editor with a
fixed/liturgical kind toggle, anchor + offset inputs, per-row
resolved-this-year preview, and preset chips.

Run the one-time migration before deploying:
  pnpm exec vite-node scripts/migrate-season-to-ranges.ts

It coalesces contiguous month runs into single fixed ranges and
merges Dec/Jan wrap into one wrapping range; the new code does not
read the legacy season field, so order matters.
2026-05-02 17:53:27 +02:00
Alexander 68b078c146 fix(apologetik/contra): scope answer-rail width:max-content to >=760px
CI / update (push) Successful in 3m35s
The answer-rail had width:max-content with a wider max-width override
gated behind @media (min-width:760px). The width:max-content sat
outside the media query, so on mobile it inflated the rail's max-content
contribution to .arg-body's 1fr grid track. The track expanded past the
viewport (max-width:100% can't clamp during cyclic track sizing), making
.arg-body and its h2/text appear to overflow horizontally.

Move width:max-content inside the >=760px block so the desktop break-out
behaviour stays, while mobile falls back to default flex-wrap within
the column.
2026-05-02 16:13:29 +02:00
Alexander 2af845bfc6 feat(offline): redesign sync UI and PWA polish
CI / update (push) Successful in 4m49s
Split the single OfflineSyncButton into two surfaces with distinct
intents:
  - OfflineSyncBanner: dismissable promo on the recipe index that
    encourages first-time download (only when standalone + not yet
    synced).
  - OfflineSyncIndicator: small status pip overlaid on the nav logo
    when offline data is available, opening a popover with sync /
    clear actions.

Also fold the sync / clear actions into the UserHeader options menu so
the avatar dropdown is the canonical place to manage offline data.
Header.svelte gains a `logo_overlay` snippet slot to host the
indicator pip.

Other:
  - manifest.json: prefer the theme-aware SVG as the primary install
    icon and drop the redundant 512px raster (kept maskable 192px).
  - scripts/deploy.sh: build locally and rsync artifacts to the
    server, avoiding any pnpm/git work on the production host.

Bump 1.57.8 -> 1.58.0.
2026-05-02 15:56:21 +02:00
Alexander 6875e8762e fix(offline): gate SW precache on controller, not just registration
`navigator.serviceWorker.ready` resolves only when a SW is registered
and active. In `vite dev` no SW exists, so awaiting `ready` hangs the
sync forever. Gate on `navigator.serviceWorker.controller` first to
short-circuit cleanly when nothing controls the page.

Bump 1.57.7 -> 1.57.8.
2026-05-02 15:55:12 +02:00
Alexander 4ed0251bb4 feat(branding): adaptive Android launcher icon
CI / update (push) Successful in 3m45s
Split the logo into foreground/background layers so Android can
apply system masks (circle, squircle, teardrop) and parallax
instead of rendering a flat composited PNG.

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

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

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

Tauri app: 0.5.1 → 0.5.2.
2026-05-02 14:45:17 +02:00
Alexander eb9d7a17b3 feat(favicon): single theme-aware SVG, drop legacy raster fallbacks
favicon.svg now uses currentColor + prefers-color-scheme so a single
asset adapts to light/dark. Removed unused .ico/.png and 512 raster;
192 PNG kept and regenerated for apple-touch-icon and PWA maskable.
2026-05-02 14:28:24 +02:00
Alexander ccca1a7959 fix(jellyfin): scope logo bocken.org link to home header only
The previous wrap-in-anchor approach leaked the bocken.org link onto
unrelated page titles because Jellyfin reuses the same <h3> across
navigations and only swaps its class between .pageTitleWithDefaultLogo
and .pageTitle. Switch to click delegation so the redirect fires only
when the clicked element currently carries the logo class. Also unwrap
any legacy anchor wrappers on first mutation, and bump cursor/filter
hover styles to !important so they survive Jellyfin's own h3 rules.
2026-05-02 13:10:24 +02:00
Alexander 2e8685d02b style(recipes): unify custom multiplier pill with preset pills
CI / update (push) Successful in 5m48s
Make the custom-multiplier pill behave and look like a single input zone:

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

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

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

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

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

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

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

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

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

Detail pages ([name]/+page, [name]/+error, IngredientsPage extras,
InstructionsPage, smaller components) and the admin page remain for
follow-up commits.
2026-05-01 13:34:44 +02:00
Alexander d540b82e85 i18n(recipes): bootstrap namespace + migrate layout, NutritionSummary
Two-locale recipes dictionary lands at src/lib/i18n/recipes/{de,en}.ts
with the same satisfies-based completeness enforcement as the other
namespaces. recipesI18n.ts is the slim shim — exports m, RecipesLang,
RecipesKey, plus langFromRecipeSlug / recipeSlugFromLang helpers for
the rezepte ↔ recipes URL slug mapping.

[recipeLang]/+layout.svelte's nav-label ternary chain collapses into
t.foo lookups. NutritionSummary.svelte is the heavy hitter — 33
inline isEnglish ternaries become a single dictionary load. Most
amino-acid names use a German-stem-plus-optional-e pattern in the old
code (`Lysin{isEnglish ? 'e' : ''}`) that's now just t.lysine in the
template; less clever, much more obviously translatable.
2026-05-01 13:22:59 +02:00
Alexander d7f96f35c2 i18n(faith): migrate prayers index + prayer detail
Adds prayer-name keys (sign_of_cross, pater_noster, fatima_prayer, …),
search/filter UI labels (search_prayers, clear_search, filter_by_category,
all_categories), the eastertide_badge, and the prayer-detail-only
nicene_creed / hail_mary aliases (German + Latin keep the Latin form,
English uses the English name).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The stats page's three streamed Promise.resolve(...).then(...) chains
now log on rejection instead of silently swallowing errors. The muscle
heatmap {#await} block gained pending and catch branches with a
lang-aware error message.
2026-04-30 19:13:06 +02:00
Alexander d8abcbf74b refactor(hooks): move server bootstrap into ServerInit hook
Module-level top-level await for db/scheduler init and the cache
warmup IIFE move into the canonical export const init hook. Same
ordering and non-blocking semantics; makes the lifecycle explicit
and works on environments without top-level await.
2026-04-30 19:07:42 +02:00
447 changed files with 80425 additions and 421410 deletions
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
#
# Pre-commit: normalise hike track altitudes.
#
# Any added/modified src/content/hikes/<slug>/track.gpx is run through
# scripts/fix-altitudes.ts (swisstopo swissALTI3D heights at each exact point)
# and re-staged, so committed tracks always carry corrected elevation instead of
# raw phone-GPS noise. Commits that don't touch a track.gpx are a fast no-op.
#
# Network failures degrade gracefully: fix-altitudes keeps a point's original
# elevation when it can't resolve it, exits 0, and the commit proceeds.
#
# Caveat: a touched track.gpx is re-staged in full, so partial (`git add -p`)
# staging of a track.gpx won't survive. These files are generated, so that's fine.
set -euo pipefail
# Staged Added/Copied/Modified track.gpx paths, NUL-delimited so non-ASCII slug
# dirs (e.g. "…pfäffikersee") come through as raw bytes, unquoted.
files=()
while IFS= read -r -d '' f; do
case "$f" in
src/content/hikes/*/track.gpx) files+=("$f") ;;
esac
done < <(git diff --cached --name-only -z --diff-filter=ACM -- src/content/hikes)
if [ ${#files[@]} -eq 0 ]; then
exit 0
fi
# Map each path to its <slug> (the directory under src/content/hikes/).
slugs=()
for f in "${files[@]}"; do
s=${f#src/content/hikes/}
slugs+=("${s%/track.gpx}")
done
echo "[pre-commit] fix-altitudes: ${slugs[*]}"
pnpm exec vite-node scripts/fix-altitudes.ts "${slugs[@]}"
# Re-stage so the corrected elevations are what actually gets committed.
git add -- "${files[@]}"
+35
View File
@@ -7,6 +7,7 @@ node_modules
/package
.env
.env.*
.env_*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
@@ -15,6 +16,34 @@ data/usda/
# Loyalty-card barcodes (regenerated by scripts/generate-loyalty-cards.ts from env)
static/shopping/supercard.svg
static/shopping/cumulus.svg
# Hikes build outputs (regenerated by scripts/build-hikes.ts at prebuild)
static/hikes/
hikes-assets/
src/lib/data/hikes.generated.ts
# Tile-proxy build artefacts + secrets (the source tree itself is tracked).
# The binary is dropped next to Cargo.toml by `make build`; its name happens
# to collide with the directory it lives in, so the path is fully qualified
# here to avoid the nested-gitignore quirk that previously hid the source.
/tile-proxy/tile-proxy
/tile-proxy/target/
/tile-proxy/.env
# Private image build outputs (regenerated by scripts/build-private-images.ts).
# Sources are private + large, so they're ignored too — only the README is kept.
private-assets/
src/lib/data/privateImages.generated.ts
src/lib/assets/private-images/*
!src/lib/assets/private-images/README.md
# Build-script disk caches (Swisstopo identify, BRouter responses, ...)
scripts/.cache/
# Loose working-tree scratch files (notes, photos, prototypes) that aren't
# part of the committed source.
/HIKES_PLAN.md
/additional_apologetics.md
/header_jellyfin.html
/person-hiking.svg
/PXL_*.jpg
/PXL_*.MP.jpg
src-tauri/icons/_safezone_template_*.png
src-tauri/target/
src-tauri/*.keystore
# Android: ignore build output and caches, track source files
@@ -22,3 +51,9 @@ src-tauri/gen/android/.gradle/
src-tauri/gen/android/app/build/
src-tauri/gen/android/buildSrc/.gradle/
src-tauri/gen/android/buildSrc/build/
# Hike content: track the writing (index.svx), route (track.gpx) and icons,
# but not the source photos (huge; re-encoded into static assets at build time).
src/content/hikes/*/images/
src/content/hikes/*/private/
src/content/hikes/*/cover.*
-2
View File
@@ -173,7 +173,6 @@ Generated: 2025-11-18
- `EditButton.svelte` - Edit button (floating)
- `FavoriteButton.svelte` - Toggle favorite
- `Card.svelte` (259 lines) ⚠️ Large - Recipe card with hover effects, tags, category
- `CardAdd.svelte` - Add recipe card placeholder
- `FormSection.svelte` - Styled form section wrapper
- `Header.svelte` - Page header
- `UserHeader.svelte` - User-specific header
@@ -190,7 +189,6 @@ Generated: 2025-11-18
#### Recipe-Specific Components
- `Recipes.svelte` - Recipe list display
- `RecipeEditor.svelte` - Recipe editing form
- `RecipeNote.svelte` - Recipe notes display
- `EditRecipe.svelte` - Edit recipe modal
- `EditRecipeNote.svelte` - Edit recipe notes
+54 -4
View File
@@ -24,10 +24,18 @@ Order = impact. Font items + app.html preload intentionally skipped.
[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?
[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
[x] on /fitness/check-in, Make the Period ended button a lot more prominent in the period tracker component.
[x] swap heart emoji on recipe favorites to lucide icon
[x] coop and migros cards on shopping list for scanning
[x] login icon from lucide in header
[ ] Investigate self-hosting BRouter
[ ] Use the same color swisstopo map both for light and dark mode (currentyl only light mode)
[ ] pre-compute required map tiles for all tiles on the route (and adjacent enough to be visibile by default on sane screen sizes) and create a fetch instruction for the server. (separate step: create a swiss-topo caching service which smoothly interpolates with non-switzerland service tiles for spots outside of switzerland)
[ ] expand compatibility outside of switzerland with non-swiss topo map
[ ] align design better with swizterland mobility
[ ] allow for difficulty cardio, difficulty technique and T1-T6 labelling
[ ] allow for Switzerland Mobility like hike icons (with alpine blue white blue, red white red, and yellow hiking shields as a fallback alternative)
[ ] Add smoothing distance for elevation calculations on GPS-tracled workouts (3 meters? more?)
## Refactor Recipe Search Component
@@ -39,3 +47,45 @@ Refactor `src/lib/components/Search.svelte` to use the new `SearchInput.svelte`
Files involved:
- `src/lib/components/Search.svelte` - refactor to use SearchInput
- `src/lib/components/SearchInput.svelte` - the reusable input component
1. $app/stores → $app/state (biggest, most mechanical)
Old: import { page } from '$app/stores' + $page.url.pathname
New: import { page } from '$app/state' + page.url.pathname (no $, it's a rune now).
Runes-based, smaller bundle (no store wrapper), cleaner SSR. Codebase has dozens of $app/stores imports — same kind
of codemod-able migration as hrefs. Available since 2.12. $app/stores is deprecated.
2. Convert legacy stores to .svelte.ts rune state
Files like $lib/stores/recipeTranslation.ts, $lib/stores/language.ts use writable(). Modern pattern: .svelte.ts files
with $state() + exported getters/setters. Better TS inference, no $ prefix, no auto-subscription gotchas.
3. Remote functions for new API code ($app/server, since 2.27)
Replaces hand-rolled +server.ts + client fetch with type-safe server functions called like normal funcs. Major
refactor for existing /api/** (lots of files), so probably only adopt for new endpoints — not worth churning the
existing ~80 API routes.
4. prerender = true audit
Static-ish pages (faith catechesis, latin prayers, apologetics arguments) are great candidates. Skip-SSR for static
content = faster cold loads + cheaper hosting. Currently nothing's prerendered — quick win where applicable.
5. @sveltejs/enhanced-img
Transparent image optimization (responsive srcset, AVIF/WebP, blur placeholders) at build time. Recipe hero images
and saint-day cards would benefit visibly. Drop-in via <enhanced:img src="...">.
6. {@attach} over use: (Svelte 5 attachments)
Newer API for DOM-lifecycle hooks. Supports spread + library composition use: can't. Low urgency; only matters when
writing new lifecycle code.
7. Shallow routing for modals/galleries
pushState + <a> flow lets modals participate in history without full navigation. Useful if you ever add a
recipe-image lightbox or apologetics-arg overlay. Net-new feature, not a migration.
+9 -2
View File
@@ -1,12 +1,14 @@
{
"name": "homepage",
"version": "1.52.1",
"version": "1.96.0",
"private": true,
"type": "module",
"scripts": {
"prepare": "git config core.hooksPath .githooks || true",
"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 && pnpm exec vite-node scripts/generate-loyalty-cards.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 && pnpm exec vite-node scripts/generate-error-quotes.ts && pnpm exec vite-node scripts/build-hikes.ts && pnpm exec vite-node scripts/build-private-images.ts",
"build": "vite build",
"postbuild": "pnpm exec vite-node scripts/build-error-page.ts && UV_THREADPOOL_SIZE=12 pnpm exec vite-node scripts/precompress.ts",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@@ -22,12 +24,15 @@
"test:e2e:docker:run": "docker run --rm --network host -v $(pwd):/app -w /app -e CI=true mcr.microsoft.com/playwright:v1.56.1-noble /bin/bash -c 'npm install -g pnpm@9.0.0 && pnpm install --frozen-lockfile && pnpm run build && pnpm exec playwright test --project=chromium'",
"deploy": "bash scripts/deploy.sh",
"deploy:dry": "bash scripts/deploy.sh --dry-run",
"photos:push": "bash scripts/hike-photos.sh push",
"photos:pull": "bash scripts/hike-photos.sh pull",
"tauri": "tauri"
},
"packageManager": "pnpm@9.0.0",
"devDependencies": {
"@playwright/test": "1.56.1",
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/enhanced-img": "^0.10.4",
"@sveltejs/kit": "^2.56.1",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tauri-apps/cli": "^2.10.1",
@@ -39,6 +44,7 @@
"@vitest/ui": "^4.1.2",
"bwip-js": "^4.10.1",
"jsdom": "^27.2.0",
"mdsvex": "^0.12.7",
"svelte": "^5.55.1",
"svelte-check": "^4.4.6",
"tslib": "^2.8.1",
@@ -58,6 +64,7 @@
"chart.js": "^4.5.1",
"chartjs-adapter-date-fns": "^3.0.0",
"date-fns": "^4.1.0",
"exifr": "^7.1.3",
"file-type": "^19.0.0",
"leaflet": "^1.9.4",
"mongoose": "^9.4.1",
+136
View File
@@ -38,6 +38,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
exifr:
specifier: ^7.1.3
version: 7.1.3
file-type:
specifier: ^19.0.0
version: 19.6.0
@@ -66,6 +69,9 @@ importers:
'@sveltejs/adapter-auto':
specifier: ^7.0.1
version: 7.0.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))
'@sveltejs/enhanced-img':
specifier: ^0.10.4
version: 0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
'@sveltejs/kit':
specifier: ^2.56.1
version: 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
@@ -99,6 +105,9 @@ importers:
jsdom:
specifier: ^27.2.0
version: 27.2.0
mdsvex:
specifier: ^0.12.7
version: 0.12.7(svelte@5.55.1)
svelte:
specifier: ^5.55.1
version: 5.55.1
@@ -919,6 +928,13 @@ packages:
peerDependencies:
'@sveltejs/kit': ^2.4.0
'@sveltejs/enhanced-img@0.10.4':
resolution: {integrity: sha512-Am5nmAKUo7Nboqq7Dhtfn7dcXA087d7gIz6Vecn1opB41aJ680+0q9U9KvEcMgduOyeiwckTIOQOx4Mmq9GcvA==}
peerDependencies:
'@sveltejs/vite-plugin-svelte': ^6.0.0 || ^7.0.0
svelte: ^5.0.0
vite: ^6.3.0 || >=7.0.0
'@sveltejs/kit@2.56.1':
resolution: {integrity: sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==}
engines: {node: '>=18.13'}
@@ -1079,6 +1095,9 @@ packages:
'@types/leaflet@1.9.21':
resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/node-cron@3.0.11':
resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
@@ -1091,6 +1110,9 @@ packages:
'@types/trusted-types@2.0.7':
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
'@types/webidl-conversions@7.0.0':
resolution: {integrity: sha512-xTE1E+YF4aWPJJeUzaZI5DRntlkY3+BCVJi0axFptnjGmAoWxkyREIh/XMrfxVLejwQxMCfDXdICo0VLxThrog==}
@@ -1341,6 +1363,9 @@ packages:
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
exifr@7.1.3:
resolution: {integrity: sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1433,6 +1458,10 @@ packages:
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
imagetools-core@9.1.0:
resolution: {integrity: sha512-xQjs+2vrxLnAjCq+omuNkd5UQTld9/bP8+YT0LyYTlKfuSQtgUBvqhUwGugzSAh6sCdN+LnROMuLswn5hZ9Fhg==}
engines: {node: '>=20.0.0'}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
@@ -1585,6 +1614,11 @@ packages:
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdsvex@0.12.7:
resolution: {integrity: sha512-gx4bReLCUvq+MPErHXYeyX+TEq1hsS2KfiZtEOMNTcbibSouFy8AHc5h04KbGCl+g5tLuo4/lbgRVYRnc7bJZw==}
peerDependencies:
svelte: ^3.56.0 || ^4.0.0 || ^5.0.0-next.120
memory-pager@1.5.0:
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
@@ -1738,6 +1772,13 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
prism-svelte@0.4.7:
resolution: {integrity: sha512-yABh19CYbM24V7aS7TuPYRNMqthxwbvx6FF/Rw920YbyBWO3tnyPIqRMgHuSVsLmuHkkBS1Akyof463FVdkeDQ==}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
@@ -1887,6 +1928,11 @@ packages:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-parse-markup@0.1.5:
resolution: {integrity: sha512-T6mqZrySltPCDwfKXWQ6zehipVLk4GWfH1zCMGgRtLlOIFPuw58ZxVYxVvotMJgJaurKi1i14viB2GIRKXeJTQ==}
peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
svelte@5.55.1:
resolution: {integrity: sha512-QjvU7EFemf6mRzdMGlAFttMWtAAVXrax61SZYHdkD6yoVGQ89VeyKfZD4H1JrV1WLmJBxWhFch9H6ig/87VGjw==}
engines: {node: '>=18'}
@@ -1968,6 +2014,25 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
unist-util-is@4.1.0:
resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==}
unist-util-stringify-position@2.0.3:
resolution: {integrity: sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==}
unist-util-visit-parents@3.1.1:
resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==}
unist-util-visit@2.0.3:
resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==}
vfile-message@2.0.4:
resolution: {integrity: sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==}
vite-imagetools@9.0.3:
resolution: {integrity: sha512-FwjApRNZyN+RucPW9Z9kf0dyzyi3r3zlDfrTnzHXNaYpmT3pZ5w//d6QkApy1iypbDm+3fq+Gwfv+PYA4j4uYw==}
engines: {node: '>=20.0.0'}
vite-node@6.0.0:
resolution: {integrity: sha512-oj4PVrT+pDh6GYf5wfUXkcZyekYS8kKPfLPXVl8qe324Ec6l4K2DUKNadRbZ3LQl0qGcDz+PyOo7ZAh00Y+JjQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2702,6 +2767,19 @@ snapshots:
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
rollup: 4.60.1
'@sveltejs/enhanced-img@0.10.4(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(rollup@4.60.1)(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
dependencies:
'@sveltejs/vite-plugin-svelte': 7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))
magic-string: 0.30.21
sharp: 0.34.5
svelte: 5.55.1
svelte-parse-markup: 0.1.5(svelte@5.55.1)
vite: 8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)
vite-imagetools: 9.0.3(rollup@4.60.1)
zimmerframe: 1.1.2
transitivePeerDependencies:
- rollup
'@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0)))(svelte@5.55.1)(typescript@6.0.2)(vite@8.0.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0))':
dependencies:
'@standard-schema/spec': 1.0.0
@@ -2847,6 +2925,10 @@ snapshots:
dependencies:
'@types/geojson': 7946.0.16
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 2.0.11
'@types/node-cron@3.0.11': {}
'@types/node@22.18.0':
@@ -2857,6 +2939,8 @@ snapshots:
'@types/trusted-types@2.0.7': {}
'@types/unist@2.0.11': {}
'@types/webidl-conversions@7.0.0': {}
'@types/whatwg-url@13.0.0':
@@ -3096,6 +3180,8 @@ snapshots:
dependencies:
'@types/estree': 1.0.8
exifr@7.1.3: {}
expect-type@1.3.0: {}
fdir@6.5.0(picomatch@4.0.3):
@@ -3188,6 +3274,8 @@ snapshots:
ieee754@1.2.1: {}
imagetools-core@9.1.0: {}
indent-string@4.0.0: {}
ip@2.0.1:
@@ -3321,6 +3409,16 @@ snapshots:
mdn-data@2.12.2: {}
mdsvex@0.12.7(svelte@5.55.1):
dependencies:
'@types/mdast': 4.0.4
'@types/unist': 2.0.11
prism-svelte: 0.4.7
prismjs: 1.30.0
svelte: 5.55.1
unist-util-visit: 2.0.3
vfile-message: 2.0.4
memory-pager@1.5.0: {}
min-indent@1.0.1: {}
@@ -3442,6 +3540,10 @@ snapshots:
ansi-styles: 5.2.0
react-is: 17.0.2
prism-svelte@0.4.7: {}
prismjs@1.30.0: {}
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
@@ -3670,6 +3772,10 @@ snapshots:
transitivePeerDependencies:
- picomatch
svelte-parse-markup@0.1.5(svelte@5.55.1):
dependencies:
svelte: 5.55.1
svelte@5.55.1:
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -3752,6 +3858,36 @@ snapshots:
undici-types@6.21.0: {}
unist-util-is@4.1.0: {}
unist-util-stringify-position@2.0.3:
dependencies:
'@types/unist': 2.0.11
unist-util-visit-parents@3.1.1:
dependencies:
'@types/unist': 2.0.11
unist-util-is: 4.1.0
unist-util-visit@2.0.3:
dependencies:
'@types/unist': 2.0.11
unist-util-is: 4.1.0
unist-util-visit-parents: 3.1.1
vfile-message@2.0.4:
dependencies:
'@types/unist': 2.0.11
unist-util-stringify-position: 2.0.3
vite-imagetools@9.0.3(rollup@4.60.1):
dependencies:
'@rollup/pluginutils': 5.3.0(rollup@4.60.1)
imagetools-core: 9.1.0
sharp: 0.34.5
transitivePeerDependencies:
- rollup
vite-node@6.0.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.1)(@types/node@22.18.0)(esbuild@0.27.3)(terser@5.46.0):
dependencies:
cac: 7.0.0
+162
View File
@@ -0,0 +1,162 @@
/**
* Postbuild: turn each prerendered /errors/<status> route into a self-contained
* HTML file at build/client/errors/<status>.html for nginx error_page use.
*
* - Inlines every <link rel="stylesheet"> by replacing it with <style>.
* - Strips <script type="module"> and <link rel="modulepreload"> (csr=false,
* so JS is dead weight and a missing-asset risk if upstream is dead).
* - Leaves font/image URLs alone — nginx serves them from the same root.
* - Emits matching .gz + .br for nginx gzip_static / brotli_static.
*
* Run: pnpm exec vite-node scripts/build-error-page.ts
*/
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
import { dirname, resolve, join, posix } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzipSync, brotliCompressSync, constants as zlib } from 'node:zlib';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const PRERENDER_DIR = join(ROOT, 'build/prerendered/errors');
const CLIENT = join(ROOT, 'build/client');
const OUT_DIR = join(CLIENT, 'errors');
// Error pages may be served from arbitrary domains via nginx's default_server
// catchall. Rewrite the home-link to an absolute canonical URL so clicking
// the logo always lands on the real site.
const CANONICAL_HOME = 'https://bocken.org/';
// Marker for idempotent script injection (so re-runs don't stack copies).
const LANG_SCRIPT_MARKER = 'data-error-toggles';
// Wires up language + theme toggles without Svelte hydration. Runs early
// so <html data-lang="…"> is set before paint (avoids flash of both langs).
// The icon inside the theme button is Svelte-reactive and stays at the
// SSR-rendered shape; the actual theme cycle + persistence still works.
const LANG_SCRIPT = `
<script ${LANG_SCRIPT_MARKER}>
(function(){try{
var html=document.documentElement;
var pref=localStorage.getItem('preferredLanguage');
var lang=(pref==='en'||pref==='de')?pref:'de';
html.setAttribute('data-lang',lang);
var wire=function(){
var langBtn=document.getElementById('lang-toggle');
if(langBtn){
var refresh=function(){
var cur=html.getAttribute('data-lang')||'de';
var next=cur==='de'?'en':'de';
langBtn.textContent=next.toUpperCase();
langBtn.setAttribute('aria-label',next==='en'?'Switch to English':'Auf Deutsch wechseln');
};
refresh();
langBtn.addEventListener('click',function(){
var cur=html.getAttribute('data-lang')||'de';
var next=cur==='de'?'en':'de';
html.setAttribute('data-lang',next);
try{localStorage.setItem('preferredLanguage',next);}catch(_){}
refresh();
});
}
var themeBtn=document.querySelector('button[aria-label^="Toggle theme"]');
if(themeBtn){
var CYCLE=['system','light','dark'];
var getTheme=function(){
var s=localStorage.getItem('theme');
return (s==='light'||s==='dark')?s:'system';
};
var applyTheme=function(t){
if(t==='system'){delete html.dataset.theme;try{localStorage.removeItem('theme');}catch(_){}}
else{html.dataset.theme=t;try{localStorage.setItem('theme',t);}catch(_){}}
themeBtn.setAttribute('aria-label','Toggle theme ('+t+')');
themeBtn.setAttribute('title','Theme: '+t);
};
themeBtn.addEventListener('click',function(){
var cur=getTheme();
var next=CYCLE[(CYCLE.indexOf(cur)+1)%CYCLE.length];
applyTheme(next);
});
}
};
if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',wire);
else wire();
}catch(_){}})();
</script>`;
if (!existsSync(PRERENDER_DIR)) {
console.error(`[error-page] missing prerender dir: ${PRERENDER_DIR}`);
console.error('[error-page] is /errors/[status=httpStatus]/+page.ts setting `prerender = true` with `entries()`?');
process.exit(1);
}
mkdirSync(OUT_DIR, { recursive: true });
// Recursively collect every prerendered html under build/prerendered/errors,
// so we pick up nested language variants (errors/en/<status>.html).
function walk(dir: string, prefix = ''): { rel: string; abs: string }[] {
const out: { rel: string; abs: string }[] = [];
for (const ent of readdirSync(dir, { withFileTypes: true })) {
const abs = join(dir, ent.name);
const rel = prefix ? `${prefix}/${ent.name}` : ent.name;
if (ent.isDirectory()) out.push(...walk(abs, rel));
else if (ent.isFile() && ent.name.endsWith('.html')) out.push({ rel, abs });
}
return out;
}
const sources = walk(PRERENDER_DIR);
if (sources.length === 0) {
console.error(`[error-page] no .html files under ${PRERENDER_DIR}`);
process.exit(1);
}
// Resolve a possibly-relative href (../foo, ./foo, /foo) against the page's
// path (e.g. /errors/503.html) into a path inside CLIENT.
function resolveAsset(href: string, pagePath: string): string {
const abs = posix.resolve(posix.dirname(pagePath), href); // e.g. /_app/immutable/assets/x.css
return join(CLIENT, abs.replace(/^\//, ''));
}
function inline(html: string, pagePath: string): string {
// Inline <link rel="stylesheet"> regardless of attribute order.
html = html.replace(/<link\b[^>]*>/g, (tag) => {
if (!/\brel=["']stylesheet["']/.test(tag)) return tag;
const m = tag.match(/\bhref=["']([^"']+)["']/);
if (!m) return tag;
const cssPath = resolveAsset(m[1], pagePath);
if (!existsSync(cssPath)) {
console.warn(`[error-page] stylesheet not found, leaving link tag: ${m[1]}`);
return tag;
}
return `<style>${readFileSync(cssPath, 'utf8')}</style>`;
});
// Drop module preloads and module scripts — nothing should hydrate.
html = html.replace(/<link[^>]*\brel=["']modulepreload["'][^>]*>\s*/g, '');
html = html.replace(/<script[^>]*\btype=["']module["'][^>]*>[\s\S]*?<\/script>\s*/g, '');
// Point the brand/home link at the canonical site (the page may be served
// from any domain when used as nginx's default_server fallback).
html = html.replace(/<a\b[^>]*\bclass="[^"]*\bhome-link\b[^"]*"[^>]*>/g, (tag) =>
tag.replace(/\bhref="[^"]*"/, `href="${CANONICAL_HOME}"`)
);
// Inject the language-toggle bootstrap script just before </head> so
// <html data-lang="…"> is set before the body paints (avoids flash of
// both languages). Idempotent — if the marker is already present, skip.
if (!html.includes(LANG_SCRIPT_MARKER)) {
html = html.replace('</head>', `${LANG_SCRIPT}</head>`);
}
return html;
}
for (const { rel, abs } of sources) {
const dst = join(OUT_DIR, rel);
mkdirSync(dirname(dst), { recursive: true });
const html = inline(readFileSync(abs, 'utf8'), `/errors/${rel}`);
const buf = Buffer.from(html, 'utf8');
writeFileSync(dst, buf);
writeFileSync(`${dst}.gz`, gzipSync(buf, { level: 9 }));
writeFileSync(`${dst}.br`, brotliCompressSync(buf, {
params: { [zlib.BROTLI_PARAM_QUALITY]: 11 }
}));
console.log(`[error-page] wrote errors/${rel} (${(buf.length / 1024).toFixed(1)} kB) + .gz + .br`);
}
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
/**
* Build script for private (auth-gated) images rendered via `<Image private>`.
*
* Public images use @sveltejs/enhanced-img, which emits PUBLIC hashed assets
* into the client bundle — fine for anything anyone may see. Private images
* must not be publicly reachable, so they can't go through enhanced-img. This
* script mirrors the hikes private pipeline instead:
*
* 1. Scan `src/lib/assets/private-images/` (recursively) for raster sources.
* 2. Encode each into AVIF + WebP at multiple widths with sharp, named by
* content hash, into `private-assets/` — a tree OUTSIDE the client bundle
* and outside `/static`, so SvelteKit/Vite never serve it directly.
* 3. Emit `src/lib/data/privateImages.generated.ts`: a manifest mapping each
* source path to its responsive variant, with URLs under `/private-images/`
* (the auth-gated endpoint at src/routes/private-images/[...file]/+server.ts).
*
* Deploy rsyncs `private-assets/` to the server, where nginx serves it only via
* an `internal` location (`/protected-images/`) reachable through X-Accel-Redirect
* from the endpoint — never publicly. In dev the endpoint streams from disk.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import os from 'node:os';
import sharp from 'sharp';
import type { PrivateImageVariant } from '../src/types/images.js';
const ROOT = path.resolve(process.cwd());
const SRC_DIR = path.join(ROOT, 'src', 'lib', 'assets', 'private-images');
// Encoded output. Sibling of `hikes-assets/` and, like it, gitignored + rsynced
// to the server by scripts/deploy.sh (never bundled, never under /static).
const OUT_DIR = path.join(ROOT, 'private-assets');
const MANIFEST_OUT = path.join(ROOT, 'src', 'lib', 'data', 'privateImages.generated.ts');
// Same responsive ladder + qualities as the hikes encoder, for consistency.
const IMAGE_WIDTHS = [480, 960, 1600] as const;
const AVIF_QUALITY = 55;
const WEBP_QUALITY = 82;
const RASTER_RE = /\.(jpe?g|png|webp|avif|tiff?|gif|heic|heif)$/i;
// Sharp releases the JS thread while libvips runs, so a small pool ~linearly
// speeds up encoding. Cap at 4 to avoid thrashing smaller boxes.
const CONCURRENCY = Math.max(2, Math.min(os.cpus().length, 4));
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function runWithConcurrency<T, R>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (true) {
const i = next++;
if (i >= items.length) return;
results[i] = await worker(items[i], i);
}
});
await Promise.all(runners);
return results;
}
async function walk(dir: string): Promise<string[]> {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return [];
}
let out: string[] = [];
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const full = path.join(dir, e.name);
if (e.isDirectory()) out = out.concat(await walk(full));
else if (RASTER_RE.test(e.name)) out.push(full);
}
return out;
}
async function encode(
srcPath: string
): Promise<{ key: string; variant: PrivateImageVariant; outNames: string[] }> {
const buffer = await fs.readFile(srcPath);
// Content hash names the output files: an existing file is byte-identical, so
// re-encodes are skipped and stale ones get swept. The source basename is
// dropped so original filenames don't leak into the (guessable) URLs.
const hash = crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 8);
const meta = await sharp(buffer).metadata();
const intrinsicW = meta.width ?? IMAGE_WIDTHS[IMAGE_WIDTHS.length - 1];
const intrinsicH = meta.height ?? 0;
let widths = IMAGE_WIDTHS.filter((w) => w <= intrinsicW);
if (widths.length === 0) widths = [intrinsicW];
await fs.mkdir(OUT_DIR, { recursive: true });
type Job = { w: number; fmt: 'avif' | 'webp'; file: string; quality: number };
const jobs: Job[] = [];
const avif: string[] = [];
const webp: string[] = [];
const outNames: string[] = [];
let largestWebp = '';
for (const w of widths) {
const avifName = `${hash}.${w}.avif`;
const webpName = `${hash}.${w}.webp`;
jobs.push({ w, fmt: 'avif', file: path.join(OUT_DIR, avifName), quality: AVIF_QUALITY });
jobs.push({ w, fmt: 'webp', file: path.join(OUT_DIR, webpName), quality: WEBP_QUALITY });
avif.push(`/private-images/${avifName} ${w}w`);
webp.push(`/private-images/${webpName} ${w}w`);
largestWebp = `/private-images/${webpName}`;
outNames.push(avifName, webpName);
}
const presence = await Promise.all(jobs.map((j) => pathExists(j.file)));
const pending = jobs.filter((_, i) => !presence[i]);
await Promise.all(
pending.map(async (j) => {
const pipeline = sharp(buffer).rotate().resize({ width: j.w, withoutEnlargement: true });
if (j.fmt === 'avif') await pipeline.avif({ quality: j.quality }).toFile(j.file);
else await pipeline.webp({ quality: j.quality }).toFile(j.file);
})
);
const largestW = widths[widths.length - 1];
const scale = largestW / intrinsicW;
const height = Math.round((intrinsicH || largestW) * scale);
// Manifest key: source path relative to SRC_DIR, forward-slashed, so a caller
// writes <Image src="blog/cover.jpg" private />.
const key = path.relative(SRC_DIR, srcPath).split(path.sep).join('/');
return {
key,
variant: {
src: largestWebp,
srcsetAvif: avif.join(', '),
srcsetWebp: webp.join(', '),
width: largestW,
height
},
outNames
};
}
async function main() {
const files = await walk(SRC_DIR);
if (files.length > 0) {
console.log(`[build-private-images] encoding ${files.length} image(s) (concurrency=${CONCURRENCY})…`);
}
const results = await runWithConcurrency(files, CONCURRENCY, (f) => encode(f));
const manifest: Record<string, PrivateImageVariant> = {};
const keep = new Set<string>();
for (const r of results) {
manifest[r.key] = r.variant;
for (const n of r.outNames) keep.add(n);
}
// Sweep encodes from prior builds whose source was removed or changed.
if (await pathExists(OUT_DIR)) {
const existing = await fs.readdir(OUT_DIR);
const orphans = existing.filter((f) => !keep.has(f));
if (orphans.length > 0) {
await Promise.all(orphans.map((f) => fs.unlink(path.join(OUT_DIR, f)).catch(() => {})));
console.log(`[build-private-images] removed ${orphans.length} orphaned file(s)`);
}
}
await fs.mkdir(path.dirname(MANIFEST_OUT), { recursive: true });
const banner =
'// AUTO-GENERATED by scripts/build-private-images.ts — do not edit by hand.\n' +
"import type { PrivateImageVariant } from '$types/images';\n\n";
const body = `export const PRIVATE_IMAGES: Record<string, PrivateImageVariant> = ${JSON.stringify(
manifest,
null,
2
)};\n`;
await fs.writeFile(MANIFEST_OUT, banner + body);
console.log(
`[build-private-images] wrote ${Object.keys(manifest).length} entry(ies) to ${path.relative(ROOT, MANIFEST_OUT)}`
);
}
main().catch((err) => {
console.error('[build-private-images] Fatal:', err);
process.exit(1);
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Migrate i18n call sites from t('key', lang) to t.key (or t[expr] for
* dynamic keys), where t = m[lang] derived once per file. Generic version
* — pass the i18n module path and the directories to scan.
*
* Usage:
* pnpm exec vite-node scripts/codemod-i18n-t-to-m.ts \
* --module=$lib/js/cospendI18n \
* --root=src/routes/'[cospendRoot=cospendRoot]' \
* --root=src/lib/components/cospend \
* [--dry]
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const args = process.argv.slice(2);
const DRY = args.includes('--dry');
const modArg = args.find((a) => a.startsWith('--module='));
if (!modArg) {
console.error('missing --module=<path>');
process.exit(1);
}
const modulePath = modArg.slice('--module='.length);
const roots = args
.filter((a) => a.startsWith('--root='))
.map((a) => a.slice('--root='.length));
if (roots.length === 0) {
console.error('missing --root=<dir> (at least one)');
process.exit(1);
}
const fnFlag = args.find((a) => a.startsWith('--fn='));
const FN = fnFlag ? fnFlag.slice('--fn='.length) : 't';
const mFlag = args.find((a) => a.startsWith('--m='));
const M_NAME = mFlag ? mFlag.slice('--m='.length) : 'm';
// Match imports from any path ending in the module basename — call sites
// reach calendarI18n via wildly different relative-path depths, so we
// don't pin the full path.
function esc(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const IMPORT_RE = new RegExp(
`import\\s*\\{([^}]+)\\}\\s*from\\s*(['"])([^'"]*${esc(modulePath)})\\2\\s*;?`
);
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const s = statSync(p);
if (s.isDirectory()) walk(p, out);
else if (extname(p) === '.svelte' || extname(p) === '.ts') out.push(p);
}
return out;
}
function migrate(src: string): { code: string; changed: boolean } {
const m0 = IMPORT_RE.exec(src);
if (!m0) return { code: src, changed: false };
const items = m0[1].split(',').map((s) => s.trim()).filter(Boolean);
if (!items.includes(FN)) return { code: src, changed: false };
const matchedPath = m0[3];
// 1. Rewrite import: drop FN, ensure M_NAME present. Preserve original path.
const fnIdx = items.indexOf(FN);
items.splice(fnIdx, 1);
if (!items.includes(M_NAME)) items.push(M_NAME);
let out = src.replace(IMPORT_RE, `import { ${items.join(', ')} } from '${matchedPath}';`);
// 2. Insert `const FN = $derived(M_NAME[lang]);` at the right spot.
const insertion = `const ${FN} = $derived(${M_NAME}[lang]);`;
let inserted = false;
const langDerivedRe =
/^([ \t]*)(const\s+lang\s*=\s*\$derived\((?:[^()]|\([^()]*\))+\)\s*;?)([ \t]*\n)/m;
if (langDerivedRe.test(out)) {
out = out.replace(langDerivedRe, (_, indent, decl, nl) => {
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
const propsRe =
/^([ \t]*)(let\s*\{[\s\S]*?\}\s*=\s*\$props(?:<[\s\S]*?>)?\(\)\s*;?)([ \t]*\n)/m;
out = out.replace(propsRe, (full, indent, decl, nl) => {
if (!/\blang\b/.test(decl)) return full;
inserted = true;
return `${indent}${decl}${nl}${indent}${insertion}${nl}`;
});
}
if (!inserted) {
console.warn(` WARN: could not auto-insert \`${insertion}\` — manual fix needed`);
}
// Build dynamic regex for FN(...) — escape `1962`-style suffixes.
const fnEsc = FN.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// 3. FN('static_key', lang) → FN.static_key (snake_case OR camelCase identifier)
out = out.replace(
new RegExp(`\\b${fnEsc}\\(\\s*['"]([a-zA-Z_$][a-zA-Z0-9_$]*)['"]\\s*,\\s*lang\\s*\\)`, 'g'),
`${FN}.$1`
);
// 4. FN(<expr>, lang) → FN[<expr>]
out = out.replace(
new RegExp(`\\b${fnEsc}\\(((?:[^()]|\\([^()]*\\))+?)\\s*,\\s*lang\\s*\\)`, 'g'),
(_match, expr) => `${FN}[${expr.trim()}]`
);
return { code: out, changed: out !== src };
}
let total = 0;
for (const root of roots) {
for (const f of walk(root)) {
const orig = readFileSync(f, 'utf8');
const { code, changed } = migrate(orig);
if (!changed) continue;
if (!DRY) writeFileSync(f, code);
total++;
console.log(` ${f}`);
}
}
console.log(`\n${DRY ? '[dry] ' : ''}${total} files migrated`);
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env bash
# Build locally and rsync artifacts to the production server.
# Avoids running pnpm / npm / any git-hosted prepare step on the server.
#
# Assumes:
# - Local machine matches the server's arch + libc (linux-x64-glibc).
# - Local Node major version matches the server's.
# - Root SSH to $REMOTE works (key-based).
#
# Usage: scripts/deploy.sh [--dry-run]
set -euo pipefail
REMOTE="${REMOTE:-root@bocken.org}"
REMOTE_DIR="${REMOTE_DIR:-/usr/share/webapps/homepage}"
REMOTE_USER_GROUP="${REMOTE_USER_GROUP:-homepage:homepage}"
SERVICE="${SERVICE:-homepage.service}"
ERROR_PAGES_DIR="${ERROR_PAGES_DIR:-/var/www/errors}"
ERROR_PAGES_OWNER="${ERROR_PAGES_OWNER:-http:http}"
# Hike images live outside the Node app: nginx serves /hikes/<slug>/images/
# directly from disk and gates /hikes/<slug>/private/ through Node via
# X-Accel-Redirect. The build pipeline writes them to ./hikes-assets/ and we
# rsync that tree to the path nginx serves from.
HIKES_ASSETS_DIR="${HIKES_ASSETS_DIR:-/var/www/static/hikes}"
HIKES_ASSETS_OWNER="${HIKES_ASSETS_OWNER:-http:http}"
# Private (auth-gated) images for <Image private>. Built into ./private-assets/
# and served by nginx ONLY via an `internal` location reached through the
# endpoint's X-Accel-Redirect — add this once to the server's nginx config:
# location /protected-images/ { internal; alias /var/www/static/private-images/; }
PRIVATE_ASSETS_DIR="${PRIVATE_ASSETS_DIR:-/var/www/static/private-images}"
PRIVATE_ASSETS_OWNER="${PRIVATE_ASSETS_OWNER:-http:http}"
DRY=""
if [[ "${1:-}" == "--dry-run" ]]; then
DRY="--dry-run"
echo ":: DRY RUN — no files will be transferred"
fi
cd "$(dirname "$0")/.."
echo ":: Sanity-checking local/remote toolchain parity"
local_node=$(node --version)
remote_node=$(ssh "$REMOTE" 'node --version')
if [[ "${local_node%%.*}" != "${remote_node%%.*}" ]]; then
echo "!! Node major mismatch: local $local_node vs remote $remote_node"
echo " Native modules (sharp, onnxruntime, bson) may break. Aborting."
exit 1
fi
echo " node $local_node (match)"
echo ":: Installing deps (frozen lockfile)"
pnpm install --frozen-lockfile
# Build against production env, NOT the dev .env. SvelteKit's
# `$env/static/private` (IMAGE_DIR, DB creds, …) is inlined at BUILD time, so a
# build that picks up the dev .env ships dev values to prod — e.g. the relative
# IMAGE_DIR="./imgs/" that resolves under the service's dist/ cwd instead of the
# real served image dir. We export .env_prod into the environment; real env vars
# take precedence over .env files in Vite/SvelteKit's env loading, so this wins
# for the whole `pnpm build` lifecycle (prebuild vite-node scripts + build).
PROD_ENV="${PROD_ENV:-.env_prod}"
if [[ ! -f "$PROD_ENV" ]]; then
echo "!! $PROD_ENV not found in $(pwd) — refusing to build with the dev .env."
echo " Create $PROD_ENV with production values (IMAGE_DIR must be an"
echo " ABSOLUTE path to the served recipe-image dir, DB creds, etc.)."
exit 1
fi
echo ":: Building (env from $PROD_ENV)"
set -a
# shellcheck source=/dev/null
source "$PROD_ENV"
set +a
pnpm build
if [[ ! -d build ]]; then
echo "!! build/ not produced — aborting"
exit 1
fi
# The server's systemd unit runs from $REMOTE_DIR/dist, so map build → dist.
echo ":: Syncing build/ → $REMOTE:$REMOTE_DIR/dist/"
rsync -az --delete $DRY --info=progress2 \
build/ "$REMOTE:$REMOTE_DIR/dist/"
echo ":: Syncing node_modules/ → $REMOTE:$REMOTE_DIR/node_modules/"
rsync -az --delete $DRY --info=progress2 \
node_modules/ "$REMOTE:$REMOTE_DIR/node_modules/"
echo ":: Syncing static/ → $REMOTE:$REMOTE_DIR/static/"
rsync -az --delete $DRY --info=progress2 \
static/ "$REMOTE:$REMOTE_DIR/static/"
echo ":: Syncing package.json + pnpm-lock.yaml"
rsync -az $DRY \
package.json pnpm-lock.yaml "$REMOTE:$REMOTE_DIR/"
if [[ ! -d build/client/errors ]]; then
echo "!! build/client/errors not produced — postbuild error-page step did not run"
exit 1
fi
echo ":: Syncing error pages → $REMOTE:$ERROR_PAGES_DIR/"
ssh "$REMOTE" "mkdir -p $ERROR_PAGES_DIR"
rsync -az --delete $DRY --info=progress2 \
build/client/errors/ "$REMOTE:$ERROR_PAGES_DIR/"
if [[ -d hikes-assets ]]; then
echo ":: Syncing hikes-assets/ → $REMOTE:$HIKES_ASSETS_DIR/"
ssh "$REMOTE" "mkdir -p $HIKES_ASSETS_DIR"
rsync -az --delete $DRY --info=progress2 \
hikes-assets/ "$REMOTE:$HIKES_ASSETS_DIR/"
else
echo ":: No hikes-assets/ dir — skipping nginx-served hike images sync"
fi
if [[ -d private-assets ]]; then
echo ":: Syncing private-assets/ → $REMOTE:$PRIVATE_ASSETS_DIR/"
ssh "$REMOTE" "mkdir -p $PRIVATE_ASSETS_DIR"
rsync -az --delete $DRY --info=progress2 \
private-assets/ "$REMOTE:$PRIVATE_ASSETS_DIR/"
else
echo ":: No private-assets/ dir — skipping auth-gated image sync"
fi
if [[ -n "$DRY" ]]; then
echo ":: Dry run complete — no service restart"
exit 0
fi
echo ":: Fixing ownership on server"
ssh "$REMOTE" "chown -R $REMOTE_USER_GROUP $REMOTE_DIR/dist $REMOTE_DIR/node_modules $REMOTE_DIR/static $REMOTE_DIR/package.json $REMOTE_DIR/pnpm-lock.yaml && chown -R $ERROR_PAGES_OWNER $ERROR_PAGES_DIR && if [[ -d $HIKES_ASSETS_DIR ]]; then chown -R $HIKES_ASSETS_OWNER $HIKES_ASSETS_DIR; fi && if [[ -d $PRIVATE_ASSETS_DIR ]]; then chown -R $PRIVATE_ASSETS_OWNER $PRIVATE_ASSETS_DIR; fi"
echo ":: Restarting $SERVICE"
ssh "$REMOTE" "systemctl restart $SERVICE"
echo ":: Verifying service is active"
sleep 2
if ssh "$REMOTE" "systemctl is-active --quiet $SERVICE"; then
echo " $SERVICE is running"
else
echo "!! $SERVICE failed to start — check logs:"
ssh "$REMOTE" "journalctl -u $SERVICE -n 30 --no-pager"
exit 1
fi
echo ":: Deploy complete"
+126
View File
@@ -0,0 +1,126 @@
/**
* One-shot fetch of the 26 Swiss cantonal coats of arms (Wappen) from
* Wikimedia Commons into `static/cantons/<iso-code>.svg`. Files are
* public-domain Swiss official insignia (PD-CH-coat-of-arms); we keep
* the source filename in a header comment for traceability.
*
* Re-run with `pnpm exec vite-node scripts/download-cantons.ts` to refresh
* any missing files. Existing files are left alone — the cantonal arms
* don't change.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
type CantonEntry = {
code: string; // ISO 3166-2:CH (lowercase for filename)
commonsFile: string; // Commons filename WITHOUT the `File:` prefix
};
// Names follow the "Wappen <German-name> matt.svg" convention used across
// almost all cantons on Commons. The handful of exceptions (Basel-Stadt,
// Basel-Landschaft, the two Appenzells) are spelt out explicitly. If a
// fetch returns 404 the script logs the failure and continues so the
// remaining cantons still land.
const CANTONS: CantonEntry[] = [
{ code: 'ag', commonsFile: 'Wappen Aargau matt.svg' },
{ code: 'ai', commonsFile: 'Wappen Appenzell Innerrhoden matt.svg' },
{ code: 'ar', commonsFile: 'Wappen Appenzell Ausserrhoden matt.svg' },
{ code: 'be', commonsFile: 'Wappen Bern matt.svg' },
{ code: 'bl', commonsFile: 'Wappen Basel-Landschaft matt.svg' },
{ code: 'bs', commonsFile: 'Wappen Basel-Stadt matt.svg' },
{ code: 'fr', commonsFile: 'Wappen Freiburg matt.svg' },
{ code: 'ge', commonsFile: 'Wappen Genf matt.svg' },
{ code: 'gl', commonsFile: 'Wappen Glarus matt.svg' },
{ code: 'gr', commonsFile: 'Wappen Graubünden matt.svg' },
{ code: 'ju', commonsFile: 'Wappen Jura matt.svg' },
{ code: 'lu', commonsFile: 'Wappen Luzern matt.svg' },
{ code: 'ne', commonsFile: 'Wappen Neuenburg matt.svg' },
{ code: 'nw', commonsFile: 'Wappen Nidwalden matt.svg' },
{ code: 'ow', commonsFile: 'Wappen Obwalden matt.svg' },
{ code: 'sg', commonsFile: 'Wappen St. Gallen matt.svg' },
{ code: 'sh', commonsFile: 'Wappen Schaffhausen matt.svg' },
{ code: 'so', commonsFile: 'Wappen Solothurn matt.svg' },
{ code: 'sz', commonsFile: 'Wappen Schwyz matt.svg' },
{ code: 'tg', commonsFile: 'Wappen Thurgau matt.svg' },
{ code: 'ti', commonsFile: 'Wappen Tessin matt.svg' },
{ code: 'ur', commonsFile: 'Wappen Uri matt.svg' },
{ code: 'vd', commonsFile: 'Wappen Waadt matt.svg' },
{ code: 'vs', commonsFile: 'Wappen Wallis matt.svg' },
{ code: 'zg', commonsFile: 'Wappen Zug matt.svg' },
{ code: 'zh', commonsFile: 'Wappen Zürich matt.svg' }
];
const OUT_DIR = path.resolve(process.cwd(), 'static', 'cantons');
const UA = 'bocken-homepage cantons-downloader (https://bocken.org)';
async function exists(p: string): Promise<boolean> {
try { await fs.access(p); return true; } catch { return false; }
}
/** Resolve a Commons `File:Foo.svg` to its actual upload.wikimedia.org URL
* via the public API. Returns null on failure (typo in filename, etc.). */
async function resolveCommonsUrl(file: string): Promise<string | null> {
const url =
'https://commons.wikimedia.org/w/api.php' +
'?action=query&format=json&prop=imageinfo&iiprop=url' +
'&titles=' + encodeURIComponent('File:' + file);
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) return null;
const json = (await res.json()) as {
query?: { pages?: Record<string, { imageinfo?: Array<{ url?: string }> }> };
};
const pages = json.query?.pages;
if (!pages) return null;
for (const page of Object.values(pages)) {
const u = page.imageinfo?.[0]?.url;
if (u) return u;
}
return null;
}
async function downloadCanton(c: CantonEntry): Promise<'ok' | 'cached' | 'failed'> {
const outPath = path.join(OUT_DIR, `${c.code}.svg`);
if (await exists(outPath)) return 'cached';
const url = await resolveCommonsUrl(c.commonsFile);
if (!url) {
console.warn(`[cantons] ${c.code}: could not resolve Commons file "${c.commonsFile}"`);
return 'failed';
}
const res = await fetch(url, { headers: { 'User-Agent': UA } });
if (!res.ok) {
console.warn(`[cantons] ${c.code}: HTTP ${res.status} fetching ${url}`);
return 'failed';
}
const body = await res.text();
// Don't prepend anything: most of these files start with an `<?xml … ?>`
// declaration, and that MUST be the very first thing in the file or
// strict XML parsers (including browsers loading via `<img>`) reject
// the document. Provenance is tracked in the CANTONS table above
// instead — keep it out of the file bytes.
await fs.writeFile(outPath, body);
return 'ok';
}
async function main() {
await fs.mkdir(OUT_DIR, { recursive: true });
let ok = 0, cached = 0, failed = 0;
for (const c of CANTONS) {
const r = await downloadCanton(c);
if (r === 'ok') ok++;
else if (r === 'cached') cached++;
else failed++;
if (r === 'ok') console.log(`[cantons] ${c.code}: downloaded`);
else if (r === 'cached') console.log(`[cantons] ${c.code}: cached`);
}
console.log(`[cantons] done — ${ok} downloaded, ${cached} cached, ${failed} failed`);
if (failed > 0) process.exitCode = 1;
}
main().catch((err) => {
console.error('[cantons] fatal:', err);
process.exit(1);
});
+235
View File
@@ -0,0 +1,235 @@
/**
* Re-derive track-point altitudes from a real terrain model.
*
* Phone GPS altitude is noisy (often ±10-20 m), which throws off the elevation
* profile and the ascend/descend stats. This script keeps every point's exact
* lat/lon and only rewrites its `<ele>`, sourcing the height from swisstopo's
* swissALTI3D / DHM25 combined model (~0.5-2 m vertical accuracy) at that exact
* coordinate.
*
* 1. Collect every `<wpt>` and `<trkpt>` in each `track.gpx`.
* 2. Convert WGS84 → LV95 (swisstopo approximate formula, ~1 m horizontal —
* negligible for an elevation lookup).
* 3. Ask swisstopo for the height of each distinct point (one batched
* `profile.json` POST per ~1000 points; per-point `height` as a fallback),
* cached on disk so re-runs and shared points are free.
* 4. Surgically replace each point's `<ele>` value, leaving coordinates,
* timestamps, `<bocken:image>` extensions and all formatting untouched.
*
* swisstopo only covers Switzerland: points outside CH keep their original
* elevation and are reported as skipped.
*
* Usage:
* pnpm exec vite-node scripts/fix-altitudes.ts [slug...] [--dry-run]
* (no slug → every hike under src/content/hikes/)
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(process.cwd());
const CONTENT_DIR = path.join(ROOT, 'src', 'content', 'hikes');
const CACHE_DIR = path.join(ROOT, 'scripts', '.cache');
const CACHE_FILE = path.join(CACHE_DIR, 'swisstopo-elevation.json');
const PROFILE_URL = 'https://api3.geo.admin.ch/rest/services/profile.json';
const HEIGHT_URL = 'https://api3.geo.admin.ch/rest/services/height';
// swisstopo's profile service handles a few thousand vertices per call; keep
// chunks well under that so the POST body and response stay modest.
const PROFILE_CHUNK = 1000;
// Matches a <wpt>/<trkpt> opening tag and its immediate <ele> child. The route
// builder always writes `<ele>` as the first child (verified across every
// track.gpx), so a single capture group around the value is enough to rewrite.
const POINT_ELE_RE =
/(<(?:wpt|trkpt)\s+lat="([^"]+)"\s+lon="([^"]+)"[^>]*>\s*<ele>)([^<]*)(<\/ele>)/g;
type Cache = Record<string, number>;
/** WGS84 (lat/lon, degrees) → CH1903+/LV95 (E, N), swisstopo approx formula. */
function wgs84ToLV95(lat: number, lon: number): [number, number] {
const phi = (lat * 3600 - 169028.66) / 10000;
const lam = (lon * 3600 - 26782.5) / 10000;
const E =
2600072.37 +
211455.93 * lam -
10938.51 * lam * phi -
0.36 * lam * phi * phi -
44.54 * lam ** 3;
const N =
1200147.07 +
308807.95 * phi +
3745.25 * lam * lam +
76.63 * phi * phi -
194.56 * lam * lam * phi +
119.79 * phi ** 3;
return [Math.round(E * 100) / 100, Math.round(N * 100) / 100];
}
const enKey = (E: number, N: number): string => `${E.toFixed(2)},${N.toFixed(2)}`;
async function loadCache(): Promise<Cache> {
try {
return JSON.parse(await fs.readFile(CACHE_FILE, 'utf-8'));
} catch {
return {};
}
}
async function saveCache(cache: Cache): Promise<void> {
await fs.mkdir(CACHE_DIR, { recursive: true });
await fs.writeFile(CACHE_FILE, JSON.stringify(cache));
}
/** Batched height lookup. Returns a map of `enKey` → height for resolved points. */
async function fetchProfile(coords: [number, number][]): Promise<Map<string, number>> {
const out = new Map<string, number>();
if (coords.length < 2) return out;
const body = new URLSearchParams({
geom: JSON.stringify({ type: 'LineString', coordinates: coords }),
sr: '2056',
distinct_points: 'true',
nb_points: String(coords.length),
offset: '0'
});
const res = await fetch(PROFILE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
if (!res.ok) throw new Error(`profile.json HTTP ${res.status}`);
const rows = (await res.json()) as Array<{
alts?: Record<string, number | null>;
easting: number;
northing: number;
}>;
for (const r of rows) {
const h = r.alts?.COMB ?? r.alts?.DTM2 ?? r.alts?.DTM25;
if (typeof h === 'number') out.set(enKey(r.easting, r.northing), h);
}
return out;
}
/** Single-point fallback (also the only option for a 1-point chunk). */
async function fetchHeight(E: number, N: number): Promise<number | null> {
try {
const res = await fetch(`${HEIGHT_URL}?easting=${E}&northing=${N}&sr=2056`);
if (!res.ok) return null;
const j = (await res.json()) as { height?: string | number; success?: boolean };
if (j.success === false) return null;
const h = typeof j.height === 'string' ? parseFloat(j.height) : j.height;
return typeof h === 'number' && Number.isFinite(h) ? h : null;
} catch {
return null;
}
}
type PointKey = string; // `${latStr},${lonStr}` exactly as written in the file
async function fixTrack(slug: string, cache: Cache, dryRun: boolean): Promise<void> {
const file = path.join(CONTENT_DIR, slug, 'track.gpx');
let text: string;
try {
text = await fs.readFile(file, 'utf-8');
} catch {
console.warn(`[fix-altitudes] ${slug}: no track.gpx, skipping`);
return;
}
// Distinct points, keyed by the exact lat/lon strings in the file so the
// rewrite can match without any float round-tripping.
const points = new Map<PointKey, { lat: number; lon: number; E: number; N: number }>();
for (const m of text.matchAll(POINT_ELE_RE)) {
const key = `${m[2]},${m[3]}`;
if (!points.has(key)) {
const lat = parseFloat(m[2]);
const lon = parseFloat(m[3]);
const [E, N] = wgs84ToLV95(lat, lon);
points.set(key, { lat, lon, E, N });
}
}
if (points.size === 0) {
console.warn(`[fix-altitudes] ${slug}: no points found`);
return;
}
// Resolve heights for any points not already cached.
const uncached = [...points.values()].filter((p) => cache[enKey(p.E, p.N)] === undefined);
if (uncached.length > 0) {
for (let i = 0; i < uncached.length; i += PROFILE_CHUNK) {
const chunk = uncached.slice(i, i + PROFILE_CHUNK);
let resolved = new Map<string, number>();
try {
resolved = await fetchProfile(chunk.map((p) => [p.E, p.N] as [number, number]));
} catch (err) {
console.warn(`[fix-altitudes] ${slug}: profile batch failed (${String(err)}), falling back per-point`);
}
for (const p of chunk) {
const k = enKey(p.E, p.N);
let h = resolved.get(k);
if (h === undefined) h = (await fetchHeight(p.E, p.N)) ?? undefined;
if (h !== undefined) cache[k] = h;
}
}
}
// Rewrite each <ele> in place; tally changes and out-of-CH skips.
let updated = 0;
let skipped = 0;
let maxDelta = 0;
const fixed = text.replace(POINT_ELE_RE, (full, open, latStr, lonStr, oldEle, close) => {
const p = points.get(`${latStr},${lonStr}`)!;
const h = cache[enKey(p.E, p.N)];
if (h === undefined) {
skipped++;
return full; // outside CH coverage — keep original elevation
}
const newEle = h.toFixed(1);
const old = parseFloat(oldEle);
if (Number.isFinite(old)) maxDelta = Math.max(maxDelta, Math.abs(h - old));
if (newEle !== oldEle.trim()) updated++;
return `${open}${newEle}${close}`;
});
const summary =
`${points.size} distinct pts · ${updated} ele rewritten · ` +
`max Δ ${maxDelta.toFixed(1)} m` +
(skipped > 0 ? ` · ${skipped} kept (outside CH)` : '');
if (dryRun) {
console.log(`[fix-altitudes] ${slug}: ${summary} (dry-run, not written)`);
return;
}
if (fixed !== text) {
await fs.writeFile(file, fixed);
console.log(`[fix-altitudes] ${slug}: ${summary}`);
} else {
console.log(`[fix-altitudes] ${slug}: already up to date (${summary})`);
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const dryRun = args.includes('--dry-run');
const slugArgs = args.filter((a) => !a.startsWith('--'));
let slugs = slugArgs;
if (slugs.length === 0) {
const entries = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
slugs = entries
.filter((e) => e.isDirectory() && !e.name.startsWith('TODO-'))
.map((e) => e.name)
.sort();
}
const cache = await loadCache();
for (const slug of slugs) {
await fixTrack(slug, cache, dryRun);
}
await saveCache(cache);
console.log(`[fix-altitudes] done (${slugs.length} track(s), cache: ${Object.keys(cache).length} pts)`);
}
main().catch((err) => {
console.error('[fix-altitudes] Fatal:', err);
process.exit(1);
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Build-time generation of bilingual Bible quotes per HTTP error status.
*
* Looks up curated references in static/allioli.tsv (DE) + static/drb.tsv (EN)
* via the existing bible reference parser, then writes the resolved verses to
* src/lib/data/errorQuotes.json for the prerendered /errors/[status] pages.
*
* - Add or change a status by editing REFS below.
* - Refs use the abbreviations defined in the TSVs (e.g. Mt 7,7 / Mt 7:7).
* - Fails the build if any reference cannot be resolved.
*
* Run: pnpm exec vite-node scripts/generate-error-quotes.ts
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { lookupReference } from '../src/lib/server/bible';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '..');
const ALLIOLI = join(ROOT, 'static/allioli.tsv');
const DRB = join(ROOT, 'static/drb.tsv');
const OUT = join(ROOT, 'src/lib/data/errorQuotes.json');
// Curated refs. Abbreviations must match the TSV's `abbreviation` column.
const REFS: Record<number, { de: string; en: string }> = {
401: { de: 'Mt 7,7', en: 'Mt 7:7' },
403: { de: 'Mt 7,14', en: 'Mt 7:14' },
404: { de: 'Mt 7,8', en: 'Mt 7:8' },
500: { de: '2Kor 4,7', en: '2Cor 4:7' },
502: { de: '1Mo 11,9', en: 'Gn 11:9' },
503: { de: 'Ps 37,7', en: 'Ps 37:7' },
504: { de: 'Jes 40,31', en: 'Is 40:31' }
};
type ResolvedQuote = { text: string; reference: string };
function resolveOne(ref: string, tsv: string): ResolvedQuote {
const result = lookupReference(ref, tsv);
if (!result || result.verses.length === 0) {
throw new Error(`could not resolve reference "${ref}" in ${tsv}`);
}
// Range refs join verses with a space. Display reference reuses the
// original input so the UI keeps the canonical "Mt 7,7" / "Mt 7:7" form.
const text = result.verses.map((v) => v.text).join(' ');
return { text, reference: ref };
}
const out: Record<string, { de: ResolvedQuote; en: ResolvedQuote }> = {};
for (const [status, refs] of Object.entries(REFS)) {
out[status] = {
de: resolveOne(refs.de, ALLIOLI),
en: resolveOne(refs.en, DRB)
};
console.log(`[error-quotes] ${status}: ${refs.de} / ${refs.en}`);
}
mkdirSync(dirname(OUT), { recursive: true });
writeFileSync(OUT, JSON.stringify(out, null, 2) + '\n', 'utf8');
console.log(`[error-quotes] wrote ${OUT.replace(ROOT + '/', '')} (${Object.keys(out).length} statuses)`);
+11 -11
View File
@@ -2,15 +2,15 @@
* 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.
* + static/shopping/cumulus.svg. Fails the build if any required env is
* unset so deploys can't silently ship a broken UI.
*
* 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 { mkdirSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { toSVG } from 'bwip-js/node';
@@ -37,15 +37,15 @@ const cards: CardSpec[] = [
mkdirSync(OUT_DIR, { recursive: true });
for (const card of cards) {
const value = process.env[card.envVar]?.trim();
const outPath = resolve(OUT_DIR, card.filename);
const missing = cards.filter((c) => !process.env[c.envVar]?.trim()).map((c) => c.envVar);
if (missing.length) {
console.error(`[loyalty-cards] missing required env: ${missing.join(', ')}`);
process.exit(1);
}
if (!value) {
try { rmSync(outPath); } catch { /* not present */ }
console.log(`[loyalty-cards] ${card.envVar} not set — skipped ${card.filename}`);
continue;
}
for (const card of cards) {
const value = process.env[card.envVar]!.trim();
const outPath = resolve(OUT_DIR, card.filename);
const svg = toSVG({
bcid: card.bcid,
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Sync hike *source* photos to/from a backup server, keeping them out of git.
#
# The repo tracks each hike's index.svx + track.gpx — the manifest of which
# images a hike uses (by content hash) and where they sit on the route. The
# original JPEGs are large and live here instead of in git. `push` backs the
# local photos up; `pull` restores them so any machine can run build-hikes and
# reproduce the encoded static assets.
#
# Only photo files are transferred — images/, private/ and root cover.* —
# mirroring the .gitignore rules; the text files stay in git and are skipped.
#
# Usage:
# scripts/hike-photos.sh push [--dry-run] [--delete]
# scripts/hike-photos.sh pull [--dry-run] [--delete]
#
# --dry-run show what would transfer, change nothing
# --delete mirror exactly (remove extra files on the destination) — careful
#
# Config (env vars, with defaults):
# REMOTE SSH host (default root@bocken.org)
# HIKE_PHOTOS_DIR remote dir for originals (default /var/backups/hike-photos)
set -euo pipefail
REMOTE="${REMOTE:-root@bocken.org}"
HIKE_PHOTOS_DIR="${HIKE_PHOTOS_DIR:-/var/backups/hike-photos}"
cd "$(dirname "$0")/.."
LOCAL="src/content/hikes/"
REMOTE_PATH="$REMOTE:$HIKE_PHOTOS_DIR/"
cmd="${1:-}"
shift || true
EXTRA=()
for arg in "$@"; do
case "$arg" in
--dry-run) EXTRA+=(--dry-run); echo ":: DRY RUN — nothing will be transferred" ;;
--delete) EXTRA+=(--delete) ;;
*) echo "!! Unknown option: $arg" >&2; exit 1 ;;
esac
done
# Transfer only the photo files: descend into each hike dir, take images/,
# private/ and a root cover.*, drop everything else (index.svx, track.gpx,
# icon.svg — those are versioned in git). Empty dirs (e.g. text-only TODO
# drafts) are pruned so the backup stays clean.
FILTERS=(
--prune-empty-dirs
--include='/*/'
--include='/*/images/'
--include='/*/images/**'
--include='/*/private/'
--include='/*/private/**'
--include='/*/cover.*'
--exclude='*'
)
case "$cmd" in
push)
echo ":: Pushing hike photos → $REMOTE_PATH"
ssh "$REMOTE" "mkdir -p '$HIKE_PHOTOS_DIR'"
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$LOCAL" "$REMOTE_PATH"
;;
pull)
echo ":: Pulling hike photos ← $REMOTE_PATH"
mkdir -p "$LOCAL"
rsync -avh --info=progress2 "${EXTRA[@]}" "${FILTERS[@]}" "$REMOTE_PATH" "$LOCAL"
;;
*)
echo "Usage: $0 {push|pull} [--dry-run] [--delete]" >&2
exit 1
;;
esac
echo ":: done"
+107
View File
@@ -0,0 +1,107 @@
/**
* One-time migration: convert legacy `season: number[]` (months 112) on every
* Recipe document to the new `seasonRanges: SeasonRange[]` shape.
*
* Contiguous months are coalesced into a single range. A wrap across the year
* boundary (e.g. months [11, 12, 1, 2]) merges into one wrapping range
* Nov 1 → Feb 28; non-contiguous months stay as separate ranges.
*
* The legacy `season` field is then $unset.
*
* Run before deploying the new code path:
* pnpm exec vite-node scripts/migrate-season-to-ranges.ts
*
* Idempotent: a recipe with no `season` field is left untouched.
*/
import { readFileSync } from 'fs';
import { resolve } from 'path';
import mongoose from 'mongoose';
const envPath = resolve(import.meta.dirname ?? '.', '..', '.env');
const envText = readFileSync(envPath, 'utf-8');
const mongoMatch = envText.match(/^MONGO_URL="?([^"\n]+)"?/m);
if (!mongoMatch) { console.error('MONGO_URL not found in .env'); process.exit(1); }
const MONGO_URL = mongoMatch[1];
const LAST_DAY = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
type FixedRange = { startM: number; endM: number };
/**
* Coalesce a set of months (112) into contiguous ranges, merging the
* year-boundary wrap if both Jan and Dec runs are present.
*/
function coalesceMonths(months: number[]): FixedRange[] {
const sorted = [...new Set(months.filter(m => Number.isInteger(m) && m >= 1 && m <= 12))].sort((a, b) => a - b);
if (sorted.length === 0) return [];
const runs: FixedRange[] = [];
let runStart = sorted[0];
let runEnd = sorted[0];
for (let i = 1; i < sorted.length; i++) {
if (sorted[i] === runEnd + 1) {
runEnd = sorted[i];
} else {
runs.push({ startM: runStart, endM: runEnd });
runStart = sorted[i];
runEnd = sorted[i];
}
}
runs.push({ startM: runStart, endM: runEnd });
// Merge the trailing-Dec run into the leading-Jan run so a winter span
// like [11,12,1,2] becomes one wrapping Nov→Feb range instead of two.
if (runs.length >= 2 && runs[0].startM === 1 && runs[runs.length - 1].endM === 12) {
const wrapped = { startM: runs[runs.length - 1].startM, endM: runs[0].endM };
return [wrapped, ...runs.slice(1, -1)];
}
return runs;
}
function rangeFromRun(run: FixedRange) {
return {
start: { kind: 'fixed', m: run.startM, d: 1 },
end: { kind: 'fixed', m: run.endM, d: LAST_DAY[run.endM - 1] }
};
}
async function main() {
await mongoose.connect(MONGO_URL);
const Recipe = mongoose.connection.collection('recipes');
const cursor = Recipe.find({ season: { $exists: true } });
let migrated = 0;
let skipped = 0;
while (await cursor.hasNext()) {
const doc = await cursor.next() as any;
if (!doc) break;
const months: number[] = Array.isArray(doc.season) ? doc.season : [];
const runs = coalesceMonths(months);
if (runs.length === 0) {
await Recipe.updateOne({ _id: doc._id }, { $unset: { season: '' } });
skipped++;
continue;
}
const seasonRanges = runs.map(rangeFromRun);
await Recipe.updateOne(
{ _id: doc._id },
{ $set: { seasonRanges }, $unset: { season: '' } }
);
migrated++;
if (migrated % 25 === 0) console.log(` migrated ${migrated}`);
}
console.log(`\nDone. Migrated: ${migrated}. Skipped (empty season): ${skipped}.`);
await mongoose.disconnect();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+174
View File
@@ -0,0 +1,174 @@
/**
* Postbuild: precompress static build output for nginx `gzip_static` /
* `brotli_static`.
*
* Replaces adapter-node's `precompress: true`, which brotli-q11 + gzips EVERY
* file in build/client single-threaded — including ~90 MB of already-compressed
* jpg/mp4/png/webp/woff2 (zero gain) and 20 MB+ text blobs at q11 (~30 s each).
*
* This version instead:
* - only touches compressible text types (skips binaries entirely),
* - tunes brotli quality down for large files (q11 is wildly slow past a few MB
* for marginal ratio gains over q10/q9),
* - runs gzip + brotli concurrently across the libuv threadpool,
* - skips files that already have a .br/.gz sibling (e.g. the error pages the
* build-error-page step emits), so it's idempotent.
*
* Run: pnpm exec vite-node scripts/precompress.ts
*/
// The async gzip/brotli calls run on libuv's threadpool. Its size must be set
// before the pool is first used — by the time this module runs under vite-node
// the pool is already up, so postbuild sets UV_THREADPOOL_SIZE on the command
// line (the authoritative knob). This line is just a fallback default for
// direct `vite-node scripts/precompress.ts` runs and won't override an
// already-set value.
import os from 'node:os';
const CORES = Math.max(1, os.cpus().length);
process.env.UV_THREADPOOL_SIZE ||= String(Math.min(CORES, 12));
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
import { join, resolve, dirname, extname, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { gzip, brotliCompress, constants as zlib } from 'node:zlib';
import { promisify } from 'node:util';
const gzipAsync = promisify(gzip);
const brotliAsync = promisify(brotliCompress);
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const TARGET_DIRS = ['build/client', 'build/prerendered'];
// Only these extensions are worth compressing; everything else (images, video,
// fonts, archives) is already compressed and skipped.
const COMPRESSIBLE = new Set([
'.js', '.mjs', '.cjs', '.css', '.html', '.htm', '.json', '.map',
'.svg', '.xml', '.txt', '.tsv', '.csv', '.wasm', '.webmanifest', '.ico'
]);
// Server-side-only data that nonetheless lands in build/client and is read back
// from disk server-side (never delivered to a browser). A .br/.gz sibling for
// these is dead weight nginx never serves — and they're the largest, slowest
// files in the tree, so skipping them is where almost all the time goes. They
// must still exist UNCOMPRESSED for the server reads, so we skip rather than
// remove them. Two kinds:
// - bible TSVs: read via src/lib/server/staticAsset.ts → resolveStaticAsset
// - ML embedding JSONs: `?url`-imported by $lib/server/{nutritionMatcher,
// shoppingCategorizer}.ts and read via SvelteKit's read(); emitted into
// _app/immutable/assets/ with a content hash (…Embeddings.<hash>.json).
const SERVER_ONLY_NAMES = new Set(['allioli.tsv', 'drb.tsv']);
const SERVER_ONLY_RE = /embeddings\.[^/]*\.json$/i;
function isServerOnly(file: string): boolean {
const base = basename(file);
return SERVER_ONLY_NAMES.has(base) || SERVER_ONLY_RE.test(base);
}
// Don't bother compressing tiny files — overhead/headers outweigh the savings.
const MIN_BYTES = 1024;
/** Pick a brotli quality that balances ratio against time for large files. */
function brotliQuality(size: number): number {
if (size > 4 * 1024 * 1024) return 9; // >4 MB: q9 (q11 would take 30 s+)
if (size > 1024 * 1024) return 10; // 14 MB
return 11; // small files: max ratio, still fast
}
async function* walk(dir: string): AsyncGenerator<string> {
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return; // dir doesn't exist (e.g. no prerendered output) — skip
}
for (const entry of entries) {
const full = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(full);
else if (entry.isFile()) yield full;
}
}
async function collect(): Promise<string[]> {
const files: string[] = [];
for (const rel of TARGET_DIRS) {
for await (const f of walk(join(ROOT, rel))) {
const ext = extname(f).toLowerCase();
if (!COMPRESSIBLE.has(ext)) continue;
if (f.endsWith('.gz') || f.endsWith('.br')) continue;
if (isServerOnly(f)) continue;
files.push(f);
}
}
return files;
}
async function exists(p: string): Promise<boolean> {
try {
await stat(p);
return true;
} catch {
return false;
}
}
let saved = 0;
let written = 0;
async function compressOne(file: string): Promise<void> {
const buf = await readFile(file);
if (buf.length < MIN_BYTES) return;
const jobs: Promise<void>[] = [];
if (!(await exists(file + '.gz'))) {
jobs.push(
gzipAsync(buf, { level: zlib.Z_BEST_COMPRESSION }).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.gz', out);
written++;
saved += buf.length - out.length;
}
})
);
}
if (!(await exists(file + '.br'))) {
jobs.push(
brotliAsync(buf, {
params: {
[zlib.BROTLI_PARAM_QUALITY]: brotliQuality(buf.length),
[zlib.BROTLI_PARAM_SIZE_HINT]: buf.length
}
}).then(async (out) => {
if (out.length < buf.length) {
await writeFile(file + '.br', out);
written++;
saved += buf.length - out.length;
}
})
);
}
await Promise.all(jobs);
}
/** Run `tasks` with at most `limit` in flight at once. */
async function pool<T>(items: T[], limit: number, fn: (item: T) => Promise<void>): Promise<void> {
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
await fn(items[idx]);
}
});
await Promise.all(workers);
}
const t0 = Date.now();
const files = await collect();
console.log(`[precompress] ${files.length} compressible files, ${CORES} cores`);
await pool(files, CORES, compressOne);
console.log(
`[precompress] wrote ${written} files, saved ${(saved / 1048576).toFixed(1)} MB in ${(
(Date.now() - t0) / 1000
).toFixed(1)}s`
);
+132
View File
@@ -0,0 +1,132 @@
/**
* Split a single-file i18n module (with an object literal whose values are
* `Record<locale, string>`) into per-locale files under
* src/lib/i18n/<namespace>/<locale>.ts.
*
* The first locale is the source of truth; others use `as const satisfies
* Record<keyof typeof <first>, string>` so missing translations fail
* type-checking.
*
* Run: pnpm exec vite-node scripts/split-i18n.ts <source> <namespace> <locales,csv> [--marker=<marker>] [--basename=<name>]
* e.g. ... cospendI18n.ts cospend de,en
* ... calendarI18n.ts calendar de,en,la --marker='export const ui = {' --basename=de
*
* Defaults: marker = `const translations: Translations = {`, basename = first locale.
*/
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
const [, , srcPath, namespace, localesCsv, ...flags] = process.argv;
if (!srcPath || !namespace || !localesCsv) {
console.error(
'usage: split-i18n.ts <source> <namespace> <locales,csv> [--marker=...] [--basename=...]'
);
process.exit(1);
}
const locales = localesCsv.split(',').map((s) => s.trim()).filter(Boolean);
const markerFlag = flags.find((f) => f.startsWith('--marker='));
const startMarker = markerFlag
? markerFlag.slice('--marker='.length)
: 'const translations: Translations = {';
const basenameFlag = flags.find((f) => f.startsWith('--basename='));
const fileBase = basenameFlag ? basenameFlag.slice('--basename='.length) : '';
const src = readFileSync(srcPath, 'utf8');
// Slice the translations object body
const startIdx = src.indexOf(startMarker);
if (startIdx === -1) throw new Error(`marker not found in ${srcPath}: ${startMarker}`);
// Object literal can close with `};` or `} as const;` — pick the earliest match.
const candA = src.indexOf('\n};', startIdx);
const candB = src.indexOf('\n} as const', startIdx);
const endIdx =
candA < 0 ? candB : candB < 0 ? candA : Math.min(candA, candB);
if (endIdx === -1) throw new Error('translations object end not found');
const body = src.slice(startIdx + startMarker.length, endIdx);
// Match each translation entry boundary: `key: { ...inner... },`. Each
// entry's body is then parsed independently for `loc: 'value'` pairs, so
// locale order in the source file doesn't matter.
const entryRe = /^\s*(\w+)\s*:\s*\{([\s\S]*?)\}\s*,?\s*$/gm;
// Match `loc: '...'` OR `loc: "..."` (double quotes are used when the string
// contains a literal apostrophe).
const localeRe = /(\w+)\s*:\s*(?:'([^']*)'|"((?:\\.|[^"\\])*)")/g;
function decodeJsString(raw: string, doubleQuoted: boolean): string {
if (doubleQuoted) {
// Already valid JSON (escapes preserved). Parse directly.
return JSON.parse('"' + raw + '"');
}
// Single-quoted: convert any \' → ' and escape literal " for JSON.
const jsonReady = '"' + raw.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"';
return JSON.parse(jsonReady);
}
interface Entry {
key: string;
values: Record<string, string>;
}
const entries: Entry[] = [];
let m: RegExpExecArray | null;
while ((m = entryRe.exec(body)) !== null) {
const inner = m[2];
const values: Record<string, string> = {};
let lm: RegExpExecArray | null;
while ((lm = localeRe.exec(inner)) !== null) {
const single = lm[2];
const double = lm[3];
values[lm[1]] = single !== undefined
? decodeJsString(single, false)
: decodeJsString(double, true);
}
for (const loc of locales) {
if (!(loc in values)) {
throw new Error(`entry "${m[1]}" is missing locale "${loc}"`);
}
}
entries.push({ key: m[1], values });
}
console.log(`extracted ${entries.length} entries`);
const outDir = `src/lib/i18n/${namespace}`;
mkdirSync(outDir, { recursive: true });
const sourceLocale = locales[0];
// Optional file prefix lets us split multiple tables into the same dir
// (e.g. calendar `ui` → de.ts, calendar `ui1962` → de_1962.ts).
const path = (loc: string) => `${outDir}/${fileBase ? `${loc}_${fileBase}` : loc}.ts`;
// Write the source-of-truth locale (no satisfies clause).
{
const lines = [
'/** Generated by scripts/split-i18n.ts. */',
`/** ${sourceLocale.toUpperCase()} ${namespace}${fileBase ? ` (${fileBase})` : ''} UI strings — source of truth for the key set. */`,
'',
`export const ${sourceLocale} = {`
];
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[sourceLocale])},`);
lines.push('} as const;', '');
writeFileSync(path(sourceLocale), lines.join('\n'));
}
// Write the other locales with `satisfies` constraint.
const sourceFile = fileBase ? `${sourceLocale}_${fileBase}` : sourceLocale;
for (let i = 1; i < locales.length; i++) {
const loc = locales[i];
const lines = [
'/** Generated by scripts/split-i18n.ts. */',
`import type { ${sourceLocale} } from './${sourceFile}';`,
'',
`export const ${loc} = {`
];
for (const e of entries) lines.push(`\t${e.key}: ${JSON.stringify(e.values[loc])},`);
lines.push(
`} as const satisfies Record<keyof typeof ${sourceLocale}, string>;`,
''
);
writeFileSync(path(loc), lines.join('\n'));
}
console.log(`wrote ${locales.map(path).join(', ')}`);
+424
View File
@@ -0,0 +1,424 @@
/**
* Build-time static hero-map renderer for individual hikes.
*
* Fetches the Swisstopo raster tiles covering each hike's bbox, composites
* them into one PNG via sharp, draws the trail polyline + start/end markers
* on top, and emits a single WebP. The result is served as `<img>` in the
* detail page's hero so the user sees an exact replica of the live map
* during the few hundred milliseconds it takes Leaflet to dynamic-import,
* fetch tiles, and render — eliminating the perceived load delay.
*
* Tiles are content-cached on disk; rendered heroes are name-cached by
* content hash so a re-build with unchanged GPX is a no-op.
*/
import { promises as fs } from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
const TILE_SIZE = 256;
const TILE_CACHE_DIR = path.resolve(process.cwd(), 'scripts', '.cache', 'swisstopo-tiles');
// Swisstopo serves the WMTS tiles from wmts10wmts100. Spread across a
// couple of sub-domains so we don't hammer a single origin during initial
// build (browsers see different hosts; the disk cache makes follow-up
// builds a non-event regardless).
const SUBDOMAINS = ['wmts10', 'wmts20'] as const;
const USER_AGENT = 'bocken-homepage build-hikes';
function tileUrl(sub: string, layer: string, z: number, x: number, y: number): string {
return `https://${sub}.geo.admin.ch/1.0.0/${layer}/default/current/3857/${z}/${x}/${y}.jpeg`;
}
/** Web Mercator: lng/lat → absolute pixel coordinate at a given zoom. */
export function lngLatToPx(lng: number, lat: number, zoom: number): { x: number; y: number } {
const n = 2 ** zoom;
const x = ((lng + 180) / 360) * n * TILE_SIZE;
const latRad = (lat * Math.PI) / 180;
const y =
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n * TILE_SIZE;
return { x, y };
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
/** `null` = network failure (we'll count it against the abort threshold).
* `'blank'` = HTTP 4xx, i.e. the tile is intentionally not served — for
* the Swisstopo Pixelkarte that means we're outside Switzerland's bbox.
* The overview hero canvas extends into DE/IT/FR, so we treat blanks as
* "OK, just nothing there" rather than failures. */
type TileResult = Buffer | 'blank' | null;
async function fetchTile(
layer: string,
z: number,
x: number,
y: number
): Promise<TileResult> {
const key = `${layer.replace(/[^a-z0-9]/gi, '_')}_${z}_${x}_${y}.jpeg`;
const cachePath = path.join(TILE_CACHE_DIR, key);
try {
return await fs.readFile(cachePath);
} catch { /* miss */ }
const sub = SUBDOMAINS[(x + y) % SUBDOMAINS.length];
try {
const res = await fetch(tileUrl(sub, layer, z, x, y), {
headers: { 'User-Agent': USER_AGENT }
});
if (!res.ok) {
// 4xx means "we don't serve this tile" (out-of-bounds for the
// Swiss data set). Anything else (5xx) is a real failure.
if (res.status >= 400 && res.status < 500) return 'blank';
if (process.env.STATIC_MAP_DEBUG) {
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} HTTP ${res.status}`);
}
return null;
}
const buf = Buffer.from(await res.arrayBuffer());
await fs.mkdir(TILE_CACHE_DIR, { recursive: true });
await fs.writeFile(cachePath, buf);
return buf;
} catch (err) {
if (process.env.STATIC_MAP_DEBUG) {
console.warn(`[staticHikeMap] tile ${z}/${x}/${y} error:`, err);
}
return null;
}
}
function escapeSvgNumber(n: number): string {
// Keep SVG path compact but precise enough for 1600 px rendering.
return n.toFixed(1);
}
export interface RenderStaticMapPhotoMarker {
lat: number;
lng: number;
}
export interface StaticMapPose {
zoom: number;
centerLat: number;
centerLng: number;
/** Origin in zoom-pixel space — top-left of the output canvas. The
* renderer needs it; the caller doesn't, but exposing it keeps the
* `computePose` ↔ `renderStaticMap` interface stateless. */
originX: number;
originY: number;
}
export interface ComputeStaticMapPoseOpts {
bbox: [number, number, number, number];
/** Canvas dimensions for centering / tile fetching. */
width?: number;
height?: number;
paddingPx?: number;
/** Reference dimensions used purely for zoom selection. Defaults to
* `width × height` — but pass the expected *display* size (not the
* rendered canvas size) when you want zoom to match Leaflet's
* `fitBounds` at the user's viewport. The renderer still draws the
* full `width × height` canvas around the chosen zoom, so wider
* viewports get more context without the bbox being cropped on
* smaller ones. */
fitWidth?: number;
fitHeight?: number;
/** Upper bound on the zoom search — mirrors Leaflet's `fitBounds({ maxZoom })`.
* Use this when the live map clamps its zoom so the static hero doesn't
* land at a more detailed level than Leaflet will ever show. */
maxZoom?: number;
}
/** Pure-math pass: pick the zoom + centre + canvas origin that the static
* renderer would use for these inputs. Identical for light- and dark-
* themed renders, so callers can compute it once and re-use. */
export function computeStaticMapPose(opts: ComputeStaticMapPoseOpts): StaticMapPose | null {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const paddingPx = opts.paddingPx ?? 24;
const fitWidth = opts.fitWidth ?? width;
const fitHeight = opts.fitHeight ?? height;
const maxZoom = opts.maxZoom ?? 18;
const [minLat, minLng, maxLat, maxLng] = opts.bbox;
if (
!Number.isFinite(minLat) || !Number.isFinite(minLng) ||
!Number.isFinite(maxLat) || !Number.isFinite(maxLng)
) {
return null;
}
const innerW = Math.max(1, fitWidth - 2 * paddingPx);
const innerH = Math.max(1, fitHeight - 2 * paddingPx);
// Pick the highest integer zoom where the bbox fits inside the
// reference inner rectangle. This mirrors Leaflet's `fitBounds`
// integer-zoom search, so a viewport matching `fitWidth × fitHeight`
// will choose the same zoom Leaflet does for the same bbox.
let zoom = 7;
for (let z = maxZoom; z >= 7; z--) {
const tl = lngLatToPx(minLng, maxLat, z);
const br = lngLatToPx(maxLng, minLat, z);
if (br.x - tl.x <= innerW && br.y - tl.y <= innerH) {
zoom = z;
break;
}
}
const centerLat = (minLat + maxLat) / 2;
const centerLng = (minLng + maxLng) / 2;
const c = lngLatToPx(centerLng, centerLat, zoom);
const originX = Math.round(c.x - width / 2);
const originY = Math.round(c.y - height / 2);
return { zoom, centerLat, centerLng, originX, originY };
}
export interface RenderStaticMapOpts {
/** Pre-computed pose (zoom + centre + origin). Get this via
* `computeStaticMapPose(...)`. Shared by light- and dark-themed
* renders so both variants align perfectly. */
pose: StaticMapPose;
/** Track polyline as `[lat, lng]` tuples (any length). */
polyline: Array<[number, number]>;
color: string;
outputPath: string;
width?: number;
height?: number;
/** Swisstopo WMTS layer ID. Defaults to the schematic Pixelkarte (the
* same base layer Leaflet starts with on the detail page). */
layer?: string;
/** Optional image-point markers to burn into the SVG overlay alongside
* the start/end dots. Pass only the points safe to render in a public-
* facing image — private photos should be filtered out by the caller. */
photoMarkers?: RenderStaticMapPhotoMarker[];
/** Fill colour for the photo marker dots. Should match the live
* HikePhoto marker styling (`--color-primary`). */
photoMarkerColor?: string;
/** Border colour for the photo marker dots — matches the live
* `.hike-photo-marker .badge` `border-color: var(--color-surface)` so
* the static blends in with the active theme's surface colour. */
photoMarkerBorderColor?: string;
/** Stroke colour of the Lucide `camera` icon inside the badge. Matches
* the live badge's `color: var(--color-text-on-primary)` — white on
* the light theme's mid-blue primary, dark on the dark theme's light-
* blue primary. */
photoMarkerIconColor?: string;
}
/** Fetch every Swisstopo tile covering the canvas at the given pose, then
* composite them into a single PNG buffer. Returns `null` when fewer than
* half the tiles arrive (a patchy hero is worse than no hero). Shared by
* `renderStaticMap` (per-hike hero) and `renderOverviewMap` (the /hikes
* landing-page hero) so both pull the same tile cache and use the same
* fallback colour. */
async function composeBaseMap(
pose: StaticMapPose,
width: number,
height: number,
layer: string
): Promise<Buffer | null> {
const { zoom, originX, originY } = pose;
const minTileX = Math.floor(originX / TILE_SIZE);
const maxTileX = Math.floor((originX + width - 1) / TILE_SIZE);
const minTileY = Math.floor(originY / TILE_SIZE);
const maxTileY = Math.floor((originY + height - 1) / TILE_SIZE);
// Parallel tile fetches — disk cache makes follow-up builds essentially
// free, but the first build pulls ~620 tiles per per-hike hero and
// considerably more for the overview hero.
const tileJobs: Array<{ tx: number; ty: number; left: number; top: number }> = [];
for (let ty = minTileY; ty <= maxTileY; ty++) {
for (let tx = minTileX; tx <= maxTileX; tx++) {
tileJobs.push({
tx,
ty,
left: tx * TILE_SIZE - originX,
top: ty * TILE_SIZE - originY
});
}
}
const tileBufs = await Promise.all(
tileJobs.map(async (job) => ({
job,
buf: await fetchTile(layer, zoom, job.tx, job.ty)
}))
);
const composites: Array<{ input: Buffer; left: number; top: number }> = [];
let networkFailures = 0;
for (const { job, buf } of tileBufs) {
if (buf === null) {
networkFailures++;
continue;
}
if (buf === 'blank') continue; // out-of-bounds, draw the fallback grey
composites.push({ input: buf, left: job.left, top: job.top });
}
// Network-failure threshold (not "fewer than half present"): blank
// out-of-bounds tiles are an expected outcome for the overview hero
// that extends past Switzerland's edges, so they don't count against
// the abort threshold.
if (networkFailures > tileJobs.length / 2) return null;
// Tile composite is identical regardless of UI theme — we deliberately
// don't invert the Pixelkarte for dark mode (its colour palette doesn't
// survive a naive invert). Only the SVG overlay above changes per theme.
return sharp({
create: { width, height, channels: 3, background: { r: 235, g: 235, b: 235 } }
})
.composite(composites)
.png()
.toBuffer();
}
/** Render and write a single static hero map at the given pose. Returns
* `false` on failure (zero tiles fetched, degenerate inputs). */
export async function renderStaticMap(opts: RenderStaticMapOpts): Promise<boolean> {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
const { zoom, originX, originY } = opts.pose;
if (opts.polyline.length < 2) return false;
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
if (!mapBuf) return false;
// SVG overlay — polyline + photo markers + start/end dots.
const pathParts: string[] = [];
for (let i = 0; i < opts.polyline.length; i++) {
const [lat, lng] = opts.polyline[i];
const p = lngLatToPx(lng, lat, zoom);
const px = p.x - originX;
const py = p.y - originY;
pathParts.push((i === 0 ? 'M' : 'L') + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
}
const start = opts.polyline[0];
const end = opts.polyline[opts.polyline.length - 1];
const startP = lngLatToPx(start[1], start[0], zoom);
const endP = lngLatToPx(end[1], end[0], zoom);
const sx = escapeSvgNumber(startP.x - originX);
const sy = escapeSvgNumber(startP.y - originY);
const ex = escapeSvgNumber(endP.x - originX);
const ey = escapeSvgNumber(endP.y - originY);
const photoMarkerColor = opts.photoMarkerColor ?? '#5e81ac';
const photoMarkerBorderColor = opts.photoMarkerBorderColor ?? '#eceff4';
const photoMarkerIconColor = opts.photoMarkerIconColor ?? '#fff';
// Match HikeMap's `.hike-photo-marker .badge` — 28 px Nord-blue circle
// with a 2 px theme-surface border, holding a 14 px theme-on-primary
// Lucide `camera` icon. The camera icon paths are the literal Lucide
// source (lucide-camera).
const photoMarkers = (opts.photoMarkers ?? [])
.map((m) => {
const p = lngLatToPx(m.lng, m.lat, zoom);
const cx = escapeSvgNumber(p.x - originX);
const cy = escapeSvgNumber(p.y - originY);
return (
`<g transform="translate(${cx} ${cy})">` +
`<circle r="14" fill="${photoMarkerColor}" stroke="${photoMarkerBorderColor}" stroke-width="2"/>` +
`<g transform="translate(-7 -7) scale(0.5833)" stroke="${photoMarkerIconColor}" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">` +
`<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z"/>` +
`<circle cx="12" cy="13" r="3"/>` +
`</g>` +
`</g>`
);
})
.join('');
const overlay = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
`<path d="${pathParts.join(' ')}" fill="none" stroke="${opts.color}" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.95"/>` +
photoMarkers +
`<circle cx="${sx}" cy="${sy}" r="9" fill="#a3be8c" stroke="#fff" stroke-width="3"/>` +
`<circle cx="${ex}" cy="${ey}" r="9" fill="#bf616a" stroke="#fff" stroke-width="3"/>` +
`</svg>`
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}
// ---------------------------------------------------------------------------
// Overview hero (one image for the whole /hikes index page).
// Same tile composite as `renderStaticMap`, but the overlay draws many
// polylines (one per hike, coloured by SAC tier) and no per-route start /
// end / photo markers — the map is a finder, not a detail view.
// ---------------------------------------------------------------------------
export interface RenderOverviewPolyline {
points: Array<[number, number]>;
color: string;
/** Indices where a new disconnected sub-path begins (multi-day stage gaps
* >1 km), so the line isn't drawn across an overnight transfer. */
breaks?: number[];
}
export interface RenderOverviewMapOpts {
pose: StaticMapPose;
polylines: RenderOverviewPolyline[];
outputPath: string;
width?: number;
height?: number;
layer?: string;
}
export async function renderOverviewMap(opts: RenderOverviewMapOpts): Promise<boolean> {
const width = opts.width ?? 1600;
const height = opts.height ?? 1000;
const layer = opts.layer ?? 'ch.swisstopo.pixelkarte-farbe';
const { zoom, originX, originY } = opts.pose;
const drawable = opts.polylines.filter((p) => p.points.length >= 2);
if (drawable.length === 0) return false;
const mapBuf = await composeBaseMap(opts.pose, width, height, layer);
if (!mapBuf) return false;
// One <path> per hike polyline. The overview map is rendered fairly
// zoomed-out, so even ≤150-point preview polylines stay compact.
const paths = drawable
.map((line) => {
const breakSet = new Set(line.breaks ?? []);
const parts: string[] = [];
for (let i = 0; i < line.points.length; i++) {
const [lat, lng] = line.points[i];
const p = lngLatToPx(lng, lat, zoom);
const px = p.x - originX;
const py = p.y - originY;
// Start a fresh sub-path at index 0 and at every stage break.
const cmd = i === 0 || breakSet.has(i) ? 'M' : 'L';
parts.push(cmd + escapeSvgNumber(px) + ',' + escapeSvgNumber(py));
}
return (
`<path d="${parts.join(' ')}" fill="none" stroke="${line.color}" ` +
`stroke-width="4" stroke-linecap="round" stroke-linejoin="round" stroke-opacity="0.9"/>`
);
})
.join('');
const overlay = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
paths +
`</svg>`
);
await sharp(mapBuf)
.composite([{ input: overlay, left: 0, top: 0 }])
.webp({ quality: 78 })
.toFile(opts.outputPath);
return true;
}
+1 -1
View File
@@ -144,7 +144,7 @@ dependencies = [
[[package]]
name = "bocken"
version = "0.5.1"
version = "0.5.3"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "bocken"
version = "0.5.1"
version = "0.5.3"
edition = "2021"
[lib]
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 952 B

After

Width:  |  Height:  |  Size: 473 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

+97
View File
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="g1">
<rect
style="fill:#2e3440;stroke-width:15;stroke-linecap:round;stroke-linejoin:round"
id="rect1"
width="1024"
height="1024"
x="0"
y="0" />
<g
id="g2"
transform="matrix(6.5209236,0,0,6.5209236,362.8589,246.15055)">
<g
class="stroke"
id="branches"
transform="translate(-42.033271,-37.145192)"
style="stroke:#d8dee9;stroke-opacity:1">
<path
d="m 65.113709,84.638921 c -0.346049,-9.794303 8.85917,-32.693347 8.85917,-32.693347"
id="path1"
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 65.108044,84.684262 c 0.346049,-9.794303 -8.85917,-32.693347 -8.85917,-32.693347"
id="path2"
style="fill:none;stroke:#d8dee9;stroke-width:3;stroke-dasharray:none;stroke-opacity:1" />
</g>
<g
class="leaf"
id="g1-2"
style="fill:#d8dee9;fill-opacity:1">
<path
d="M 0,0 C 6.633,-3.91 14.348,-4.302 20.992,-1.732 20.009,5.333 15.93,11.893 9.31,15.795 2.69,19.697 -5.025,20.088 -11.669,17.519 -10.7,10.462 -6.62,3.901 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,4.116564,13.543871)"
id="path3"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m 0,0 c -6.62,3.901 -14.335,4.293 -20.979,1.724 0.97,-7.058 5.049,-13.618 11.669,-17.519 6.633,-3.91 14.348,-4.301 20.992,-1.732 C 10.699,-10.462 6.62,-3.902 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,10.339434,19.278333)"
id="path4"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="M 0,0 C 6.633,-3.909 14.348,-4.301 20.992,-1.731 20.009,5.333 15.93,11.894 9.31,15.795 2.69,19.697 -5.026,20.088 -11.669,17.52 -10.7,10.461 -6.62,3.902 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,10.903454,36.572256)"
id="path5"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="M 0,0 C 6.644,-2.57 14.358,-2.178 20.992,1.732 27.612,5.633 31.691,12.194 32.661,19.25 26.017,21.82 18.302,21.429 11.682,17.527 5.062,13.625 0.982,7.065 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,32.871328,24.119748)"
id="path6"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="M 0,0 C 6.62,3.901 10.699,10.461 11.669,17.519 5.025,20.088 -2.689,19.696 -9.31,15.795 -15.93,11.893 -20.009,5.333 -20.992,-1.732 -14.348,-4.301 -6.633,-3.91 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,35.741597,35.870171)"
id="path7"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m -27.40181,13.441787 c 6.644,-2.57 14.359,-2.178 20.9920004,1.731 6.62000002,3.902 10.699,10.461 11.669,17.519 -6.644,2.569 -14.359,2.178 -20.9790004,-1.724 -6.62,-3.901 -10.7,-10.462 -11.682,-17.526"
transform="matrix(0.35277777,0,0,-0.35277777,43.12113,17.474745)"
id="path8"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.205 -6.242,-3.433 -10.906,-9.591 -12.178,-17.169 -1.275,-7.594 1.123,-14.937 5.902,-20.22 C -5.936,-13.736 -1.273,-7.578 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,20.082753,7.127875)"
id="path9"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="m 0,0 c 1.271,7.579 -1.125,14.922 -5.904,20.206 -6.242,-3.434 -10.906,-9.592 -12.178,-17.17 -1.275,-7.593 1.123,-14.937 5.902,-20.22 C -5.937,-13.736 -1.273,-7.578 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,26.963346,20.756878)"
id="path10"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
<path
d="M 0,0 C 4.779,5.283 7.176,12.627 5.901,20.22 4.629,27.798 -0.035,33.956 -6.277,37.39 -11.055,32.106 -13.453,24.763 -12.18,17.184 -10.908,9.606 -6.244,3.448 0,0"
transform="matrix(0.35277777,0,0,-0.35277777,29.06985,14.051408)"
id="path11"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none" />
</g>
<path
class="fill"
d="m 23.308833,63.179301 -3.288947,-3.831872 2.16535,-2.433461 1.123597,-1.262592 1.119364,1.257653 2.169936,2.4384 z M 37.853155,39.714993 c -0.02117,0.08396 -0.9652,3.0988 -3.220508,5.991225 -1.128536,1.453444 -2.574573,2.872317 -4.37515,3.93065 -1.617486,0.947914 -3.517195,1.617839 -5.820481,1.786467 l -1.128183,-1.267531 -1.127125,1.266825 C 19.838911,51.249415 17.912391,50.557971 16.276561,49.58254 13.551705,47.957293 11.640003,45.483263 10.434208,43.388115 9.8309581,42.34354 9.4048026,41.401624 9.134222,40.732051 8.9991081,40.397265 8.902447,40.131271 8.8414165,39.954529 8.8107248,39.865982 8.7892053,39.800013 8.7761526,39.75909 l -0.013053,-0.04233 -0.00212,-0.006 L 8.374688,38.405835 H 0.87287218 v 3.653366 H 5.7302693 c 0.5323417,1.327503 1.5515166,3.495323 3.2441444,5.720645 1.3409083,1.757539 3.1143223,3.553177 5.4257223,4.937477 1.423105,0.854428 3.055761,1.541992 4.884914,1.960034 l -1.365956,1.534936 -2.751314,3.091744 4.069998,4.741686 c -1.8415,0.426861 -3.481212,1.128536 -4.909256,1.995664 -3.439936,2.087739 -5.6744305,5.06095 -7.0735472,7.485944 -0.7094361,1.234017 -1.2043833,2.33292 -1.5250583,3.129845 H 0.87287218 v 3.653366 H 8.3746915 l 0.3869972,-1.306689 c 0.017992,-0.07479 0.9574388,-3.071988 3.1996943,-5.959122 1.120775,-1.448505 2.556934,-2.865966 4.343753,-3.928886 1.594908,-0.94615 3.464983,-1.623483 5.726994,-1.814336 l 1.276703,1.486959 1.276703,-1.486959 c 2.304697,0.193675 4.202641,0.89147 5.8166,1.865136 2.703336,1.631245 4.598811,4.097514 5.794022,6.182431 0.597605,1.039283 1.01988,1.975908 1.288344,2.640894 0.134056,0.33267 0.229658,0.597253 0.289983,0.772583 0.02999,0.08784 0.05151,0.153459 0.06456,0.194028 l 0.01305,0.04198 0.0014,0.0056 0.385939,1.306336 h 7.502877 V 76.657176 H 40.885985 C 40.357172,75.336729 39.347522,73.18549 37.673944,70.973573 36.344677,69.222384 34.586433,67.430273 32.294788,66.041387 30.865333,65.173554 29.224563,64.47082 27.3813,64.044312 L 31.450238,59.304037 28.698572,56.21194 27.33438,54.679121 c 1.829153,-0.417336 3.461456,-1.104195 4.884208,-1.957917 3.46957,-2.081036 5.72135,-5.0673 7.129639,-7.505347 0.716845,-1.244953 1.216025,-2.353733 1.538111,-3.156656 h 4.855986 v -3.653366 h -7.502877 z"
id="path12"
style="fill:#d8dee9;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1 -1
View File
@@ -1,7 +1,7 @@
{
"productName": "Bocken",
"identifier": "org.bocken.app",
"version": "0.5.1",
"version": "0.5.3",
"build": {
"devUrl": "http://192.168.1.4:5173",
"frontendDist": "https://bocken.org"
+93
View File
@@ -464,6 +464,99 @@ a:focus-visible {
animation: none;
}
/* ============================================
HIKES TRANSITIONS
Cards + filter fly in/out vertically, clicked card morphs into the hero
map (cross-fade between thumbnail and map), and the whole below-map panel
(an opaque sheet) slides up from the bottom. Page chrome under the hero
cross-fades so nothing snaps in at transition end. Lives in app.css (not
the page component) so the rules are still loaded on the OLD side of a
nav AWAY from /hikes.
============================================ */
@keyframes hikes-fly-up {
from { transform: translateY(100vh); }
to { transform: translateY(0); }
}
@keyframes hikes-fly-down {
from { transform: translateY(0); }
to { transform: translateY(100vh); }
}
@keyframes hikes-root-fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes hikes-root-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* Only-child hike-fly-in pseudos (unpaired cards/filter on enter or exit):
* kill UA's default fade, switch blend mode so the custom fly animation
* shows clean motion against the rest of the page. */
::view-transition-old(.hike-fly-in):only-child,
::view-transition-new(.hike-fly-in):only-child {
animation: none;
mix-blend-mode: normal;
}
/* Paired (card ↔ hero): keep UA cross-fade so the card thumbnail dissolves
* into the hero map — otherwise the new image would just cover the old one
* and the thumbnail would vanish silently at t=0. Stretch the duration to
* match the group so the fade ends exactly when the morph does. */
::view-transition-old(.hike-fly-in):not(:only-child),
::view-transition-new(.hike-fly-in):not(:only-child) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Group (the morphing bbox) timing. */
::view-transition-group(.hike-fly-in) {
animation-duration: 550ms;
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Cards + filter rise from below the viewport on enter. */
html.vt-enter-hikes::view-transition-new(.hike-fly-in):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
/* Cards + filter drop off the bottom on exit. */
html.vt-exit-hikes::view-transition-old(.hike-fly-in):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Everything below the hero map on a detail page — stage nav, photo strip,
* metrics, tags, elevation chart, scroll area, meta footer — slides up from
* the bottom on enter and back down on any exit, as one panel. The wrapper
* carries `view-transition-name: hike-below-map` and an opaque background, so
* the whole sheet (background included) moves; the hero map morphs separately
* above, and the rest of the page chrome cross-fades via the root-pseudo rule. */
html.vt-enter-hike-detail::view-transition-new(hike-below-map):only-child {
animation: hikes-fly-up 700ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
html.vt-enter-hikes::view-transition-old(hike-below-map):only-child,
html.vt-exit-hike-detail::view-transition-old(hike-below-map):only-child {
animation: hikes-fly-down 700ms cubic-bezier(0.4, 0.1, 0.4, 1) both;
}
/* Cross-fade the rest of the page (root pseudo) during hike transitions so
* the destination's chrome — metrics + content + footer on the detail page,
* overview hero + credit on the index — phases in instead of snapping in
* at the end of the morph. Overrides the global rule above; scope keeps
* other routes' transitions on their existing instant-swap behavior. */
html.vt-enter-hike-detail::view-transition-old(root),
html.vt-enter-hikes::view-transition-old(root),
html.vt-exit-hikes::view-transition-old(root),
html.vt-exit-hike-detail::view-transition-old(root) {
animation: hikes-root-fade-out 450ms ease-out both;
}
html.vt-enter-hike-detail::view-transition-new(root),
html.vt-enter-hikes::view-transition-new(root),
html.vt-exit-hikes::view-transition-new(root),
html.vt-exit-hike-detail::view-transition-new(root) {
animation: hikes-root-fade-in 450ms ease-out both;
}
/* ============================================
RECIPE GRID
Responsive card grid used across recipe pages
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
@@ -0,0 +1,102 @@
---
title: Flims Gletschermühlen
date: 2024-07-14
author: Alexander
difficulty: T2
tags: [Graubünden, Flims, Sommer]
seasons: 5-8
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
import Private from '$lib/components/Private.svelte';
</script>
## Anreise
Start bei Bargis. Anreise am besten via Bus von Flims.
<JourneyPlanner from="<current location>" to="Fidaz, Bargis" toFixed time="07:00" target="arrival"/>
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
<HikeImage idx={23} />
<HikeImage idx={24} />
<HikeImage idx={25} />
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
<HikeImage idx={29} />
<HikeImage idx={30} />
<HikeImage idx={31} />
<HikeImage idx={32} />
<HikeImage idx={33} />
<HikeImage idx={34} />
<HikeImage idx={35} />
<HikeImage idx={36} />
<HikeImage idx={37} />
<HikeImage idx={38} />
<HikeImage idx={39} />
<HikeImage idx={40} />
<HikeImage idx={41} />
<HikeImage idx={42} />
<HikeImage idx={43} />
<HikeImage idx={44} />
<HikeImage idx={45} />
<HikeImage idx={46} />
<HikeImage idx={47} />
<HikeImage idx={48} />
<HikeImage idx={49} />
<HikeImage idx={50} />
<HikeImage idx={51} />
<HikeImage idx={52} />
<HikeImage idx={53} />
<HikeImage idx={54} />
<HikeImage idx={55} />
<HikeImage idx={56} />
<HikeImage idx={57} />
<HikeImage idx={58} />
<HikeImage idx={59} />
<HikeImage idx={60} />
<HikeImage idx={61} />
<HikeImage idx={62} />
<HikeImage idx={63} />
<HikeImage idx={64} />
<HikeImage idx={65} />
<HikeImage idx={66} />
<HikeImage idx={67} />
<HikeImage idx={68} />
<HikeImage idx={69} />
<HikeImage idx={70} />
<HikeImage idx={71} />
<HikeImage idx={72} />
<HikeImage idx={73} />
<HikeImage idx={74} />
<HikeImage idx={75} />
## Abreise
Via Bus oder Auto wieder nach Hause. Wenn man nicht abgeholt wird wie wir, muss man noch etwas weiter laufen bis nach Trin.
<JourneyPlanner from="Trin, Quadris" fromFixed to="<current location>" time="15:30" target="departure"/>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
---
title: Schlittelausflug Brün
date: 2024-12-25
author: Alexander
difficulty: T1
tags: [Graubünden, Flims, Winter, Schlitteln]
seasons: 12-2
summary:
heroAlt:
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
import Private from '$lib/components/Private.svelte';
</script>
## Übersicht
Ein netter Ausflug zum Schlitteln, wenn man bereits in Flims ist.
Aufstieg ca. 1 Stunde mit wunderschöner Winterlandschaft.
## Anreise
Start direkt in Brün. Eine Anreise mit Bus (Linie 404) ist möglich, ein direktes Anfahren mit Auto wäre jedoch zu empfehlen.
Es empfiehlt sich ca. um 11 Uhr in Brün anzukommen, da durch die Nähe zum Piz Riein ausserhalb der Mittagszeit es schnell schattig werden kann.
<JourneyPlanner from="<current location>" to="Valendas, Brün Dorf" toFixed time="11:00" target="arrival"/>
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage src="PXL_20241225_121635285.jpg" alt="Anna auf dem Weg runter" private />
<HikeImage src="PXL_20241225_122938851.jpg" alt="Wieder in Brün" private />
<HikeImage src="PXL_20241225_122942649.jpg" alt="Wieder in Brün" />
## Abreise
Via Bus (Linie 404) oder Auto.
<JourneyPlanner from="Valendas, Brün Dorf" fromFixed to="<current location>" time="12:30" target="departure"/>
@@ -0,0 +1,917 @@
<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Bocken Route Builder" xmlns="http://www.topografix.com/GPX/1/1" xmlns:bocken="https://bocken.org/gpx/v1">
<wpt lat="46.778422" lon="9.30542">
<ele>1289.9</ele>
<time>2024-12-25T11:00:27.000Z</time>
<extensions>
<bocken:image hash="33736035" visibility="private"/>
</extensions>
</wpt>
<wpt lat="46.781754" lon="9.304902">
<ele>1321.3</ele>
<time>2024-12-25T11:02:57.000Z</time>
<extensions>
<bocken:image hash="b50be014"/>
</extensions>
</wpt>
<wpt lat="46.78252" lon="9.305407">
<ele>1327.1</ele>
<time>2024-12-25T11:04:19.000Z</time>
<extensions>
<bocken:image hash="be3138c8"/>
</extensions>
</wpt>
<wpt lat="46.780856" lon="9.307209">
<ele>1356.5</ele>
<time>2024-12-25T11:09:30.000Z</time>
<extensions>
<bocken:image hash="d4b01559"/>
</extensions>
</wpt>
<wpt lat="46.780487" lon="9.307823">
<ele>1363.9</ele>
<time>2024-12-25T11:11:11.000Z</time>
<extensions>
<bocken:image hash="64b8ebe0"/>
</extensions>
</wpt>
<wpt lat="46.781086" lon="9.310293">
<ele>1444.3</ele>
<time>2024-12-25T11:24:33.000Z</time>
<extensions>
<bocken:image hash="ace73886"/>
</extensions>
</wpt>
<wpt lat="46.781736" lon="9.310631">
<ele>1453.9</ele>
<time>2024-12-25T11:27:15.000Z</time>
<extensions>
<bocken:image hash="2e3de268"/>
</extensions>
</wpt>
<wpt lat="46.781776" lon="9.311906">
<ele>1495.3</ele>
<time>2024-12-25T11:33:44.000Z</time>
<extensions>
<bocken:image hash="e8cd91ea"/>
</extensions>
</wpt>
<wpt lat="46.77932" lon="9.314403">
<ele>1540.4</ele>
<time>2024-12-25T11:42:13.000Z</time>
<extensions>
<bocken:image hash="f03708bf"/>
</extensions>
</wpt>
<wpt lat="46.777217" lon="9.317179">
<ele>1580.1</ele>
<time>2024-12-25T11:50:16.000Z</time>
<extensions>
<bocken:image hash="0bf223b8" visibility="private"/>
</extensions>
</wpt>
<wpt lat="46.780061" lon="9.317093">
<ele>1614.9</ele>
<time>2024-12-25T12:00:12.000Z</time>
<extensions>
<bocken:image hash="b0be80dd"/>
</extensions>
</wpt>
<trk>
<name>Etappe 1</name>
<trkseg>
<trkpt lat="46.778422" lon="9.30542">
<ele>1289.9</ele>
<time>2024-12-25T11:00:27.000Z</time>
</trkpt>
<trkpt lat="46.778416" lon="9.305474">
<ele>1290.1</ele>
<time>2024-12-25T11:00:28.504Z</time>
</trkpt>
<trkpt lat="46.778416" lon="9.305543">
<ele>1290.3</ele>
<time>2024-12-25T11:00:30.402Z</time>
</trkpt>
<trkpt lat="46.77843" lon="9.305573">
<ele>1290.3</ele>
<time>2024-12-25T11:00:31.400Z</time>
</trkpt>
<trkpt lat="46.778532" lon="9.305683">
<ele>1291.2</ele>
<time>2024-12-25T11:00:36.492Z</time>
</trkpt>
<trkpt lat="46.778563" lon="9.305731">
<ele>1291.5</ele>
<time>2024-12-25T11:00:38.307Z</time>
</trkpt>
<trkpt lat="46.778709" lon="9.305979">
<ele>1293.7</ele>
<time>2024-12-25T11:00:47.301Z</time>
</trkpt>
<trkpt lat="46.778762" lon="9.306037">
<ele>1294.3</ele>
<time>2024-12-25T11:00:49.961Z</time>
</trkpt>
<trkpt lat="46.778818" lon="9.306061">
<ele>1294.8</ele>
<time>2024-12-25T11:00:52.305Z</time>
</trkpt>
<trkpt lat="46.778869" lon="9.306064">
<ele>1295.2</ele>
<time>2024-12-25T11:00:54.355Z</time>
</trkpt>
<trkpt lat="46.779004" lon="9.306009">
<ele>1296.5</ele>
<time>2024-12-25T11:00:59.983Z</time>
</trkpt>
<trkpt lat="46.779116" lon="9.305952">
<ele>1297.1</ele>
<time>2024-12-25T11:01:04.747Z</time>
</trkpt>
<trkpt lat="46.779221" lon="9.305905">
<ele>1297.3</ele>
<time>2024-12-25T11:01:09.157Z</time>
</trkpt>
<trkpt lat="46.779305" lon="9.305897">
<ele>1297.5</ele>
<time>2024-12-25T11:01:12.538Z</time>
</trkpt>
<trkpt lat="46.779651" lon="9.305939">
<ele>1301.6</ele>
<time>2024-12-25T11:01:26.481Z</time>
</trkpt>
<trkpt lat="46.779773" lon="9.305926">
<ele>1303.0</ele>
<time>2024-12-25T11:01:31.394Z</time>
</trkpt>
<trkpt lat="46.779851" lon="9.305896">
<ele>1304.2</ele>
<time>2024-12-25T11:01:34.633Z</time>
</trkpt>
<trkpt lat="46.779954" lon="9.305841">
<ele>1305.4</ele>
<time>2024-12-25T11:01:39.037Z</time>
</trkpt>
<trkpt lat="46.780225" lon="9.30561">
<ele>1309.0</ele>
<time>2024-12-25T11:01:51.639Z</time>
</trkpt>
<trkpt lat="46.780626" lon="9.305371">
<ele>1313.5</ele>
<time>2024-12-25T11:02:09.033Z</time>
</trkpt>
<trkpt lat="46.780671" lon="9.305355">
<ele>1314.0</ele>
<time>2024-12-25T11:02:10.893Z</time>
</trkpt>
<trkpt lat="46.780816" lon="9.305363">
<ele>1314.8</ele>
<time>2024-12-25T11:02:16.720Z</time>
</trkpt>
<trkpt lat="46.780929" lon="9.30533">
<ele>1315.3</ele>
<time>2024-12-25T11:02:21.348Z</time>
</trkpt>
<trkpt lat="46.780989" lon="9.305304">
<ele>1315.5</ele>
<time>2024-12-25T11:02:23.861Z</time>
</trkpt>
<trkpt lat="46.781243" lon="9.305123">
<ele>1316.5</ele>
<time>2024-12-25T11:02:35.212Z</time>
</trkpt>
<trkpt lat="46.781504" lon="9.304991">
<ele>1318.9</ele>
<time>2024-12-25T11:02:46.304Z</time>
</trkpt>
<trkpt lat="46.781617" lon="9.30491">
<ele>1320.1</ele>
<time>2024-12-25T11:02:51.360Z</time>
</trkpt>
<trkpt lat="46.781668" lon="9.304888">
<ele>1320.6</ele>
<time>2024-12-25T11:02:53.495Z</time>
</trkpt>
<trkpt lat="46.781706" lon="9.304886">
<ele>1320.9</ele>
<time>2024-12-25T11:02:55.022Z</time>
</trkpt>
<trkpt lat="46.781754" lon="9.304902">
<ele>1321.3</ele>
<time>2024-12-25T11:02:57.000Z</time>
</trkpt>
<trkpt lat="46.781813" lon="9.304937">
<ele>1321.8</ele>
<time>2024-12-25T11:03:02.723Z</time>
</trkpt>
<trkpt lat="46.78206" lon="9.305146">
<ele>1323.5</ele>
<time>2024-12-25T11:03:28.379Z</time>
</trkpt>
<trkpt lat="46.782117" lon="9.305184">
<ele>1323.9</ele>
<time>2024-12-25T11:03:34.011Z</time>
</trkpt>
<trkpt lat="46.782334" lon="9.305263">
<ele>1325.3</ele>
<time>2024-12-25T11:03:54.110Z</time>
</trkpt>
<trkpt lat="46.78248" lon="9.305283">
<ele>1326.1</ele>
<time>2024-12-25T11:04:07.290Z</time>
</trkpt>
<trkpt lat="46.782509" lon="9.305298">
<ele>1326.4</ele>
<time>2024-12-25T11:04:10.055Z</time>
</trkpt>
<trkpt lat="46.782535" lon="9.30533">
<ele>1326.6</ele>
<time>2024-12-25T11:04:13.111Z</time>
</trkpt>
<trkpt lat="46.782546" lon="9.305368">
<ele>1326.8</ele>
<time>2024-12-25T11:04:15.650Z</time>
</trkpt>
<trkpt lat="46.78252" lon="9.305407">
<ele>1327.1</ele>
<time>2024-12-25T11:04:19.000Z</time>
</trkpt>
<trkpt lat="46.782529" lon="9.305446">
<ele>1327.2</ele>
<time>2024-12-25T11:04:22.651Z</time>
</trkpt>
<trkpt lat="46.782505" lon="9.305472">
<ele>1327.5</ele>
<time>2024-12-25T11:04:26.523Z</time>
</trkpt>
<trkpt lat="46.782475" lon="9.305481">
<ele>1327.8</ele>
<time>2024-12-25T11:04:30.491Z</time>
</trkpt>
<trkpt lat="46.782437" lon="9.305473">
<ele>1327.9</ele>
<time>2024-12-25T11:04:35.466Z</time>
</trkpt>
<trkpt lat="46.782332" lon="9.305419">
<ele>1328.5</ele>
<time>2024-12-25T11:04:49.890Z</time>
</trkpt>
<trkpt lat="46.782273" lon="9.305398">
<ele>1328.9</ele>
<time>2024-12-25T11:04:57.758Z</time>
</trkpt>
<trkpt lat="46.782008" lon="9.305388">
<ele>1331.5</ele>
<time>2024-12-25T11:05:32.106Z</time>
</trkpt>
<trkpt lat="46.781851" lon="9.30536">
<ele>1333.6</ele>
<time>2024-12-25T11:05:52.600Z</time>
</trkpt>
<trkpt lat="46.781795" lon="9.305358">
<ele>1334.5</ele>
<time>2024-12-25T11:05:59.858Z</time>
</trkpt>
<trkpt lat="46.781736" lon="9.305378">
<ele>1335.4</ele>
<time>2024-12-25T11:06:07.706Z</time>
</trkpt>
<trkpt lat="46.781714" lon="9.305402">
<ele>1335.8</ele>
<time>2024-12-25T11:06:11.264Z</time>
</trkpt>
<trkpt lat="46.781688" lon="9.30546">
<ele>1336.6</ele>
<time>2024-12-25T11:06:17.415Z</time>
</trkpt>
<trkpt lat="46.781662" lon="9.305689">
<ele>1338.7</ele>
<time>2024-12-25T11:06:38.011Z</time>
</trkpt>
<trkpt lat="46.781641" lon="9.305796">
<ele>1339.7</ele>
<time>2024-12-25T11:06:47.887Z</time>
</trkpt>
<trkpt lat="46.781615" lon="9.305873">
<ele>1340.4</ele>
<time>2024-12-25T11:06:55.505Z</time>
</trkpt>
<trkpt lat="46.781592" lon="9.305919">
<ele>1341.0</ele>
<time>2024-12-25T11:07:00.559Z</time>
</trkpt>
<trkpt lat="46.781448" lon="9.306123">
<ele>1343.6</ele>
<time>2024-12-25T11:07:26.554Z</time>
</trkpt>
<trkpt lat="46.781411" lon="9.306188">
<ele>1344.4</ele>
<time>2024-12-25T11:07:34.054Z</time>
</trkpt>
<trkpt lat="46.781245" lon="9.306508">
<ele>1348.1</ele>
<time>2024-12-25T11:08:09.674Z</time>
</trkpt>
<trkpt lat="46.781134" lon="9.306686">
<ele>1350.4</ele>
<time>2024-12-25T11:08:31.035Z</time>
</trkpt>
<trkpt lat="46.781063" lon="9.306817">
<ele>1351.9</ele>
<time>2024-12-25T11:08:45.859Z</time>
</trkpt>
<trkpt lat="46.780882" lon="9.307183">
<ele>1356.1</ele>
<time>2024-12-25T11:09:25.916Z</time>
</trkpt>
<trkpt lat="46.780856" lon="9.307209">
<ele>1356.5</ele>
<time>2024-12-25T11:09:30.000Z</time>
</trkpt>
<trkpt lat="46.780806" lon="9.307293">
<ele>1357.5</ele>
<time>2024-12-25T11:09:43.673Z</time>
</trkpt>
<trkpt lat="46.780634" lon="9.307515">
<ele>1360.3</ele>
<time>2024-12-25T11:10:24.855Z</time>
</trkpt>
<trkpt lat="46.780549" lon="9.307703">
<ele>1362.4</ele>
<time>2024-12-25T11:10:52.532Z</time>
</trkpt>
<trkpt lat="46.780487" lon="9.307823">
<ele>1363.9</ele>
<time>2024-12-25T11:11:11.000Z</time>
</trkpt>
<trkpt lat="46.780461" lon="9.30794">
<ele>1365.1</ele>
<time>2024-12-25T11:11:21.979Z</time>
</trkpt>
<trkpt lat="46.780403" lon="9.308115">
<ele>1366.9</ele>
<time>2024-12-25T11:11:39.333Z</time>
</trkpt>
<trkpt lat="46.780344" lon="9.308364">
<ele>1368.9</ele>
<time>2024-12-25T11:12:02.851Z</time>
</trkpt>
<trkpt lat="46.780309" lon="9.308474">
<ele>1370.0</ele>
<time>2024-12-25T11:12:13.678Z</time>
</trkpt>
<trkpt lat="46.780172" lon="9.308786">
<ele>1373.6</ele>
<time>2024-12-25T11:12:46.761Z</time>
</trkpt>
<trkpt lat="46.78013" lon="9.308907">
<ele>1374.7</ele>
<time>2024-12-25T11:12:58.870Z</time>
</trkpt>
<trkpt lat="46.780088" lon="9.309049">
<ele>1376.1</ele>
<time>2024-12-25T11:13:12.676Z</time>
</trkpt>
<trkpt lat="46.780027" lon="9.309317">
<ele>1378.7</ele>
<time>2024-12-25T11:13:37.885Z</time>
</trkpt>
<trkpt lat="46.779979" lon="9.30956">
<ele>1381.0</ele>
<time>2024-12-25T11:14:00.460Z</time>
</trkpt>
<trkpt lat="46.779951" lon="9.309722">
<ele>1382.5</ele>
<time>2024-12-25T11:14:15.373Z</time>
</trkpt>
<trkpt lat="46.779923" lon="9.309842">
<ele>1383.7</ele>
<time>2024-12-25T11:14:26.689Z</time>
</trkpt>
<trkpt lat="46.779794" lon="9.310237">
<ele>1388.0</ele>
<time>2024-12-25T11:15:05.752Z</time>
</trkpt>
<trkpt lat="46.779698" lon="9.310591">
<ele>1391.1</ele>
<time>2024-12-25T11:15:39.737Z</time>
</trkpt>
<trkpt lat="46.77967" lon="9.310656">
<ele>1391.9</ele>
<time>2024-12-25T11:15:46.592Z</time>
</trkpt>
<trkpt lat="46.779627" lon="9.310722">
<ele>1392.6</ele>
<time>2024-12-25T11:15:54.723Z</time>
</trkpt>
<trkpt lat="46.779532" lon="9.3108">
<ele>1394.3</ele>
<time>2024-12-25T11:16:08.929Z</time>
</trkpt>
<trkpt lat="46.779468" lon="9.310839">
<ele>1395.0</ele>
<time>2024-12-25T11:16:17.969Z</time>
</trkpt>
<trkpt lat="46.779411" lon="9.310862">
<ele>1395.8</ele>
<time>2024-12-25T11:16:25.677Z</time>
</trkpt>
<trkpt lat="46.779353" lon="9.310881">
<ele>1396.5</ele>
<time>2024-12-25T11:16:33.425Z</time>
</trkpt>
<trkpt lat="46.778964" lon="9.310958">
<ele>1402.1</ele>
<time>2024-12-25T11:17:24.593Z</time>
</trkpt>
<trkpt lat="46.778797" lon="9.311025">
<ele>1404.1</ele>
<time>2024-12-25T11:17:47.167Z</time>
</trkpt>
<trkpt lat="46.778656" lon="9.311056">
<ele>1405.3</ele>
<time>2024-12-25T11:18:05.753Z</time>
</trkpt>
<trkpt lat="46.778631" lon="9.311068">
<ele>1405.4</ele>
<time>2024-12-25T11:18:09.183Z</time>
</trkpt>
<trkpt lat="46.778612" lon="9.311084">
<ele>1405.6</ele>
<time>2024-12-25T11:18:12.042Z</time>
</trkpt>
<trkpt lat="46.778598" lon="9.311108">
<ele>1405.8</ele>
<time>2024-12-25T11:18:14.856Z</time>
</trkpt>
<trkpt lat="46.778598" lon="9.31116">
<ele>1406.2</ele>
<time>2024-12-25T11:18:19.498Z</time>
</trkpt>
<trkpt lat="46.778609" lon="9.311186">
<ele>1406.3</ele>
<time>2024-12-25T11:18:22.226Z</time>
</trkpt>
<trkpt lat="46.778629" lon="9.31121">
<ele>1406.4</ele>
<time>2024-12-25T11:18:25.600Z</time>
</trkpt>
<trkpt lat="46.778651" lon="9.311221">
<ele>1406.6</ele>
<time>2024-12-25T11:18:28.631Z</time>
</trkpt>
<trkpt lat="46.77884" lon="9.311188">
<ele>1408.2</ele>
<time>2024-12-25T11:18:53.442Z</time>
</trkpt>
<trkpt lat="46.778933" lon="9.3112">
<ele>1409.4</ele>
<time>2024-12-25T11:19:05.611Z</time>
</trkpt>
<trkpt lat="46.778978" lon="9.311215">
<ele>1409.9</ele>
<time>2024-12-25T11:19:11.628Z</time>
</trkpt>
<trkpt lat="46.779154" lon="9.311334">
<ele>1412.5</ele>
<time>2024-12-25T11:19:36.908Z</time>
</trkpt>
<trkpt lat="46.779327" lon="9.311421">
<ele>1415.2</ele>
<time>2024-12-25T11:20:00.758Z</time>
</trkpt>
<trkpt lat="46.779497" lon="9.311468">
<ele>1417.4</ele>
<time>2024-12-25T11:20:23.310Z</time>
</trkpt>
<trkpt lat="46.779841" lon="9.311512">
<ele>1422.3</ele>
<time>2024-12-25T11:21:08.321Z</time>
</trkpt>
<trkpt lat="46.779942" lon="9.311522">
<ele>1423.7</ele>
<time>2024-12-25T11:21:21.516Z</time>
</trkpt>
<trkpt lat="46.780057" lon="9.311506">
<ele>1425.2</ele>
<time>2024-12-25T11:21:36.573Z</time>
</trkpt>
<trkpt lat="46.780112" lon="9.311482">
<ele>1426.0</ele>
<time>2024-12-25T11:21:44.056Z</time>
</trkpt>
<trkpt lat="46.780204" lon="9.311423">
<ele>1427.5</ele>
<time>2024-12-25T11:21:57.153Z</time>
</trkpt>
<trkpt lat="46.78027" lon="9.311369">
<ele>1428.7</ele>
<time>2024-12-25T11:22:07.014Z</time>
</trkpt>
<trkpt lat="46.78032" lon="9.311315">
<ele>1429.5</ele>
<time>2024-12-25T11:22:15.120Z</time>
</trkpt>
<trkpt lat="46.780368" lon="9.311245">
<ele>1430.4</ele>
<time>2024-12-25T11:22:23.962Z</time>
</trkpt>
<trkpt lat="46.780629" lon="9.310815">
<ele>1435.7</ele>
<time>2024-12-25T11:23:15.251Z</time>
</trkpt>
<trkpt lat="46.780899" lon="9.310423">
<ele>1441.3</ele>
<time>2024-12-25T11:24:04.878Z</time>
</trkpt>
<trkpt lat="46.780968" lon="9.310341">
<ele>1442.6</ele>
<time>2024-12-25T11:24:16.474Z</time>
</trkpt>
<trkpt lat="46.781011" lon="9.310305">
<ele>1443.2</ele>
<time>2024-12-25T11:24:22.934Z</time>
</trkpt>
<trkpt lat="46.781048" lon="9.310287">
<ele>1443.8</ele>
<time>2024-12-25T11:24:28.018Z</time>
</trkpt>
<trkpt lat="46.781086" lon="9.310293">
<ele>1444.3</ele>
<time>2024-12-25T11:24:33.000Z</time>
</trkpt>
<trkpt lat="46.78109" lon="9.310279">
<ele>1444.6</ele>
<time>2024-12-25T11:24:35.394Z</time>
</trkpt>
<trkpt lat="46.781125" lon="9.310282">
<ele>1444.8</ele>
<time>2024-12-25T11:24:43.475Z</time>
</trkpt>
<trkpt lat="46.78119" lon="9.310304">
<ele>1445.8</ele>
<time>2024-12-25T11:24:58.853Z</time>
</trkpt>
<trkpt lat="46.781608" lon="9.310537">
<ele>1451.9</ele>
<time>2024-12-25T11:26:41.977Z</time>
</trkpt>
<trkpt lat="46.781736" lon="9.310631">
<ele>1453.9</ele>
<time>2024-12-25T11:27:15.000Z</time>
</trkpt>
<trkpt lat="46.781992" lon="9.310887">
<ele>1458.1</ele>
<time>2024-12-25T11:27:54.242Z</time>
</trkpt>
<trkpt lat="46.782265" lon="9.311167">
<ele>1463.0</ele>
<time>2024-12-25T11:28:36.436Z</time>
</trkpt>
<trkpt lat="46.782316" lon="9.311231">
<ele>1463.9</ele>
<time>2024-12-25T11:28:44.941Z</time>
</trkpt>
<trkpt lat="46.782351" lon="9.311289">
<ele>1464.6</ele>
<time>2024-12-25T11:28:51.636Z</time>
</trkpt>
<trkpt lat="46.782389" lon="9.311378">
<ele>1465.7</ele>
<time>2024-12-25T11:29:00.720Z</time>
</trkpt>
<trkpt lat="46.782576" lon="9.311913">
<ele>1471.2</ele>
<time>2024-12-25T11:29:52.743Z</time>
</trkpt>
<trkpt lat="46.782683" lon="9.312188">
<ele>1474.0</ele>
<time>2024-12-25T11:30:20.137Z</time>
</trkpt>
<trkpt lat="46.782742" lon="9.312381">
<ele>1475.9</ele>
<time>2024-12-25T11:30:38.442Z</time>
</trkpt>
<trkpt lat="46.782786" lon="9.312543">
<ele>1477.2</ele>
<time>2024-12-25T11:30:53.536Z</time>
</trkpt>
<trkpt lat="46.782798" lon="9.312602">
<ele>1477.6</ele>
<time>2024-12-25T11:30:58.867Z</time>
</trkpt>
<trkpt lat="46.782798" lon="9.312636">
<ele>1477.8</ele>
<time>2024-12-25T11:31:01.812Z</time>
</trkpt>
<trkpt lat="46.782789" lon="9.312662">
<ele>1477.9</ele>
<time>2024-12-25T11:31:04.335Z</time>
</trkpt>
<trkpt lat="46.782746" lon="9.312686">
<ele>1478.3</ele>
<time>2024-12-25T11:31:10.157Z</time>
</trkpt>
<trkpt lat="46.782721" lon="9.312689">
<ele>1478.7</ele>
<time>2024-12-25T11:31:13.330Z</time>
</trkpt>
<trkpt lat="46.782699" lon="9.312678">
<ele>1478.9</ele>
<time>2024-12-25T11:31:16.271Z</time>
</trkpt>
<trkpt lat="46.782678" lon="9.312654">
<ele>1479.2</ele>
<time>2024-12-25T11:31:19.643Z</time>
</trkpt>
<trkpt lat="46.78246" lon="9.312333">
<ele>1483.2</ele>
<time>2024-12-25T11:31:58.799Z</time>
</trkpt>
<trkpt lat="46.782285" lon="9.312031">
<ele>1487.3</ele>
<time>2024-12-25T11:32:33.063Z</time>
</trkpt>
<trkpt lat="46.782246" lon="9.311979">
<ele>1488.2</ele>
<time>2024-12-25T11:32:39.743Z</time>
</trkpt>
<trkpt lat="46.782196" lon="9.31192">
<ele>1489.2</ele>
<time>2024-12-25T11:32:47.873Z</time>
</trkpt>
<trkpt lat="46.782137" lon="9.311873">
<ele>1490.0</ele>
<time>2024-12-25T11:32:56.373Z</time>
</trkpt>
<trkpt lat="46.782088" lon="9.311841">
<ele>1490.7</ele>
<time>2024-12-25T11:33:03.162Z</time>
</trkpt>
<trkpt lat="46.782035" lon="9.311823">
<ele>1491.5</ele>
<time>2024-12-25T11:33:10.045Z</time>
</trkpt>
<trkpt lat="46.781976" lon="9.311818">
<ele>1492.1</ele>
<time>2024-12-25T11:33:17.519Z</time>
</trkpt>
<trkpt lat="46.78192" lon="9.311833">
<ele>1492.8</ele>
<time>2024-12-25T11:33:24.720Z</time>
</trkpt>
<trkpt lat="46.781776" lon="9.311906">
<ele>1495.3</ele>
<time>2024-12-25T11:33:44.000Z</time>
</trkpt>
<trkpt lat="46.781729" lon="9.311954">
<ele>1496.1</ele>
<time>2024-12-25T11:33:53.503Z</time>
</trkpt>
<trkpt lat="46.781335" lon="9.312383">
<ele>1503.6</ele>
<time>2024-12-25T11:35:14.942Z</time>
</trkpt>
<trkpt lat="46.781145" lon="9.312623">
<ele>1507.2</ele>
<time>2024-12-25T11:35:56.570Z</time>
</trkpt>
<trkpt lat="46.781083" lon="9.312733">
<ele>1508.8</ele>
<time>2024-12-25T11:36:12.736Z</time>
</trkpt>
<trkpt lat="46.781002" lon="9.31291">
<ele>1511.1</ele>
<time>2024-12-25T11:36:36.893Z</time>
</trkpt>
<trkpt lat="46.78095" lon="9.312999">
<ele>1512.4</ele>
<time>2024-12-25T11:36:50.168Z</time>
</trkpt>
<trkpt lat="46.780902" lon="9.31306">
<ele>1513.2</ele>
<time>2024-12-25T11:37:00.712Z</time>
</trkpt>
<trkpt lat="46.78086" lon="9.31309">
<ele>1514.1</ele>
<time>2024-12-25T11:37:08.460Z</time>
</trkpt>
<trkpt lat="46.780694" lon="9.31326">
<ele>1517.2</ele>
<time>2024-12-25T11:37:42.057Z</time>
</trkpt>
<trkpt lat="46.780601" lon="9.313378">
<ele>1519.0</ele>
<time>2024-12-25T11:38:02.472Z</time>
</trkpt>
<trkpt lat="46.78048" lon="9.313565">
<ele>1521.6</ele>
<time>2024-12-25T11:38:31.666Z</time>
</trkpt>
<trkpt lat="46.780447" lon="9.313604">
<ele>1522.2</ele>
<time>2024-12-25T11:38:38.701Z</time>
</trkpt>
<trkpt lat="46.780285" lon="9.313719">
<ele>1524.7</ele>
<time>2024-12-25T11:39:08.549Z</time>
</trkpt>
<trkpt lat="46.780238" lon="9.313764">
<ele>1525.6</ele>
<time>2024-12-25T11:39:17.862Z</time>
</trkpt>
<trkpt lat="46.780095" lon="9.313951">
<ele>1528.0</ele>
<time>2024-12-25T11:39:49.670Z</time>
</trkpt>
<trkpt lat="46.780029" lon="9.314036">
<ele>1529.3</ele>
<time>2024-12-25T11:40:04.252Z</time>
</trkpt>
<trkpt lat="46.779972" lon="9.314074">
<ele>1530.3</ele>
<time>2024-12-25T11:40:14.635Z</time>
</trkpt>
<trkpt lat="46.77993" lon="9.314088">
<ele>1530.8</ele>
<time>2024-12-25T11:40:21.774Z</time>
</trkpt>
<trkpt lat="46.77964" lon="9.314135">
<ele>1535.1</ele>
<time>2024-12-25T11:41:10.123Z</time>
</trkpt>
<trkpt lat="46.779527" lon="9.31417">
<ele>1536.7</ele>
<time>2024-12-25T11:41:29.265Z</time>
</trkpt>
<trkpt lat="46.779487" lon="9.314194">
<ele>1537.3</ele>
<time>2024-12-25T11:41:36.431Z</time>
</trkpt>
<trkpt lat="46.779441" lon="9.314237">
<ele>1538.0</ele>
<time>2024-12-25T11:41:45.481Z</time>
</trkpt>
<trkpt lat="46.779396" lon="9.314295">
<ele>1539.0</ele>
<time>2024-12-25T11:41:55.427Z</time>
</trkpt>
<trkpt lat="46.77932" lon="9.314403">
<ele>1540.4</ele>
<time>2024-12-25T11:42:13.000Z</time>
</trkpt>
<trkpt lat="46.779313" lon="9.314438">
<ele>1540.8</ele>
<time>2024-12-25T11:42:16.830Z</time>
</trkpt>
<trkpt lat="46.77928" lon="9.314478">
<ele>1541.3</ele>
<time>2024-12-25T11:42:23.410Z</time>
</trkpt>
<trkpt lat="46.779212" lon="9.314531">
<ele>1542.4</ele>
<time>2024-12-25T11:42:35.235Z</time>
</trkpt>
<trkpt lat="46.779139" lon="9.314551">
<ele>1543.4</ele>
<time>2024-12-25T11:42:46.630Z</time>
</trkpt>
<trkpt lat="46.778993" lon="9.314513">
<ele>1545.5</ele>
<time>2024-12-25T11:43:09.381Z</time>
</trkpt>
<trkpt lat="46.778945" lon="9.314511">
<ele>1546.2</ele>
<time>2024-12-25T11:43:16.748Z</time>
</trkpt>
<trkpt lat="46.778818" lon="9.314557">
<ele>1547.9</ele>
<time>2024-12-25T11:43:36.823Z</time>
</trkpt>
<trkpt lat="46.778609" lon="9.314607">
<ele>1550.6</ele>
<time>2024-12-25T11:44:09.314Z</time>
</trkpt>
<trkpt lat="46.77849" lon="9.314662">
<ele>1552.3</ele>
<time>2024-12-25T11:44:28.463Z</time>
</trkpt>
<trkpt lat="46.778114" lon="9.314981">
<ele>1558.2</ele>
<time>2024-12-25T11:45:35.177Z</time>
</trkpt>
<trkpt lat="46.778062" lon="9.315019">
<ele>1558.7</ele>
<time>2024-12-25T11:45:44.097Z</time>
</trkpt>
<trkpt lat="46.777899" lon="9.315109">
<ele>1561.1</ele>
<time>2024-12-25T11:46:10.832Z</time>
</trkpt>
<trkpt lat="46.777872" lon="9.315135">
<ele>1561.6</ele>
<time>2024-12-25T11:46:15.794Z</time>
</trkpt>
<trkpt lat="46.777853" lon="9.315164">
<ele>1562.0</ele>
<time>2024-12-25T11:46:20.011Z</time>
</trkpt>
<trkpt lat="46.77783" lon="9.315238">
<ele>1562.8</ele>
<time>2024-12-25T11:46:28.548Z</time>
</trkpt>
<trkpt lat="46.777777" lon="9.315595">
<ele>1566.2</ele>
<time>2024-12-25T11:47:06.927Z</time>
</trkpt>
<trkpt lat="46.777752" lon="9.315719">
<ele>1567.4</ele>
<time>2024-12-25T11:47:20.508Z</time>
</trkpt>
<trkpt lat="46.777724" lon="9.315821">
<ele>1568.2</ele>
<time>2024-12-25T11:47:32.053Z</time>
</trkpt>
<trkpt lat="46.777662" lon="9.315984">
<ele>1569.9</ele>
<time>2024-12-25T11:47:51.643Z</time>
</trkpt>
<trkpt lat="46.777446" lon="9.316396">
<ele>1573.9</ele>
<time>2024-12-25T11:48:46.157Z</time>
</trkpt>
<trkpt lat="46.777371" lon="9.316581">
<ele>1575.4</ele>
<time>2024-12-25T11:49:08.744Z</time>
</trkpt>
<trkpt lat="46.777344" lon="9.316674">
<ele>1576.2</ele>
<time>2024-12-25T11:49:19.357Z</time>
</trkpt>
<trkpt lat="46.777256" lon="9.317067">
<ele>1579.0</ele>
<time>2024-12-25T11:50:02.799Z</time>
</trkpt>
<trkpt lat="46.777217" lon="9.317179">
<ele>1580.1</ele>
<time>2024-12-25T11:50:16.000Z</time>
</trkpt>
<trkpt lat="46.777218" lon="9.317208">
<ele>1580.3</ele>
<time>2024-12-25T11:50:20.041Z</time>
</trkpt>
<trkpt lat="46.77726" lon="9.31725">
<ele>1579.6</ele>
<time>2024-12-25T11:50:30.385Z</time>
</trkpt>
<trkpt lat="46.777316" lon="9.317282">
<ele>1578.8</ele>
<time>2024-12-25T11:50:42.606Z</time>
</trkpt>
<trkpt lat="46.777683" lon="9.317418">
<ele>1580.2</ele>
<time>2024-12-25T11:51:59.551Z</time>
</trkpt>
<trkpt lat="46.777914" lon="9.317508">
<ele>1583.2</ele>
<time>2024-12-25T11:52:48.136Z</time>
</trkpt>
<trkpt lat="46.778206" lon="9.317509">
<ele>1590.3</ele>
<time>2024-12-25T11:53:47.475Z</time>
</trkpt>
<trkpt lat="46.778592" lon="9.317407">
<ele>1598.0</ele>
<time>2024-12-25T11:55:07.191Z</time>
</trkpt>
<trkpt lat="46.77922" lon="9.317416">
<ele>1607.9</ele>
<time>2024-12-25T11:57:14.818Z</time>
</trkpt>
<trkpt lat="46.779428" lon="9.317344">
<ele>1609.4</ele>
<time>2024-12-25T11:57:58.258Z</time>
</trkpt>
<trkpt lat="46.77965" lon="9.317225">
<ele>1612.7</ele>
<time>2024-12-25T11:58:46.316Z</time>
</trkpt>
<trkpt lat="46.77985" lon="9.31718">
<ele>1613.6</ele>
<time>2024-12-25T11:59:27.439Z</time>
</trkpt>
<trkpt lat="46.780045" lon="9.317101">
<ele>1614.9</ele>
<time>2024-12-25T12:00:08.563Z</time>
</trkpt>
<trkpt lat="46.780061" lon="9.317093">
<ele>1614.9</ele>
<time>2024-12-25T12:00:12.000Z</time>
</trkpt>
<trkpt lat="46.780092" lon="9.317082">
<ele>1614.9</ele>
<time>2024-12-25T12:02:09.000Z</time>
</trkpt>
</trkseg>
</trk>
</gpx>
@@ -0,0 +1,75 @@
---
title: Monte Generosa
date: 2024-09-02
author: Alexander
difficulty: T4
tags: [Tessin, Schweiz, Sommer, Schwierig]
seasons: 5-9
summary: Eine anspruchsvolle aber kurze Gipfelbesteigung
heroAlt: Blick auf die Felswand, den schwierigsten Teil der Route
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Übersicht
Eine tiefe T4-Wanderung mit starkem Anstieg konsistent. Der letzte Aufstieg ist sehr exponiert, benötigt Hände und hat die Gefahr eines über 100 m Absturz. Nichts für Bergsteigeranfänger.
Es empfiehlt sich, das Wetter explizit für Monte Generosa zu überprüfen.
Wir wurden im schwierigsten Teil von Regen überrascht, was wohl zur nervenzerreibendsten Wanderung meines Lebens geführt hat.
## Anreise
Anreise via Bus (Linie 541) nach Rovio, Paese.
<JourneyPlanner from="<current location>" to="Rovio, Paese" toFixed target="arrival" time="09:00" />
## Anfang
Man fängt in Rovio an und geniesst noch kurz die engen Gassen des Dorfes.
<HikeImage idx={0} />
<HikeImage idx={1} />
Dann fängt der erste Waldteil an. Anfangs noch auf einem recht guten Waldweg. Später geht es über zu einem Pfad mit einem deutlich stärkeren Anstieg. Der Pfad ist sehr gut markiert.
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
## Letzte Rast
Vor dem eigentlichen T4-Stück kann man sich noch gut bei einer kleinen Hütte ausruhen und die schöne Aussicht geniessen.
Wie auf den Bildern zu sehen, waren hier bereits grössere Wolken voll um uns herum.
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
Blick auf die Steilwand, welche man links vom Bild besteigen wird.
<HikeImage idx={17} />
Wegen starkem Regen und Schwierigkeit der Strecke fehlen hier Bilder.
## Ankunft auf der Bergstation
Oben auf der Station angekommen, wurde das Wetter wieder besser und hat einen schönen Ausblick ermöglicht.
Ein guter Punkt, um ein ordentliches Dessert zu geniessen.
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
## Heimreise
Von der Bergstation fährt eine Zahnradbahn nach Capolago-Riva S.Vitale. Von dort via Zug geht es schnell nach Lugano oder zu anderen Orten.
<JourneyPlanner from="Generoso Vetta" to="<current location>" fromFixed target=departure time="14:30" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,68 @@
---
title: Spaziergang um den Pfäffikersee
date: 2024-07-27
author: Alexander
difficulty: T1
tags: [Zürich, See, Familie, Sommer]
summary: Ein entspannter Spaziergang zum Juckerhof um den Pfäffikersee.
heroAlt: Blick auf den Kirchturm Seegräben durch das Schilf
seasons: 4-9
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Übersicht
Ein wunderschöner Spaziergang um den Pfäffikersee mit Ziel Juckerhof.
Dort kann man ein kleines Picknick geniessen und auch z.B. selber Heidelbeeren pflücken.
## Anfahrt
Anreise via Zug nach Pfäffikon, ZH.
<JourneyPlanner from="<current location>" to="Pfäffikon ZH, Bahnhof" toFixed time="09:00" target="arrival" />
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
## Römisches Kastell
Auf dem Weg kommt man an einer Ruine einer römischen Festungsanlage vorbei.
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
## Juckerhof
Nach ca. 1,5 Stunden ist das eigentliche Ziel des Spaziergangs erreicht: der Juckerhof.
Hier kann man eine kleine Verpflegung sich direkt am Hof holen und auf den hübschen Wiesen geniessen.
<HikeImage idx={15} />
<HikeImage idx={16} />
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
### Heidelbeerpflücken
Es ist auch möglich selber Heidelbeeren zu pflücken und die hübsche Aussicht zu geniessen.
<HikeImage src="PXL_20240727_113858845.jpg" />
<HikeImage src="PXL_20240727_113854798.jpg" />
<HikeImage src="PXL_20240727_113902155.jpg" private />
## Abreise
Via Zug von Aathal, 30 Minuten vom Juckerhof.
<JourneyPlanner from="Aathal" to="<current location>" fromFixed time="14:00" target="departure" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,74 @@
---
title: Wanderung durch das Verzascatal
date: 2024-09-01
author: Alexander
difficulty: T1
tags: [Tessin, Panorama, Familie, Spätsommer]
summary: Eine schöne Aussichtstour durch das Tessin... wenn das Wetter hält
heroAlt:
seasons: 5-9
---
<script>
import HikeImage from '$lib/components/hikes/HikeImage.svelte';
import JourneyPlanner from '$lib/components/hikes/JourneyPlanner.svelte';
</script>
## Anfahrt
Anreise von Lugano via Bus nach Lavertezzo, Ai Poss.
<JourneyPlanner from="<current location>" to="Lavertezzo, Ai Poss" toFixed time="09:00" target="arrival" />
<HikeImage idx={0} />
<HikeImage idx={1} />
<HikeImage idx={2} />
<HikeImage idx={3} />
<HikeImage idx={4} />
<HikeImage idx={5} />
<HikeImage idx={6} />
<HikeImage idx={7} />
<HikeImage idx={8} />
<HikeImage idx={9} />
<HikeImage idx={10} />
<HikeImage idx={11} />
<HikeImage idx={12} />
<HikeImage idx={13} />
<HikeImage idx={14} />
<HikeImage idx={15} />
<HikeImage idx={16} />
<HikeImage idx={17} />
<HikeImage idx={18} />
<HikeImage idx={19} />
<HikeImage idx={20} />
<HikeImage idx={21} />
<HikeImage idx={22} />
<HikeImage idx={23} />
<HikeImage idx={24} />
<HikeImage idx={25} />
<HikeImage idx={26} />
<HikeImage idx={27} />
<HikeImage idx={28} />
<HikeImage idx={29} />
<HikeImage idx={30} />
<HikeImage idx={31} />
<HikeImage idx={32} />
<HikeImage idx={33} />
<HikeImage idx={34} />
<HikeImage idx={35} />
<HikeImage idx={36} />
<HikeImage idx={37} />
<HikeImage idx={38} />
<HikeImage idx={39} />
<HikeImage idx={40} />
<HikeImage idx={41} />
<HikeImage idx={42} />
<HikeImage idx={43} />
<HikeImage idx={44} />
<HikeImage idx={45} />
<HikeImage idx={46} />
<HikeImage idx={47} />
## Abreise
Via Bus zurück von Sonogno nach Lugano. Dort via Zug nach Hause.
<JourneyPlanner from="Sonogno" to="<current location>" fromFixed time="12:00" target="departure" />
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 15 KiB

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