4 Commits

Author SHA1 Message Date
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
10 changed files with 338 additions and 117 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.67.3",
"version": "1.68.1",
"private": true,
"type": "module",
"scripts": {
+101 -37
View File
@@ -200,47 +200,107 @@
}
// Generate future predicted cycles (12 cycles ≈ ~1 year)
const cycleMs = Math.round(emaCycle) * 86400000;
const meanCycleDays = Math.round(emaCycle);
const cycleMs = meanCycleDays * 86400000;
const periodMs = (Math.round(emaPeriod) - 1) * 86400000;
const lutealLength = 14;
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null;
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
// Cycle range for Ogino-style widening of future fertile windows.
// Without ≥2 observed cycles, widening collapses to a point estimate.
const shortestCycle = cycleLengths.length >= 2 ? Math.min(...cycleLengths) : meanCycleDays;
const longestCycle = cycleLengths.length >= 2 ? Math.max(...cycleLengths) : meanCycleDays;
/**
* Build a fertility window for one cycle.
*
* Anchor: the next period's start (luteal-back-count). Past cycles know it
* exactly; future cycles use the mean prediction and widen the outer fertile
* range to cover the earliest/latest historically observed ovulation day.
*
* Floor: fertile/peak never overlap the prior bleed. Day-after-period-end
* is the earliest possible fertile day shown — a hard biological floor for
* the user's mental model, even though sperm survival could in theory begin
* earlier in the bleed for very short cycles.
*
* @param {number} cycleStartMs ms of cycle start (= prior period start)
* @param {number | null} priorPeriodEndMs ms of prior bleed end, or null if unknown
* @param {number} nextPeriodStartMs ms of the next period's start
* @param {boolean} widen true → use shortest/longest cycle bounds; false → point estimate
*/
function buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, widen) {
const ovMs = nextPeriodStartMs - lutealLength * 86400000;
const earliestOvMs = widen
? cycleStartMs + (shortestCycle - lutealLength) * 86400000
: ovMs;
let latestOvMs = widen
? cycleStartMs + (longestCycle - lutealLength) * 86400000
: ovMs;
// Cap latest ov before the next bleed starts.
if (latestOvMs > nextPeriodStartMs - 86400000) latestOvMs = nextPeriodStartMs - 86400000;
const floorMs = priorPeriodEndMs !== null ? priorPeriodEndMs + 86400000 : cycleStartMs;
let fertileStartMs = Math.max(earliestOvMs - 5 * 86400000, floorMs, cycleStartMs);
let peakStartMs = Math.max(ovMs - 2 * 86400000, floorMs, cycleStartMs);
const peakEndMs = ovMs - 86400000;
let fertileEndMs = Math.max(latestOvMs, ovMs);
// Suppress peak if floor pushed it past ov (e.g. very short cycle + long period).
if (peakStartMs > peakEndMs) peakStartMs = peakEndMs + 86400000;
// Keep fertile envelope around peak/ov.
if (fertileStartMs > peakStartMs && peakStartMs <= peakEndMs) fertileStartMs = peakStartMs;
return {
fertileStart: new Date(fertileStartMs),
fertileEnd: new Date(fertileEndMs),
peakStart: new Date(peakStartMs),
peakEnd: new Date(peakEndMs),
ovulation: new Date(ovMs),
lutealStart: new Date(latestOvMs + 86400000),
lutealEnd: new Date(nextPeriodStartMs - 86400000)
};
}
/** @type {{ start: Date, end: Date, fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
const futureCycles = [];
if (lastStart) {
let base = lastStart.getTime();
// Prior bleed end for the first predicted cycle: actual end if recorded,
// predicted end if ongoing, else cycle start.
let priorPeriodEndMs;
if (sorted[0]?.endDate) {
priorPeriodEndMs = midnight(new Date(sorted[0].endDate));
} else if (predictedEndOfOngoing) {
priorPeriodEndMs = midnight(predictedEndOfOngoing);
} else {
priorPeriodEndMs = base;
}
for (let i = 0; i < 12; i++) {
const start = new Date(base + cycleMs);
const end = new Date(start.getTime() + periodMs);
const ov = new Date(start.getTime() - lutealLength * 86400000);
// Luteal phase: day after ovulation until day before next period
const lutealStart = new Date(ov.getTime() + 86400000);
const lutealEnd = new Date(start.getTime() - 86400000);
const nextPeriodStartMs = base + cycleMs;
const periodEndMs = nextPeriodStartMs + periodMs;
const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
futureCycles.push({
start, end,
fertileStart: new Date(ov.getTime() - 5 * 86400000),
fertileEnd: ov,
peakStart: new Date(ov.getTime() - 2 * 86400000),
lutealStart,
lutealEnd
start: new Date(nextPeriodStartMs),
end: new Date(periodEndMs),
...w
});
base = start.getTime();
base = nextPeriodStartMs;
priorPeriodEndMs = periodEndMs;
}
}
// Past fertility/luteal windows (from completed cycles)
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, lutealStart: Date, lutealEnd: Date }[]} */
/** @type {{ fertileStart: Date, fertileEnd: Date, peakStart: Date, peakEnd: Date, ovulation: Date, lutealStart: Date, lutealEnd: Date }[]} */
const pastFertileWindows = [];
for (let i = 1; i < completed.length; i++) {
const nextPeriodStart = new Date(completed[i].startDate);
const ov = new Date(nextPeriodStart.getTime() - lutealLength * 86400000);
pastFertileWindows.push({
fertileStart: new Date(ov.getTime() - 5 * 86400000),
fertileEnd: ov,
peakStart: new Date(ov.getTime() - 2 * 86400000),
lutealStart: new Date(ov.getTime() + 86400000),
lutealEnd: new Date(nextPeriodStart.getTime() - 86400000)
});
const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
const priorPeriodEndMs = completed[i - 1].endDate
? midnight(new Date(completed[i - 1].endDate))
: null;
const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
}
return {
@@ -405,12 +465,14 @@
const cs = midnight(c.start);
const ce = midnight(c.end);
if (d >= cs && d <= ce) return 'predicted';
const fe = midnight(c.fertileEnd);
if (d === fe) return 'ovulation';
const ovDay = midnight(c.ovulation);
if (d === ovDay) return 'ovulation';
const ps = midnight(c.peakStart);
const pe = midnight(c.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(c.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile';
if (d >= fs && d < ps) return 'fertile';
const fe = midnight(c.fertileEnd);
if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(c.lutealStart);
const le = midnight(c.lutealEnd);
if (d >= ls && d <= le) return 'luteal';
@@ -418,12 +480,14 @@
// Past fertility/luteal windows
for (const w of predictions.pastFertileWindows) {
const fe = midnight(w.fertileEnd);
if (d === fe) return 'ovulation';
const ovDay = midnight(w.ovulation);
if (d === ovDay) return 'ovulation';
const ps = midnight(w.peakStart);
const pe = midnight(w.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(w.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile';
if (d >= fs && d < ps) return 'fertile';
const fe = midnight(w.fertileEnd);
if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(w.lutealStart);
const le = midnight(w.lutealEnd);
if (d >= ls && d <= le) return 'luteal';
@@ -737,8 +801,8 @@
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span>
@@ -762,8 +826,8 @@
<div class="status-side">
<div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span>
<span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div>
<div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span>
+47 -6
View File
@@ -8,10 +8,12 @@
import Route from '@lucide/svelte/icons/route';
import Gauge from '@lucide/svelte/icons/gauge';
import Flame from '@lucide/svelte/icons/flame';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
import CloudOff from '@lucide/svelte/icons/cloud-off';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
const t = $derived(m[lang]);
/**
* @type {{
@@ -30,10 +32,11 @@
* gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }>
* }
* },
* unsynced?: boolean
* }}
*/
let { session } = $props();
let { session, unsynced = false } = $props();
/** @param {number} mins */
function formatDuration(mins) {
@@ -153,9 +156,17 @@
});
</script>
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
{#snippet cardBody()}
<div class="card-top">
<h3 class="session-name">{session.name}</h3>
<h3 class="session-name">
{session.name}
{#if unsynced}
<span class="unsynced-badge" title={t.unsynced_label}>
<CloudOff size={12} strokeWidth={2} />
{t.unsynced_label}
</span>
{/if}
</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div>
@@ -207,7 +218,13 @@
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
</div>
</a>
{/snippet}
{#if unsynced}
<div class="session-card unsynced">{@render cardBody()}</div>
{:else}
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">{@render cardBody()}</a>
{/if}
<style>
.session-card {
@@ -228,6 +245,30 @@
.session-card:active {
transform: translateY(0);
}
.session-card.unsynced {
border-left: 3px solid var(--orange, var(--nord12));
opacity: 0.92;
cursor: default;
}
.session-card.unsynced:hover {
transform: none;
box-shadow: var(--shadow-sm);
}
.unsynced-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--orange, var(--nord12));
background: color-mix(in srgb, var(--orange, var(--nord12)) 12%, transparent);
border-radius: 100px;
vertical-align: middle;
}
.card-top {
margin-bottom: 0.6rem;
}
+1
View File
@@ -33,6 +33,7 @@ export const de = {
weight: "Gewicht",
history_title: "Verlauf",
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
unsynced_label: "Nicht synchronisiert",
load_more: "Mehr laden",
date: "Datum",
time: "Uhrzeit",
+1
View File
@@ -33,6 +33,7 @@ export const en = {
weight: "Weight",
history_title: "History",
no_workouts_yet: "No workouts yet. Start your first workout!",
unsynced_label: "Unsynced",
load_more: "Load more",
date: "Date",
time: "Time",
+27 -11
View File
@@ -16,15 +16,8 @@ function addFavoriteStatus<T extends { _id: unknown }>(
}));
}
export const load: PageLoad = async ({ params, fetch, parent }) => {
const parentData = await parent();
const apiBase = `/api/${params.recipeLang}`;
const useOfflineData =
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
if (useOfflineData) {
try {
if (await isOfflineDataAvailable()) {
async function loadFromIndexedDB() {
if (!await isOfflineDataAvailable()) return null;
const [allBrief, seasonRecipes] = await Promise.all([
getAllBriefRecipes(),
getBriefRecipesInSeasonOn(new Date())
@@ -33,9 +26,21 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
all_brief: rand_array(allBrief),
season: rand_array(seasonRecipes),
heroIndex: Math.random(),
isOffline: true
isOffline: true as const
};
}
export const load: PageLoad = async ({ params, fetch, parent }) => {
const parentData = await parent();
const apiBase = `/api/${params.recipeLang}`;
const canUseOffline = browser && canUseOfflineData();
const knownOffline = browser && (isOffline() || parentData.isOffline);
// Skip the network entirely when device is known offline.
if (canUseOffline && knownOffline) {
try {
const offline = await loadFromIndexedDB();
if (offline) return offline;
} catch (e) {
console.error('Failed to load offline data:', e);
}
@@ -54,7 +59,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
favorites = body.favorites ?? [];
}
} catch {
// Network unreachable — empty data; +page.svelte renders fallback layout.
// Network unreachable — IndexedDB fallback below picks up the slack.
}
// API failed or returned empty (502, slow cellular, server hiccup) — fall
// back to IndexedDB so the cached PWA shell stays useful.
if (canUseOffline && !all_brief.length) {
try {
const offline = await loadFromIndexedDB();
if (offline) return offline;
} catch (e) {
console.error('Failed to load offline data:', e);
}
}
const marked = addFavoriteStatus(all_brief, favorites);
@@ -15,23 +15,28 @@ function addFavoriteStatus<T extends { _id: unknown }>(
}));
}
export const load: PageLoad = async ({ params, fetch, parent }) => {
const parentData = await parent();
const month = parseInt(params.month, 10);
const apiBase = `/api/${params.recipeLang}`;
const useOfflineData =
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
if (useOfflineData) {
try {
if (await isOfflineDataAvailable()) {
async function loadFromIndexedDB(month: number) {
if (!await isOfflineDataAvailable()) return null;
const recipes = await getBriefRecipesOverlappingMonth(month);
return {
month,
season: rand_array(recipes),
isOffline: true
isOffline: true as const
};
}
export const load: PageLoad = async ({ params, fetch, parent }) => {
const parentData = await parent();
const month = parseInt(params.month, 10);
const apiBase = `/api/${params.recipeLang}`;
const canUseOffline = browser && canUseOfflineData();
const knownOffline = browser && (isOffline() || parentData.isOffline);
// Skip the network entirely when device is known offline.
if (canUseOffline && knownOffline) {
try {
const offline = await loadFromIndexedDB(month);
if (offline) return offline;
} catch (e) {
console.error('Failed to load offline season data:', e);
}
@@ -50,7 +55,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
favorites = body.favorites ?? [];
}
} catch {
// Empty arrays — page will render with no recipes
// Network unreachable — IndexedDB fallback below picks up the slack.
}
// API failed or returned empty (502, slow cellular, server hiccup) — fall
// back to IndexedDB so the cached PWA shell stays useful.
if (canUseOffline && !item_season.length) {
try {
const offline = await loadFromIndexedDB(month);
if (offline) return offline;
} catch (e) {
console.error('Failed to load offline season data:', e);
}
}
return {
+5 -3
View File
@@ -34,12 +34,14 @@
'workout', 'training', 'workout/active', 'training/aktiv',
'exercises', 'uebungen', 'stats', 'statistik',
'history', 'verlauf', 'check-in', 'erfassung',
'nutrition', 'ernaehrung'
'nutrition', 'ernaehrung',
'nutrition/meals', 'ernaehrung/meals',
'check-in/body-parts', 'erfassung/body-parts'
];
const urls = slugs.map((s) => `/fitness/${s}`);
const urls = ['/fitness', ...slugs.map((s) => `/fitness/${s}`)];
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
// Also cache __data.json for client-side navigation
const dataUrls = slugs.map((s) => `/fitness/${s}/__data.json`);
const dataUrls = ['/fitness/__data.json', ...slugs.map((s) => `/fitness/${s}/__data.json`)];
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
}
@@ -1,10 +1,13 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { invalidateAll } from '$app/navigation';
import { page as appPage } from '$app/state';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
const lang = $derived(detectFitnessLang(appPage.url.pathname));
const t = $derived(m[lang]);
@@ -12,9 +15,68 @@
let { data } = $props();
const sessions = $derived(data.sessions?.sessions ?? []);
/** @type {Array<{ _id: string, name: string, startTime: string, exercises: any[], duration?: number, totalVolume?: number, totalDistance?: number, prs?: any[], kcalEstimate?: any }>} */
let queuedSessions = $state([]);
async function loadQueued() {
try {
const entries = await getQueuedSessions();
queuedSessions = entries.map(e => {
const d = /** @type {any} */ (e.data);
// Server normally computes duration from start/end; do it locally
// so the unsynced card shows the same Clock stat as synced ones.
const duration = d.startTime && d.endTime
? Math.max(0, Math.round((new Date(d.endTime).getTime() - new Date(d.startTime).getTime()) / 60000))
: undefined;
return /** @type {any} */ ({
...d,
_id: `queued-${e.id}`,
duration,
});
});
} catch (err) {
console.error('Failed to load queued workouts:', err);
}
}
async function onOnline() {
// The layout's online listener triggers flushQueue; give it a moment to
// finish, then refresh both the queue display and server-side sessions.
setTimeout(async () => {
await loadQueued();
await invalidateAll();
}, 1500);
}
onMount(() => {
loadQueued();
window.addEventListener('online', onOnline);
});
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('online', onOnline);
});
/** Server sessions for the active view */
const serverSessions = $derived(data.sessions?.sessions ?? []);
const viewMonth = $derived(data.month); // YYYY-MM or null
/** Queued sessions filtered to the current view month (or shown on the recent view) */
const visibleQueued = $derived.by(() => {
if (!viewMonth) return queuedSessions; // recent view shows everything pending
return queuedSessions.filter(q => {
const d = new Date(q.startTime);
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
return ym === viewMonth;
});
});
/** Merged list, queued sessions first (newest unsynced on top) */
const sessions = $derived([...visibleQueued, ...serverSessions].sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
));
const queuedIds = $derived(new Set(visibleQueued.map(q => q._id)));
/** @type {Record<string, typeof sessions>} */
const grouped = $derived.by(() => {
/** @type {Record<string, typeof sessions>} */
@@ -74,7 +136,7 @@
<h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
<div class="session-list">
{#each monthSessions as session (session._id)}
<SessionCard {session} />
<SessionCard {session} unsynced={queuedIds.has(session._id)} />
{/each}
</div>
</section>
+44 -26
View File
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
'/recipes/offline-shell',
'/glaube',
'/faith',
'/fitness'
'/fitness',
// Active workout shells precached so a fresh install can log workouts
// offline immediately without an online visit to /fitness first.
'/fitness/workout',
'/fitness/training',
'/fitness/workout/active',
'/fitness/training/aktiv'
];
// Assets to precache
@@ -117,30 +123,34 @@ sw.addEventListener('fetch', (event) => {
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
const cacheKey = url.pathname;
let response: Response | undefined;
try {
// Try network first
const response = await fetch(event.request);
response = await fetch(event.request);
} catch {
// Network unreachable — fall through to cache fallback below.
}
// Cache successful responses for offline use (using pathname as key)
if (response.ok) {
if (response?.ok) {
cache.put(cacheKey, response.clone());
}
return response;
} catch {
// Network failed - try to serve from cache (ignoring query params)
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
// No cached data available - return error response
// The page will need to handle this gracefully
// Network unreachable OR upstream 5xx (502/503/504 etc.) — serve
// stale cached data so the PWA stays usable when the origin is down.
if (!response || response.status >= 500) {
const cached = await cache.match(cacheKey);
if (cached) return cached;
}
// Pass through non-5xx errors (404, 401, ...) untouched.
if (response) return response;
// No response and no cache — synthetic offline error.
return new Response(JSON.stringify({ error: 'offline' }), {
status: 503,
headers: { 'Content-Type': 'application/json' }
});
}
})()
);
return;
@@ -222,27 +232,32 @@ sw.addEventListener('fetch', (event) => {
// Use pathname only for cache key (ignore query params)
const cacheKey = url.pathname;
let response: Response | undefined;
try {
// Try network first
const response = await fetch(event.request);
response = await fetch(event.request);
} catch {
// Network unreachable — fall through to fallback below.
}
// Cache successful HTML responses for cacheable pages (using pathname as key)
const isCacheablePage = response.ok && (
if (response?.ok) {
const isCacheablePage =
url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
url.pathname === '/'
);
url.pathname === '/';
if (isCacheablePage) {
cache.put(cacheKey, response.clone());
}
return response;
} catch {
// Network failed - try to serve from cache (ignoring query params)
const cached = await cache.match(cacheKey);
if (cached) {
return cached;
}
// Network unreachable OR upstream 5xx (502 Bad Gateway, 503, 504, ...) —
// serve stale shell so the PWA stays usable when the origin is down.
const upstreamDown = !response || response.status >= 500;
if (upstreamDown) {
const cached = await cache.match(cacheKey);
if (cached) return cached;
// For recipe routes, redirect to the offline shell with the target URL
// The offline shell will then do client-side navigation to load from IndexedDB
// Skip if this is already the offline-shell or an offline navigation to prevent loops
@@ -262,6 +277,10 @@ sw.addEventListener('fetch', (event) => {
return Response.redirect(redirectUrl, 302);
}
}
}
// Pass through non-5xx errors (404, 401, ...) untouched.
if (response && !upstreamDown) return response;
// Last resort - return a styled offline response
return new Response(
@@ -299,7 +318,6 @@ p{color:#aaa}
</body></html>`,
{ headers: { 'Content-Type': 'text/html' } }
);
}
})()
);
return;