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
|
// Active workout / completion
|
||||||
workout_complete: { en: 'Workout Complete', de: 'Training abgeschlossen' },
|
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' },
|
duration: { en: 'Duration', de: 'Dauer' },
|
||||||
tonnage: { en: 'Tonnage', de: 'Tonnage' },
|
tonnage: { en: 'Tonnage', de: 'Tonnage' },
|
||||||
distance: { en: 'Distance', de: 'Distanz' },
|
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:' },
|
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...' },
|
updating: { en: 'Updating...', de: 'Aktualisieren...' },
|
||||||
view_workout: { en: 'VIEW WORKOUT', de: 'TRAINING ANSEHEN' },
|
view_workout: { en: 'VIEW WORKOUT', de: 'TRAINING ANSEHEN' },
|
||||||
|
done: { en: 'DONE', de: 'FERTIG' },
|
||||||
workout_name_placeholder: { en: 'Workout name', de: 'Trainingsname' },
|
workout_name_placeholder: { en: 'Workout name', de: 'Trainingsname' },
|
||||||
cancel_workout: { en: 'CANCEL WORKOUT', de: 'TRAINING ABBRECHEN' },
|
cancel_workout: { en: 'CANCEL WORKOUT', de: 'TRAINING ABBRECHEN' },
|
||||||
finish: { en: 'FINISH', de: 'BEENDEN' },
|
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 { getWorkoutSync } from '$lib/js/workoutSync.svelte';
|
||||||
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
|
import WorkoutFab from '$lib/components/fitness/WorkoutFab.svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, fitnessLabels } from '$lib/js/fitnessI18n';
|
||||||
|
import { flushQueue } from '$lib/offline/fitnessQueue';
|
||||||
|
|
||||||
let { data, children } = $props();
|
let { data, children } = $props();
|
||||||
let user = $derived(data.session?.user);
|
let user = $derived(data.session?.user);
|
||||||
@@ -20,14 +21,34 @@
|
|||||||
const s = $derived(fitnessSlugs(lang));
|
const s = $derived(fitnessSlugs(lang));
|
||||||
const labels = $derived(fitnessLabels(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 () => {
|
onMount(async () => {
|
||||||
workout.restore();
|
workout.restore();
|
||||||
workout.onChange(() => sync.notifyChange());
|
workout.onChange(() => sync.notifyChange());
|
||||||
await sync.init();
|
await sync.init();
|
||||||
|
flushQueue();
|
||||||
|
precacheFitnessShells();
|
||||||
|
window.addEventListener('online', onOnline);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
sync.destroy();
|
sync.destroy();
|
||||||
|
if (typeof window !== 'undefined') window.removeEventListener('online', onOnline);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** @param {string} path */
|
/** @param {string} path */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
|
import { estimateWorkoutKcal } from '$lib/data/kcalEstimate';
|
||||||
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
import { estimateCardioKcal } from '$lib/data/cardioKcalEstimate';
|
||||||
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
import ExerciseName from '$lib/components/fitness/ExerciseName.svelte';
|
||||||
|
import { queueSession } from '$lib/offline/fitnessQueue';
|
||||||
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
import SetTable from '$lib/components/fitness/SetTable.svelte';
|
||||||
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
import ExercisePicker from '$lib/components/fitness/ExercisePicker.svelte';
|
||||||
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
import SyncIndicator from '$lib/components/fitness/SyncIndicator.svelte';
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
/** @type {any[]} */
|
/** @type {any[]} */
|
||||||
let templateDiffs = $state([]);
|
let templateDiffs = $state([]);
|
||||||
let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done'
|
let templateUpdateStatus = $state('idle'); // 'idle' | 'updating' | 'done'
|
||||||
|
let offlineQueued = $state(false);
|
||||||
|
|
||||||
let useGps = $state(gps.isTracking);
|
let useGps = $state(gps.isTracking);
|
||||||
|
|
||||||
@@ -214,9 +216,15 @@
|
|||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
completionData = buildCompletion(sessionData, d.session);
|
completionData = buildCompletion(sessionData, d.session);
|
||||||
computeTemplateDiff(completionData);
|
computeTemplateDiff(completionData);
|
||||||
|
} else {
|
||||||
|
await queueSession(sessionData);
|
||||||
|
offlineQueued = true;
|
||||||
|
completionData = buildCompletion(sessionData, { _id: null });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
console.error('[finish] fetch error:', err);
|
await queueSession(sessionData);
|
||||||
|
offlineQueued = true;
|
||||||
|
completionData = buildCompletion(sessionData, { _id: null });
|
||||||
await sync.onWorkoutEnd();
|
await sync.onWorkoutEnd();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,6 +534,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="completion-name">{completionData.name}</p>
|
<p class="completion-name">{completionData.name}</p>
|
||||||
|
{#if offlineQueued}
|
||||||
|
<p class="offline-banner">{t('workout_saved_offline', lang)}</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="completion-stats">
|
<div class="completion-stats">
|
||||||
@@ -651,8 +662,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button class="done-btn" onclick={() => goto(`/fitness/${sl.history}/${completionData.sessionId}`)}>
|
<button class="done-btn" onclick={() => goto(offlineQueued ? `/fitness/${sl.workout}` : `/fitness/${sl.history}/${completionData.sessionId}`)}>
|
||||||
{t('view_workout', lang)}
|
{offlineQueued ? t('done', lang) : t('view_workout', lang)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -794,6 +805,14 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--color-text-secondary);
|
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 {
|
.pr-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
// Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root)
|
// Handle SvelteKit __data.json requests for cacheable routes (recipes, glaube, root)
|
||||||
// Cache successful responses, serve from cache when offline
|
// Cache successful responses, serve from cache when offline
|
||||||
const isCacheableDataRoute = url.pathname.includes('__data.json') &&
|
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) {
|
if (isCacheableDataRoute) {
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
@@ -193,7 +193,7 @@ sw.addEventListener('fetch', (event) => {
|
|||||||
|
|
||||||
// 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 && (
|
const isCacheablePage = response.ok && (
|
||||||
url.pathname.match(/^\/(rezepte|recipes|glaube|faith)(\/|$)/) ||
|
url.pathname.match(/^\/(rezepte|recipes|glaube|faith|fitness)(\/|$)/) ||
|
||||||
url.pathname === '/'
|
url.pathname === '/'
|
||||||
);
|
);
|
||||||
if (isCacheablePage) {
|
if (isCacheablePage) {
|
||||||
|
|||||||
Reference in New Issue
Block a user