feat(fitness): cache more fitness shells & show unsynced workouts on history

Precache:
- Service worker now precaches /fitness/workout(/active) shells (DE+EN)
  on install so a fresh device can log workouts offline without first
  visiting /fitness online.
- Layout-level precache adds /fitness/__data.json itself plus the
  static sub-routes nutrition/meals and check-in/body-parts (DE+EN).

Unsynced workouts on history:
- History page reads the offline outbox via getQueuedSessions on mount
  and merges queued sessions into the displayed list, sorted by
  startTime. Duration is computed locally so the Clock stat still
  shows.
- SessionCard gains an 'unsynced' prop: renders as a non-clickable
  div with an orange-accent border and a CloudOff badge labelled
  'Unsynced' / 'Nicht synchronisiert'.
- On window 'online', the page waits briefly for the layout's
  flushQueue to drain the outbox, then re-reads the queue and calls
  invalidateAll to swap unsynced placeholders for the now-saved
  server sessions.
This commit is contained in:
2026-05-08 16:28:26 +02:00
parent 0814803fc7
commit 244050fa75
7 changed files with 126 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homepage",
"version": "1.67.5",
"version": "1.68.0",
"private": true,
"type": "module",
"scripts": {
+47 -6
View File
@@ -8,10 +8,12 @@
import Route from '@lucide/svelte/icons/route';
import Gauge from '@lucide/svelte/icons/gauge';
import Flame from '@lucide/svelte/icons/flame';
import { detectFitnessLang, fitnessSlugs } from '$lib/js/fitnessI18n';
import CloudOff from '@lucide/svelte/icons/cloud-off';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
const lang = $derived(detectFitnessLang(page.url.pathname));
const sl = $derived(fitnessSlugs(lang));
const t = $derived(m[lang]);
/**
* @type {{
@@ -30,10 +32,11 @@
* gpsPreview?: number[][],
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: number }>
* }>
* }
* },
* unsynced?: boolean
* }}
*/
let { session } = $props();
let { session, unsynced = false } = $props();
/** @param {number} mins */
function formatDuration(mins) {
@@ -153,9 +156,17 @@
});
</script>
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
{#snippet cardBody()}
<div class="card-top">
<h3 class="session-name">{session.name}</h3>
<h3 class="session-name">
{session.name}
{#if unsynced}
<span class="unsynced-badge" title={t.unsynced_label}>
<CloudOff size={12} strokeWidth={2} />
{t.unsynced_label}
</span>
{/if}
</h3>
<span class="session-date">{formatDate(session.startTime)} &middot; {formatTime(session.startTime)}</span>
</div>
@@ -207,7 +218,13 @@
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
{/if}
</div>
</a>
{/snippet}
{#if unsynced}
<div class="session-card unsynced">{@render cardBody()}</div>
{:else}
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">{@render cardBody()}</a>
{/if}
<style>
.session-card {
@@ -228,6 +245,30 @@
.session-card:active {
transform: translateY(0);
}
.session-card.unsynced {
border-left: 3px solid var(--orange, var(--nord12));
opacity: 0.92;
cursor: default;
}
.session-card.unsynced:hover {
transform: none;
box-shadow: var(--shadow-sm);
}
.unsynced-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: 0.5rem;
padding: 0.1rem 0.45rem;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--orange, var(--nord12));
background: color-mix(in srgb, var(--orange, var(--nord12)) 12%, transparent);
border-radius: 100px;
vertical-align: middle;
}
.card-top {
margin-bottom: 0.6rem;
}
+1
View File
@@ -33,6 +33,7 @@ export const de = {
weight: "Gewicht",
history_title: "Verlauf",
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
unsynced_label: "Nicht synchronisiert",
load_more: "Mehr laden",
date: "Datum",
time: "Uhrzeit",
+1
View File
@@ -33,6 +33,7 @@ export const en = {
weight: "Weight",
history_title: "History",
no_workouts_yet: "No workouts yet. Start your first workout!",
unsynced_label: "Unsynced",
load_more: "Load more",
date: "Date",
time: "Time",
+5 -3
View File
@@ -34,12 +34,14 @@
'workout', 'training', 'workout/active', 'training/aktiv',
'exercises', 'uebungen', 'stats', 'statistik',
'history', 'verlauf', 'check-in', 'erfassung',
'nutrition', 'ernaehrung'
'nutrition', 'ernaehrung',
'nutrition/meals', 'ernaehrung/meals',
'check-in/body-parts', 'erfassung/body-parts'
];
const urls = slugs.map((s) => `/fitness/${s}`);
const urls = ['/fitness', ...slugs.map((s) => `/fitness/${s}`)];
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
// Also cache __data.json for client-side navigation
const dataUrls = slugs.map((s) => `/fitness/${s}/__data.json`);
const dataUrls = ['/fitness/__data.json', ...slugs.map((s) => `/fitness/${s}/__data.json`)];
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
}
@@ -1,10 +1,13 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { resolve } from '$app/paths';
import { invalidateAll } from '$app/navigation';
import { page as appPage } from '$app/state';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
const lang = $derived(detectFitnessLang(appPage.url.pathname));
const t = $derived(m[lang]);
@@ -12,9 +15,68 @@
let { data } = $props();
const sessions = $derived(data.sessions?.sessions ?? []);
/** @type {Array<{ _id: string, name: string, startTime: string, exercises: any[], duration?: number, totalVolume?: number, totalDistance?: number, prs?: any[], kcalEstimate?: any }>} */
let queuedSessions = $state([]);
async function loadQueued() {
try {
const entries = await getQueuedSessions();
queuedSessions = entries.map(e => {
const d = /** @type {any} */ (e.data);
// Server normally computes duration from start/end; do it locally
// so the unsynced card shows the same Clock stat as synced ones.
const duration = d.startTime && d.endTime
? Math.max(0, Math.round((new Date(d.endTime).getTime() - new Date(d.startTime).getTime()) / 60000))
: undefined;
return /** @type {any} */ ({
...d,
_id: `queued-${e.id}`,
duration,
});
});
} catch (err) {
console.error('Failed to load queued workouts:', err);
}
}
async function onOnline() {
// The layout's online listener triggers flushQueue; give it a moment to
// finish, then refresh both the queue display and server-side sessions.
setTimeout(async () => {
await loadQueued();
await invalidateAll();
}, 1500);
}
onMount(() => {
loadQueued();
window.addEventListener('online', onOnline);
});
onDestroy(() => {
if (typeof window !== 'undefined') window.removeEventListener('online', onOnline);
});
/** Server sessions for the active view */
const serverSessions = $derived(data.sessions?.sessions ?? []);
const viewMonth = $derived(data.month); // YYYY-MM or null
/** Queued sessions filtered to the current view month (or shown on the recent view) */
const visibleQueued = $derived.by(() => {
if (!viewMonth) return queuedSessions; // recent view shows everything pending
return queuedSessions.filter(q => {
const d = new Date(q.startTime);
const ym = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
return ym === viewMonth;
});
});
/** Merged list, queued sessions first (newest unsynced on top) */
const sessions = $derived([...visibleQueued, ...serverSessions].sort(
(a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()
));
const queuedIds = $derived(new Set(visibleQueued.map(q => q._id)));
/** @type {Record<string, typeof sessions>} */
const grouped = $derived.by(() => {
/** @type {Record<string, typeof sessions>} */
@@ -74,7 +136,7 @@
<h2 class="month-header">{month}{monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
<div class="session-list">
{#each monthSessions as session (session._id)}
<SessionCard {session} />
<SessionCard {session} unsynced={queuedIds.has(session._id)} />
{/each}
</div>
</section>
+7 -1
View File
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
'/recipes/offline-shell',
'/glaube',
'/faith',
'/fitness'
'/fitness',
// Active workout shells precached so a fresh install can log workouts
// offline immediately without an online visit to /fitness first.
'/fitness/workout',
'/fitness/training',
'/fitness/workout/active',
'/fitness/training/aktiv'
];
// Assets to precache