perf: stream secondary panels on fitness stats page
Four panel fetches (muscle heatmap, nutrition stats, own periods, shared
periods) are now returned as unawaited promises from load() and resolved
into $state-backed locals on the client via $effect. The load function
keeps awaiting only stats/goal/latest since the main charts, goal
header, and body-part cards depend on them immediately.
Rationale for the $state-backed resolution rather than {#await}: the
user wants the nutrition card shells and the muscle heatmap container
to render their skeleton shape on first paint and only fill in the
numbers once the data arrives. Defaults (`{}`, empty heatmap, `[]`)
match the previous error-fallback shapes so the existing `!= null`
checks inside each card cascade naturally to the "—" branches while
the promise is in flight. No template restructuring beyond dropping
the outer `{#if ns}` (which already hid everything when null).
stats (overview) is intentionally still awaited: it feeds ~30 $derived
chart expressions and wrapping it would mean recreating every Chart.js
instance after the promise settles.
This commit is contained in:
@@ -9,7 +9,7 @@ Order = impact. Font items + app.html preload intentionally skipped.
|
||||
- [x] 3. Recipe API endpoints — drop `JSON.parse(JSON.stringify(...))` double-serialize (9 endpoints). Client-side shuffle / cache headers deferred (would require rethinking hero preload + hydration)
|
||||
- [x] 4. Favorites page — drop unnecessary `all_brief` fetch (verified Search uses `favoritesOnly` so `allRecipes` was redundant)
|
||||
- [x] 5. Replace redundant `locals.auth()` with `locals.session` across all routes (68 files, 107 sites — loaders, actions, API endpoints)
|
||||
- [ ] 6. Stream fitness stats loader — return promises for slow panels
|
||||
- [x] 6. Stream fitness stats loader — muscleHeatmap, nutritionStats, periods, sharedPeriods now stream via `{#await}`. `stats` still awaited (too many chart $deriveds depend on it)
|
||||
- [ ] 7. Overview endpoint — add `.select(...)` projection, cap timeseries window
|
||||
- [ ] 8. Calendar payload trim — drop `name` from `yearDays`, pre-filter `feastDots` server-side
|
||||
- [ ] 9. History sessions endpoint — slim exercise payload for list view
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homepage",
|
||||
"version": "1.46.18",
|
||||
"version": "1.46.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -2,21 +2,33 @@ import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch, locals }) => {
|
||||
const session = locals.session ?? await locals.auth();
|
||||
const [res, goalRes, heatmapRes, nutritionRes, latestRes, periodRes, sharedRes] = await Promise.all([
|
||||
|
||||
// stats / goal / latest block the shell because the main charts, goal header,
|
||||
// and body-part cards all depend on them. The heavier panels below stream.
|
||||
const [res, goalRes, latestRes] = await Promise.all([
|
||||
fetch('/api/fitness/stats/overview'),
|
||||
fetch('/api/fitness/goal'),
|
||||
fetch('/api/fitness/stats/muscle-heatmap?weeks=8'),
|
||||
fetch('/api/fitness/stats/nutrition'),
|
||||
fetch('/api/fitness/measurements/latest'),
|
||||
fetch('/api/fitness/period').catch(() => null),
|
||||
fetch('/api/fitness/period/shared').catch(() => null)
|
||||
fetch('/api/fitness/measurements/latest')
|
||||
]);
|
||||
const stats = await res.json();
|
||||
const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 };
|
||||
const muscleHeatmap = heatmapRes.ok ? await heatmapRes.json() : { weeks: [], totals: {}, muscleGroups: [] };
|
||||
const nutritionStats = nutritionRes.ok ? await nutritionRes.json() : null;
|
||||
const latest = latestRes.ok ? await latestRes.json() : {};
|
||||
const periods = periodRes?.ok ? (await periodRes.json()).entries : [];
|
||||
const sharedPeriods = sharedRes?.ok ? (await sharedRes.json()).shared : [];
|
||||
return { session, stats, goal, muscleHeatmap, nutritionStats, latest, periods, sharedPeriods };
|
||||
|
||||
// Streamed — resolved into $state-backed locals on the client so the card
|
||||
// shells render immediately and fill in once the value arrives. Error
|
||||
// fallbacks keep the previous empty shapes.
|
||||
const muscleHeatmap = fetch('/api/fitness/stats/muscle-heatmap?weeks=8')
|
||||
.then(r => r.ok ? r.json() : { weeks: [], totals: {}, muscleGroups: [] })
|
||||
.catch(() => ({ weeks: [], totals: {}, muscleGroups: [] }));
|
||||
const nutritionStats = fetch('/api/fitness/stats/nutrition')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.catch(() => null);
|
||||
const periods = fetch('/api/fitness/period')
|
||||
.then(r => r.ok ? r.json().then(j => j.entries) : [])
|
||||
.catch(() => []);
|
||||
const sharedPeriods = fetch('/api/fitness/period/shared')
|
||||
.then(r => r.ok ? r.json().then(j => j.shared) : [])
|
||||
.catch(() => []);
|
||||
|
||||
return { session, stats, goal, latest, muscleHeatmap, nutritionStats, periods, sharedPeriods };
|
||||
};
|
||||
|
||||
@@ -120,7 +120,22 @@
|
||||
}]
|
||||
});
|
||||
|
||||
const ns = $derived(data.nutritionStats);
|
||||
// Streamed panels: render empty shells on SSR/initial hydrate, then fill
|
||||
// in once the server-sent promise resolves. Defaults match the previous
|
||||
// error-fallback shapes so the existing `!= null` checks cascade to the
|
||||
// "—" branches while the data is in flight.
|
||||
/** @type {any} */
|
||||
let ns = $state({});
|
||||
/** @type {{ weeks: any[]; totals: any; muscleGroups: any[] }} */
|
||||
let muscleHeatmapData = $state({ weeks: [], totals: {}, muscleGroups: [] });
|
||||
/** @type {any[]} */
|
||||
let periodsData = $state([]);
|
||||
/** @type {any[]} */
|
||||
let sharedPeriodsData = $state([]);
|
||||
$effect(() => { Promise.resolve(data.nutritionStats).then(v => { ns = v ?? {}; }); });
|
||||
$effect(() => { Promise.resolve(data.muscleHeatmap).then(v => { muscleHeatmapData = v ?? { weeks: [], totals: {}, muscleGroups: [] }; }); });
|
||||
$effect(() => { Promise.resolve(data.periods).then(v => { periodsData = v ?? []; }); });
|
||||
$effect(() => { Promise.resolve(data.sharedPeriods).then(v => { sharedPeriodsData = v ?? []; }); });
|
||||
|
||||
const hasSma = $derived(stats.weightChart?.sma?.some((/** @type {any} */ v) => v !== null));
|
||||
|
||||
@@ -362,7 +377,6 @@
|
||||
{/if}
|
||||
|
||||
<div class="muscle-nutrition-layout">
|
||||
{#if ns}
|
||||
<div class="lifetime-card protein-card">
|
||||
<div class="card-icon"><Beef size={24} /></div>
|
||||
{#if ns.avgProteinPerKg != null}
|
||||
@@ -483,11 +497,10 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="section-block muscle-heatmap-block">
|
||||
<h2 class="section-title">{t('muscle_balance', lang)}</h2>
|
||||
<MuscleHeatmap data={data.muscleHeatmap} />
|
||||
<MuscleHeatmap data={muscleHeatmapData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -540,10 +553,10 @@
|
||||
{/if}
|
||||
|
||||
{#if data.goal?.sex === 'female'}
|
||||
<PeriodTracker periods={data.periods ?? []} {lang} mode="projection" />
|
||||
<PeriodTracker periods={periodsData} {lang} mode="projection" />
|
||||
{/if}
|
||||
|
||||
{#each data.sharedPeriods ?? [] as shared (shared.owner)}
|
||||
{#each sharedPeriodsData as shared (shared.owner)}
|
||||
<PeriodTracker periods={shared.entries} {lang} readOnly ownerName={shared.owner} mode="projection" />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user