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

@@ -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;