From 0f79170f020371d5c185ad12f2b5588279c00369 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Mon, 23 Mar 2026 22:15:40 +0100 Subject: [PATCH] fitness: add offline support with session queue and shell caching Cache fitness page shells and data routes in the service worker so pages load offline. Queue finished workouts in IndexedDB when the POST fails and auto-flush them on reconnect. Show an offline banner on the completion screen so the user knows their workout will sync. --- src/lib/js/fitnessI18n.ts | 2 + src/lib/offline/fitnessQueue.ts | 77 +++++++++++++++++++ src/routes/fitness/+layout.svelte | 21 +++++ .../[active=fitnessActive]/+page.svelte | 27 ++++++- src/service-worker.ts | 4 +- 5 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 src/lib/offline/fitnessQueue.ts diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 7f23122..189d0d0 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -128,6 +128,7 @@ const translations: Translations = { // Active workout / completion workout_complete: { en: 'Workout Complete', de: 'Training abgeschlossen' }, + workout_saved_offline: { en: 'Saved offline — will sync when back online.', de: 'Offline gespeichert — wird bei Verbindung synchronisiert.' }, duration: { en: 'Duration', de: 'Dauer' }, tonnage: { en: 'Tonnage', de: 'Tonnage' }, distance: { en: 'Distance', de: 'Distanz' }, @@ -139,6 +140,7 @@ const translations: Translations = { template_diff_desc: { en: 'Your weights or reps differ from the template:', de: 'Gewichte oder Wiederholungen weichen von der Vorlage ab:' }, updating: { en: 'Updating...', de: 'Aktualisieren...' }, view_workout: { en: 'VIEW WORKOUT', de: 'TRAINING ANSEHEN' }, + done: { en: 'DONE', de: 'FERTIG' }, workout_name_placeholder: { en: 'Workout name', de: 'Trainingsname' }, cancel_workout: { en: 'CANCEL WORKOUT', de: 'TRAINING ABBRECHEN' }, finish: { en: 'FINISH', de: 'BEENDEN' }, diff --git a/src/lib/offline/fitnessQueue.ts b/src/lib/offline/fitnessQueue.ts new file mode 100644 index 0000000..85c50a3 --- /dev/null +++ b/src/lib/offline/fitnessQueue.ts @@ -0,0 +1,77 @@ +/** + * Offline outbox for fitness sessions that failed to POST. + * Stores them in IndexedDB and flushes when back online. + */ + +const DB_NAME = 'fitness-outbox'; +const DB_VERSION = 1; +const STORE = 'sessions'; + +function openDB(): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true }); + } + }; + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +export async function queueSession(sessionData: unknown): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).add({ data: sessionData, queuedAt: Date.now() }); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +export async function getQueuedSessions(): Promise> { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readonly'); + const req = tx.objectStore(STORE).getAll(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +async function removeSession(id: number): Promise { + const db = await openDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE, 'readwrite'); + tx.objectStore(STORE).delete(id); + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + }); +} + +/** Try POSTing all queued sessions. Returns count of successfully synced. */ +export async function flushQueue(): Promise { + const sessions = await getQueuedSessions(); + if (sessions.length === 0) return 0; + + let synced = 0; + for (const entry of sessions) { + try { + const res = await fetch('/api/fitness/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(entry.data) + }); + if (res.ok) { + await removeSession(entry.id); + synced++; + } + } catch { + // Still offline — stop trying + break; + } + } + return synced; +} diff --git a/src/routes/fitness/+layout.svelte b/src/routes/fitness/+layout.svelte index 77d545a..c201c2b 100644 --- a/src/routes/fitness/+layout.svelte +++ b/src/routes/fitness/+layout.svelte @@ -9,6 +9,7 @@ import { getWorkoutSync } from '$lib/js/workoutSync.svelte'; import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte'; import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n'; + import { flushQueue } from '$lib/offline/fitnessQueue'; let { data, children } = $props(); let user = $derived(data.session?.user); @@ -20,14 +21,34 @@ const s = $derived(fitnessSlugs(lang)); const labels = $derived(fitnessLabels(lang)); + /** Pre-cache all fitness page shells so they work offline */ + function precacheFitnessShells() { + if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return; + const slugs = [ + 'workout', 'training', 'workout/active', 'training/aktiv', + 'exercises', 'uebungen', 'stats', 'statistik', + 'history', 'verlauf', 'measure', 'messen' + ]; + const urls = slugs.map((s) => `/fitness/${s}`); + navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls }); + } + + function onOnline() { + flushQueue(); + } + onMount(async () => { workout.restore(); workout.onChange(() => sync.notifyChange()); await sync.init(); + flushQueue(); + precacheFitnessShells(); + window.addEventListener('online', onOnline); }); onDestroy(() => { sync.destroy(); + if (typeof window !== 'undefined') window.removeEventListener('online', onOnline); }); /** @param {string} path */ diff --git a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte index f13ef27..0302732 100644 --- a/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte +++ b/src/routes/fitness/[workout=fitnessWorkout]/[active=fitnessActive]/+page.svelte @@ -13,6 +13,7 @@ import { estimateWorkoutKcal } from '$lib/data/kcalEstimate'; import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate'; import ExerciseName from '$lib/components/fitness/ExerciseName.svelte'; + import { queueSession } from '$lib/offline/fitnessQueue'; import SetTable from '$lib/components/fitness/SetTable.svelte'; import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte'; import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte'; @@ -35,6 +36,7 @@ /** @type {any[]} */ let templateDiffs = $state([]); let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done' + let offlineQueued = $state(false); let useGps = $state(gps.isTracking); @@ -214,9 +216,15 @@ const d = await res.json(); completionData = buildCompletion(sessionData, d.session); computeTemplateDiff(completionData); + } else { + await queueSession(sessionData); + offlineQueued = true; + completionData = buildCompletion(sessionData, { _id: null }); } - } catch (err) { - console.error('[finish] fetch error:', err); + } catch { + await queueSession(sessionData); + offlineQueued = true; + completionData = buildCompletion(sessionData, { _id: null }); await sync.onWorkoutEnd(); } } @@ -526,6 +534,9 @@ {/if}

{completionData.name}

+ {#if offlineQueued} +

{t('workout_saved_offline', lang)}

+ {/if}
@@ -651,8 +662,8 @@
{/if} - @@ -794,6 +805,14 @@ font-size: 0.9rem; color: var(--color-text-secondary); } + .offline-banner { + margin: 0.5rem 0 0; + padding: 0.4rem 0.8rem; + font-size: 0.8rem; + color: var(--nord0); + background: var(--nord13); + border-radius: 0.4rem; + } .pr-badge { display: inline-flex; align-items: center; diff --git a/src/service-worker.ts b/src/service-worker.ts index 75fdfa8..b718891 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -73,7 +73,7 @@ sw.addEventListener('fetch', (event) => { // Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root) // Cache successful responses, serve from cache when offline const isCacheableDataRoute = url.pathname.includes('__data.json') && - (url.pathname.match(/^\/(rezepte|recipes|glaube|faith)(\/|$)/) || url.pathname === '/__data.json'); + (url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) || url.pathname === '/__data.json'); if (isCacheableDataRoute) { event.respondWith( @@ -193,7 +193,7 @@ sw.addEventListener('fetch', (event) => { // Cache successful HTML responses for cacheable pages (using pathname as key) const isCacheablePage = response.ok && ( - url.pathname.match(/^\/(rezepte|recipes|glaube|faith)(\/|$)/) || + url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) || url.pathname === '/' ); if (isCacheablePage) {