From 19ac60c6f2bcdd6267b21bc3ca484207c64f1fab 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 7f23122d..189d0d02 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 00000000..85c50a37 --- /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 77d545a4..c201c2bb 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 f13ef274..03027329 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 75fdfa86..b7188913 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) {