fitness: add offline support with session queue and shell caching
All checks were successful
CI / update (push) Successful in 2m3s
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:
@@ -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' },
|
||||
|
||||
77
src/lib/offline/fitnessQueue.ts
Normal file
77
src/lib/offline/fitnessQueue.ts
Normal 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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user