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:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "homepage",
|
"name": "homepage",
|
||||||
"version": "1.67.5",
|
"version": "1.68.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
import Route from '@lucide/svelte/icons/route';
|
import Route from '@lucide/svelte/icons/route';
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
import Flame from '@lucide/svelte/icons/flame';
|
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 lang = $derived(detectFitnessLang(page.url.pathname));
|
||||||
const sl = $derived(fitnessSlugs(lang));
|
const sl = $derived(fitnessSlugs(lang));
|
||||||
|
const t = $derived(m[lang]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {{
|
* @type {{
|
||||||
@@ -30,10 +32,11 @@
|
|||||||
* gpsPreview?: number[][],
|
* gpsPreview?: number[][],
|
||||||
* sets: Array<{ reps?: number, weight?: number, rpe?: number, distance?: number, duration?: 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 */
|
/** @param {number} mins */
|
||||||
function formatDuration(mins) {
|
function formatDuration(mins) {
|
||||||
@@ -153,9 +156,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a href={resolve('/fitness/[history=fitnessHistory]/[id]', { history: sl.history, id: session._id })} class="session-card">
|
{#snippet cardBody()}
|
||||||
<div class="card-top">
|
<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)} · {formatTime(session.startTime)}</span>
|
<span class="session-date">{formatDate(session.startTime)} · {formatTime(session.startTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -207,7 +218,13 @@
|
|||||||
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
<span class="stat pr"><Trophy size={14} /> {session.prs.length} PR{session.prs.length > 1 ? 's' : ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
<style>
|
||||||
.session-card {
|
.session-card {
|
||||||
@@ -228,6 +245,30 @@
|
|||||||
.session-card:active {
|
.session-card:active {
|
||||||
transform: translateY(0);
|
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 {
|
.card-top {
|
||||||
margin-bottom: 0.6rem;
|
margin-bottom: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const de = {
|
|||||||
weight: "Gewicht",
|
weight: "Gewicht",
|
||||||
history_title: "Verlauf",
|
history_title: "Verlauf",
|
||||||
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
|
no_workouts_yet: "Noch keine Trainings. Starte dein erstes Training!",
|
||||||
|
unsynced_label: "Nicht synchronisiert",
|
||||||
load_more: "Mehr laden",
|
load_more: "Mehr laden",
|
||||||
date: "Datum",
|
date: "Datum",
|
||||||
time: "Uhrzeit",
|
time: "Uhrzeit",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const en = {
|
|||||||
weight: "Weight",
|
weight: "Weight",
|
||||||
history_title: "History",
|
history_title: "History",
|
||||||
no_workouts_yet: "No workouts yet. Start your first workout!",
|
no_workouts_yet: "No workouts yet. Start your first workout!",
|
||||||
|
unsynced_label: "Unsynced",
|
||||||
load_more: "Load more",
|
load_more: "Load more",
|
||||||
date: "Date",
|
date: "Date",
|
||||||
time: "Time",
|
time: "Time",
|
||||||
|
|||||||
@@ -34,12 +34,14 @@
|
|||||||
'workout', 'training', 'workout/active', 'training/aktiv',
|
'workout', 'training', 'workout/active', 'training/aktiv',
|
||||||
'exercises', 'uebungen', 'stats', 'statistik',
|
'exercises', 'uebungen', 'stats', 'statistik',
|
||||||
'history', 'verlauf', 'check-in', 'erfassung',
|
'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 });
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_PAGES', urls });
|
||||||
// Also cache __data.json for client-side navigation
|
// 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 });
|
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_DATA', urls: dataUrls });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
import { page as appPage } from '$app/state';
|
import { page as appPage } from '$app/state';
|
||||||
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
|
||||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||||
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
import SessionCard from '$lib/components/fitness/SessionCard.svelte';
|
||||||
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
import { detectFitnessLang, fitnessSlugs, m } from '$lib/js/fitnessI18n';
|
||||||
|
import { getQueuedSessions } from '$lib/offline/fitnessQueue';
|
||||||
|
|
||||||
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
const lang = $derived(detectFitnessLang(appPage.url.pathname));
|
||||||
const t = $derived(m[lang]);
|
const t = $derived(m[lang]);
|
||||||
@@ -12,9 +15,68 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
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
|
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>} */
|
/** @type {Record<string, typeof sessions>} */
|
||||||
const grouped = $derived.by(() => {
|
const grouped = $derived.by(() => {
|
||||||
/** @type {Record<string, typeof sessions>} */
|
/** @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>
|
<h2 class="month-header">{month} — {monthSessions.length} {monthSessions.length !== 1 ? t.workouts_plural : t.workout_singular}</h2>
|
||||||
<div class="session-list">
|
<div class="session-list">
|
||||||
{#each monthSessions as session (session._id)}
|
{#each monthSessions as session (session._id)}
|
||||||
<SessionCard {session} />
|
<SessionCard {session} unsynced={queuedIds.has(session._id)} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ const PRECACHE_SHELLS = [
|
|||||||
'/recipes/offline-shell',
|
'/recipes/offline-shell',
|
||||||
'/glaube',
|
'/glaube',
|
||||||
'/faith',
|
'/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
|
// Assets to precache
|
||||||
|
|||||||
Reference in New Issue
Block a user