fitness: add offline support with session queue and shell caching
All checks were successful
CI / update (push) Successful in 2m3s

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.
This commit is contained in:
2026-03-23 22:15:40 +01:00
parent 621aa46cda
commit 0f79170f02
5 changed files with 125 additions and 6 deletions

View File

@@ -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' },

View File

@@ -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<IDBDatabase> {
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<void> {
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<Array<{ id: number; data: unknown; queuedAt: number }>> {
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<void> {
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<number> {
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;
}

View File

@@ -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 */

View File

@@ -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 @@
</div>
{/if}
<p class="completion-name">{completionData.name}</p>
{#if offlineQueued}
<p class="offline-banner">{t('workout_saved_offline', lang)}</p>
{/if}
</div>
<div class="completion-stats">
@@ -651,8 +662,8 @@
</div>
{/if}
<button class="done-btn" onclick={() => goto(`/fitness/${sl.history}/${completionData.sessionId}`)}>
{t('view_workout', lang)}
<button class="done-btn" onclick={() => goto(offlineQueued ? `/fitness/${sl.workout}` : `/fitness/${sl.history}/${completionData.sessionId}`)}>
{offlineQueued ? t('done', lang) : t('view_workout', lang)}
</button>
</div>
@@ -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;

View File

@@ -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) {