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", "name": "homepage",
"version": "1.67.3", "version": "1.68.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+101 -37
View File
@@ -200,47 +200,107 @@
} }
// Generate future predicted cycles (12 cycles ≈ ~1 year) // 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 periodMs = (Math.round(emaPeriod) - 1) * 86400000;
const lutealLength = 14; const lutealLength = 14;
const lastStart = sorted[0] ? new Date(sorted[0].startDate) : null; 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 = []; const futureCycles = [];
if (lastStart) { if (lastStart) {
let base = lastStart.getTime(); 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++) { for (let i = 0; i < 12; i++) {
const start = new Date(base + cycleMs); const nextPeriodStartMs = base + cycleMs;
const end = new Date(start.getTime() + periodMs); const periodEndMs = nextPeriodStartMs + periodMs;
const ov = new Date(start.getTime() - lutealLength * 86400000); const w = buildWindow(base, priorPeriodEndMs, nextPeriodStartMs, /* widen */ true);
// Luteal phase: day after ovulation until day before next period
const lutealStart = new Date(ov.getTime() + 86400000);
const lutealEnd = new Date(start.getTime() - 86400000);
futureCycles.push({ futureCycles.push({
start, end, start: new Date(nextPeriodStartMs),
fertileStart: new Date(ov.getTime() - 5 * 86400000), end: new Date(periodEndMs),
fertileEnd: ov, ...w
peakStart: new Date(ov.getTime() - 2 * 86400000),
lutealStart,
lutealEnd
}); });
base = start.getTime(); base = nextPeriodStartMs;
priorPeriodEndMs = periodEndMs;
} }
} }
// Past fertility/luteal windows (from completed cycles) // 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 = []; const pastFertileWindows = [];
for (let i = 1; i < completed.length; i++) { for (let i = 1; i < completed.length; i++) {
const nextPeriodStart = new Date(completed[i].startDate); const cycleStartMs = midnight(new Date(completed[i - 1].startDate));
const ov = new Date(nextPeriodStart.getTime() - lutealLength * 86400000); const priorPeriodEndMs = completed[i - 1].endDate
pastFertileWindows.push({ ? midnight(new Date(completed[i - 1].endDate))
fertileStart: new Date(ov.getTime() - 5 * 86400000), : null;
fertileEnd: ov, const nextPeriodStartMs = midnight(new Date(completed[i].startDate));
peakStart: new Date(ov.getTime() - 2 * 86400000), pastFertileWindows.push(buildWindow(cycleStartMs, priorPeriodEndMs, nextPeriodStartMs, /* widen */ false));
lutealStart: new Date(ov.getTime() + 86400000),
lutealEnd: new Date(nextPeriodStart.getTime() - 86400000)
});
} }
return { return {
@@ -405,12 +465,14 @@
const cs = midnight(c.start); const cs = midnight(c.start);
const ce = midnight(c.end); const ce = midnight(c.end);
if (d >= cs && d <= ce) return 'predicted'; if (d >= cs && d <= ce) return 'predicted';
const fe = midnight(c.fertileEnd); const ovDay = midnight(c.ovulation);
if (d === fe) return 'ovulation'; if (d === ovDay) return 'ovulation';
const ps = midnight(c.peakStart); const ps = midnight(c.peakStart);
const pe = midnight(c.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(c.fertileStart); const fs = midnight(c.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile'; const fe = midnight(c.fertileEnd);
if (d >= fs && d < ps) return 'fertile'; if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(c.lutealStart); const ls = midnight(c.lutealStart);
const le = midnight(c.lutealEnd); const le = midnight(c.lutealEnd);
if (d >= ls && d <= le) return 'luteal'; if (d >= ls && d <= le) return 'luteal';
@@ -418,12 +480,14 @@
// Past fertility/luteal windows // Past fertility/luteal windows
for (const w of predictions.pastFertileWindows) { for (const w of predictions.pastFertileWindows) {
const fe = midnight(w.fertileEnd); const ovDay = midnight(w.ovulation);
if (d === fe) return 'ovulation'; if (d === ovDay) return 'ovulation';
const ps = midnight(w.peakStart); const ps = midnight(w.peakStart);
const pe = midnight(w.peakEnd);
if (d >= ps && d <= pe) return 'peak-fertile';
const fs = midnight(w.fertileStart); const fs = midnight(w.fertileStart);
if (d >= ps && d < fe) return 'peak-fertile'; const fe = midnight(w.fertileEnd);
if (d >= fs && d < ps) return 'fertile'; if (d >= fs && d <= fe) return 'fertile';
const ls = midnight(w.lutealStart); const ls = midnight(w.lutealStart);
const le = midnight(w.lutealEnd); const le = midnight(w.lutealEnd);
if (d >= ls && d <= le) return 'luteal'; if (d >= ls && d <= le) return 'luteal';
@@ -737,8 +801,8 @@
<div class="status-side"> <div class="status-side">
<div class="status-side-item ovulation-accent"> <div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span> <span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span> <span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span> <span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div> </div>
<div class="status-side-item fertile-accent"> <div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span> <span class="status-side-label">{t.fertile}</span>
@@ -762,8 +826,8 @@
<div class="status-side"> <div class="status-side">
<div class="status-side-item ovulation-accent"> <div class="status-side-item ovulation-accent">
<span class="status-side-label">{t.ovulation}</span> <span class="status-side-label">{t.ovulation}</span>
<span class="status-side-relative">{relativeDate(nextCycle.fertileEnd)}</span> <span class="status-side-relative">{relativeDate(nextCycle.ovulation)}</span>
<span class="status-side-date">{formatDate(nextCycle.fertileEnd)}</span> <span class="status-side-date">{formatDate(nextCycle.ovulation)}</span>
</div> </div>
<div class="status-side-item fertile-accent"> <div class="status-side-item fertile-accent">
<span class="status-side-label">{t.fertile}</span> <span class="status-side-label">{t.fertile}</span>
+47 -6
View File
@@ -8,10 +8,12 @@
import Route from '@lucide/svelte/icons/route'; import Route from '@lucide/svelte/icons/route';
import Gauge from '@lucide/svelte/icons/gauge'; import Gauge from '@lucide/svelte/icons/gauge';
import Flame from '@lucide/svelte/icons/flame'; 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 lang = $derived(detectFitnessLang(page.url.pathname));
const sl = $derived(fitnessSlugs(lang)); const sl = $derived(fitnessSlugs(lang));
const t = $derived(m[lang]);
/** /**
* @type {{ * @type {{
@@ -30,10 +32,11 @@
* gpsPreview?: number[][], * gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: 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 */ /** @param {number} mins */
function formatDuration(mins) { function formatDuration(mins) {
@@ -153,9 +156,17 @@
}); });
</script> </script>
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card"> {#snippet cardBody()}
<div class="card-top"> <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> <span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div> </div>
@@ -207,7 +218,13 @@
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span> <span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if} {/if}
</div> </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> <style>
.session-card { .session-card {
@@ -228,6 +245,30 @@
.session-card:active { .session-card:active {
transform: translateY(0); 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 { .card-top {
margin-bottom: 0.6rem; margin-bottom: 0.6rem;
} }
+1
View File
@@ -33,6 +33,7 @@ export const de = {
weight: "Gewicht", weight: "Gewicht",
history_title: "Verlauf", history_title: "Verlauf",
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!", no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
unsynced_label: "Nicht synchronisiert",
load_more: "Mehr laden", load_more: "Mehr laden",
date: "Datum", date: "Datum",
time: "Uhrzeit", time: "Uhrzeit",
+1
View File
@@ -33,6 +33,7 @@ export const en = {
weight: "Weight", weight: "Weight",
history_title: "History", history_title: "History",
no_workouts_yet: "No workouts yet. Start your first workout!", no_workouts_yet: "No workouts yet. Start your first workout!",
unsynced_label: "Unsynced",
load_more: "Load more", load_more: "Load more",
date: "Date", date: "Date",
time: "Time", time: "Time",
+28 -12
View File
@@ -16,15 +16,8 @@ function addFavoriteStatus<T extends { _id: unknown }>(
})); }));
} }
export const load: PageLoad = async ({ params, fetch, parent }) => { async function loadFromIndexedDB() {
const parentData = await parent(); if (!await isOfflineDataAvailable()) return null;
const apiBase = `/api/${params.recipeLang}`;
const useOfflineData =
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
if (useOfflineData) {
try {
if (await isOfflineDataAvailable()) {
const [allBrief, seasonRecipes] = await Promise.all([ const [allBrief, seasonRecipes] = await Promise.all([
getAllBriefRecipes(), getAllBriefRecipes(),
getBriefRecipesInSeasonOn(new Date()) getBriefRecipesInSeasonOn(new Date())
@@ -33,9 +26,21 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
all_brief: rand_array(allBrief), all_brief: rand_array(allBrief),
season: rand_array(seasonRecipes), season: rand_array(seasonRecipes),
heroIndex: Math.random(), 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) { } catch (e) {
console.error('Failed to load offline data:', e); console.error('Failed to load offline data:', e);
} }
@@ -54,7 +59,18 @@ export const load: PageLoad = async ({ params, fetch, parent }) => {
favorites = body.favorites ?? []; favorites = body.favorites ?? [];
} }
} catch { } 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); const marked = addFavoriteStatus(all_brief, favorites);
@@ -15,23 +15,28 @@ function addFavoriteStatus<T extends { _id: unknown }>(
})); }));
} }
export const load: PageLoad = async ({ params, fetch, parent }) => { async function loadFromIndexedDB(month: number) {
const parentData = await parent(); if (!await isOfflineDataAvailable()) return null;
const month = parseInt(params.month, 10);
const apiBase = `/api/${params.recipeLang}`;
const useOfflineData =
browser && (isOffline() || parentData.isOffline) && canUseOfflineData();
if (useOfflineData) {
try {
if (await isOfflineDataAvailable()) {
const recipes = await getBriefRecipesOverlappingMonth(month); const recipes = await getBriefRecipesOverlappingMonth(month);
return { return {
month, month,
season: rand_array(recipes), 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) { } catch (e) {
console.error('Failed to load offline season data:', 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 ?? []; favorites = body.favorites ?? [];
} }
} catch { } 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 { return {
+5 -3
View File
@@ -34,12 +34,14 @@
'workout', 'training', 'workout/active', 'training/aktiv', 'workout', 'training', 'workout/active', 'training/aktiv',
'exercises', 'uebungen', 'stats', 'statistik', 'exercises', 'uebungen', 'stats', 'statistik',
'history', 'verlauf', 'check-in', 'erfassung', '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 }); navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
// Also cache __data.json for client-side navigation // 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 }); navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
} }
@@ -1,10 +1,13 @@
<script> <script>
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { invalidateAll } from '$app/navigation';
import { page as appPage } from '$app/state'; import { page as appPage } from '$app/state';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import SessionCard from '$lib/components/fitness/SessionCard.svelte'; import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n'; import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
const lang = $derived(detectFitnessLang(appPage.url.pathname)); const lang = $derived(detectFitnessLang(appPage.url.pathname));
const t = $derived(m[lang]); const t = $derived(m[lang]);
@@ -12,9 +15,68 @@
let { data } = $props(); 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 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>} */ /** @type {Record<string, typeof sessions>} */
const grouped = $derived.by(() => { const grouped = $derived.by(() => {
/** @type {Record<string, typeof sessions>} */ /** @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> <h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
<div class="session-list"> <div class="session-list">
{#each monthSessions as session (session._id)} {#each monthSessions as session (session._id)}
<SessionCard {session} /> <SessionCard {session} unsynced={queuedIds.has(session._id)} />
{/each} {/each}
</div> </div>
</section> </section>
+44 -26
View File
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
'/recipes/offline-shell', '/recipes/offline-shell',
'/glaube', '/glaube',
'/faith', '/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 // Assets to precache
@@ -117,30 +123,34 @@ sw.addEventListener('fetch', (event) => {
// SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore // SvelteKit adds ?x-sveltekit-invalidated=... which we need to ignore
const cacheKey = url.pathname; const cacheKey = url.pathname;
let response: Response | undefined;
try { try {
// Try network first response = await fetch(event.request);
const response = await fetch(event.request); } catch {
// Network unreachable — fall through to cache fallback below.
}
// Cache successful responses for offline use (using pathname as key) // Cache successful responses for offline use (using pathname as key)
if (response.ok) { if (response?.ok) {
cache.put(cacheKey, response.clone()); cache.put(cacheKey, response.clone());
}
return response; 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 // Network unreachable OR upstream 5xx (502/503/504 etc.) — serve
// The page will need to handle this gracefully // 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' }), { return new Response(JSON.stringify({ error: 'offline' }), {
status: 503, status: 503,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
}
})() })()
); );
return; return;
@@ -222,27 +232,32 @@ sw.addEventListener('fetch', (event) => {
// Use pathname only for cache key (ignore query params) // Use pathname only for cache key (ignore query params)
const cacheKey = url.pathname; const cacheKey = url.pathname;
let response: Response | undefined;
try { try {
// Try network first response = await fetch(event.request);
const response = await fetch(event.request); } catch {
// Network unreachable — fall through to fallback below.
}
// Cache successful HTML responses for cacheable pages (using pathname as key) // 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.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
url.pathname === '/' url.pathname === '/';
);
if (isCacheablePage) { if (isCacheablePage) {
cache.put(cacheKey, response.clone()); cache.put(cacheKey, response.clone());
} }
return response; 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 // 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 // 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 // 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); 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 // Last resort - return a styled offline response
return new Response( return new Response(
@@ -299,7 +318,6 @@ p{color:#aaa}
</body></html>`, </body></html>`,
{ headers: { 'Content-Type': 'text/html' } } { headers: { 'Content-Type': 'text/html' } }
); );
}
})() })()
); );
return; return;