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