perf: parallelize DB queries across routes, clean up fitness UI

Parallelize sequential DB queries in 11 API routes and page loaders
using Promise.all — measurements/latest, stats/overview, goal streak,
exercises, sessions, task stats, monthly expenses, icon page, offline-db.

Move calorie tracking out of /fitness/measure (now under /fitness/nutrition
only). Remove fade-in entrance animations from nutrition page.

Progressive streak computation: scan 3 months first, widen only if needed.

Bump versions to 1.1.1 / 0.2.1.
This commit is contained in:
2026-04-06 13:12:02 +02:00
parent 09cd410eaa
commit 201847400e
18 changed files with 147 additions and 254 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "homepage", "name": "homepage",
"version": "1.1.0", "version": "1.1.1",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "bocken" name = "bocken"
version = "0.2.0" version = "0.2.1"
edition = "2021" edition = "2021"
[lib] [lib]

View File

@@ -1,7 +1,7 @@
{ {
"productName": "Bocken", "productName": "Bocken",
"identifier": "org.bocken.app", "identifier": "org.bocken.app",
"version": "0.2.0", "version": "0.2.1",
"build": { "build": {
"devUrl": "http://192.168.1.4:5173", "devUrl": "http://192.168.1.4:5173",
"frontendDist": "https://bocken.org" "frontendDist": "https://bocken.org"

View File

@@ -4,13 +4,9 @@ import { getUserFavorites, addFavoriteStatusToRecipes } from "$lib/server/favori
export const load: PageServerLoad = async ({ fetch, locals, params }) => { export const load: PageServerLoad = async ({ fetch, locals, params }) => {
const apiBase = `/api/${params.recipeLang}`; const apiBase = `/api/${params.recipeLang}`;
const res_season = await fetch(`${apiBase}/items/icon/` + params.icon); const [item_season, icons, userFavorites, session] = await Promise.all([
const res_icons = await fetch(`/api/rezepte/items/icon`); // Icons are shared across languages fetch(`${apiBase}/items/icon/` + params.icon).then(r => r.json()),
const icons = await res_icons.json(); fetch(`/api/rezepte/items/icon`).then(r => r.json()),
const item_season = await res_season.json();
// Get user favorites and session
const [userFavorites, session] = await Promise.all([
getUserFavorites(fetch, locals), getUserFavorites(fetch, locals),
locals.auth() locals.auth()
]); ]);

View File

@@ -6,15 +6,13 @@ import { dbConnect } from '$utils/db';
export const GET: RequestHandler = async () => { export const GET: RequestHandler = async () => {
await dbConnect(); await dbConnect();
// Fetch brief recipes (for lists/filtering) // Fetch brief and full recipes in parallel
// Include images for thumbnail caching during offline sync const [briefRecipes, fullRecipes] = await Promise.all([
const briefRecipes = await Recipe.find( Recipe.find(
{}, {},
'name short_name tags category icon description season dateModified images translations' 'name short_name tags category icon description season dateModified images translations'
).lean() as unknown as BriefRecipeType[]; ).lean() as unknown as Promise<BriefRecipeType[]>,
Recipe.find({})
// Fetch full recipes with populated base recipe references
const fullRecipes = await Recipe.find({})
.populate({ .populate({
path: 'ingredients.baseRecipeRef', path: 'ingredients.baseRecipeRef',
select: 'short_name name ingredients translations', select: 'short_name name ingredients translations',
@@ -39,7 +37,8 @@ export const GET: RequestHandler = async () => {
} }
} }
}) })
.lean() as unknown as RecipeModelType[]; .lean() as unknown as Promise<RecipeModelType[]>
]);
// Map populated refs to resolvedRecipe field (same as individual item endpoint) // Map populated refs to resolvedRecipe field (same as individual item endpoint)
function mapBaseRecipeRefs(items: any[]): any[] { function mapBaseRecipeRefs(items: any[]): any[] {

View File

@@ -20,20 +20,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const startDate = new Date(); const startDate = new Date();
startDate.setMonth(startDate.getMonth() - monthsBack); startDate.setMonth(startDate.getMonth() - monthsBack);
const totalPayments = await Payment.countDocuments(); const dateRange = { $gte: startDate, $lte: endDate };
const paymentsInRange = await Payment.countDocuments({ const [totalPayments, paymentsInRange, expensePayments] = await Promise.all([
date: { Payment.countDocuments(),
$gte: startDate, Payment.countDocuments({ date: dateRange }),
$lte: endDate Payment.countDocuments({ date: dateRange, category: { $ne: 'settlement' } })
} ]);
});
const expensePayments = await Payment.countDocuments({
date: {
$gte: startDate,
$lte: endDate
},
category: { $ne: 'settlement' }
});
// Aggregate payments by month and category // Aggregate payments by month and category
const pipeline = [ const pipeline = [

View File

@@ -46,12 +46,11 @@ export const GET: RequestHandler = async ({ url, locals }) => {
exerciseQuery = exerciseQuery.sort({ name: 1 }); exerciseQuery = exerciseQuery.sort({ name: 1 });
} }
const exercises = await exerciseQuery const [exercises, total] = await Promise.all([
.limit(limit) exerciseQuery.limit(limit).skip(offset)
.skip(offset) .select('exerciseId name gifUrl bodyPart equipment target difficulty'),
.select('exerciseId name gifUrl bodyPart equipment target difficulty'); Exercise.countDocuments(query)
]);
const total = await Exercise.countDocuments(query);
return json({ exercises, total, limit, offset }); return json({ exercises, total, limit, offset });
} catch (error) { } catch (error) {

View File

@@ -73,62 +73,48 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
}; };
async function computeStreak(username: string, weeklyGoal: number): Promise<number> { async function computeStreak(username: string, weeklyGoal: number): Promise<number> {
// Get weekly workout counts going back up to 2 years // Get weekly workout counts — only scan back enough to find the streak break.
const cutoff = new Date(); // Start with 13 weeks; if the streak fills the entire window, widen the search.
cutoff.setFullYear(cutoff.getFullYear() - 2); const now = new Date();
for (let months = 3; months <= 24; months += 6) {
const cutoff = new Date(now);
cutoff.setMonth(cutoff.getMonth() - months);
const weeklyAgg = await WorkoutSession.aggregate([ const weeklyAgg = await WorkoutSession.aggregate([
{ { $match: { createdBy: username, startTime: { $gte: cutoff } } },
$match: { { $group: { _id: { year: { $isoWeekYear: '$startTime' }, week: { $isoWeek: '$startTime' } }, count: { $sum: 1 } } },
createdBy: username, { $sort: { '_id.year': -1, '_id.week': -1 } }
startTime: { $gte: cutoff }
}
},
{
$group: {
_id: {
year: { $isoWeekYear: '$startTime' },
week: { $isoWeek: '$startTime' }
},
count: { $sum: 1 }
}
},
{
$sort: { '_id.year': -1, '_id.week': -1 }
}
]); ]);
// Build a set of weeks that met the goal
const metGoal = new Set<string>(); const metGoal = new Set<string>();
for (const item of weeklyAgg) { for (const item of weeklyAgg) {
if (item.count >= weeklyGoal) { if (item.count >= weeklyGoal) metGoal.add(`${item._id.year}-${item._id.week}`);
metGoal.add(`${item._id.year}-${item._id.week}`);
}
} }
// Walk backwards week-by-week counting consecutive weeks that met the goal.
// Current (incomplete) week counts if it already meets the goal, otherwise skip it.
const now = new Date();
let streak = 0; let streak = 0;
const currentKey = isoWeekKey(now); const currentKey = isoWeekKey(now);
const currentWeekMet = metGoal.has(currentKey); if (metGoal.has(currentKey)) streak = 1;
// If current week already met: count it, then check previous weeks. const maxWeeks = Math.ceil(months * 4.35);
// If not: start checking from last week (current week still in progress). let foundBreak = false;
if (currentWeekMet) streak = 1; for (let i = 1; i <= maxWeeks; i++) {
for (let i = 1; i <= 104; i++) {
const weekDate = new Date(now); const weekDate = new Date(now);
weekDate.setDate(weekDate.getDate() - i * 7); weekDate.setDate(weekDate.getDate() - i * 7);
if (metGoal.has(isoWeekKey(weekDate))) { if (metGoal.has(isoWeekKey(weekDate))) {
streak++; streak++;
} else { } else {
foundBreak = true;
break; break;
} }
} }
return streak; // If we found where the streak broke, we're done
if (foundBreak || streak < maxWeeks) return streak;
// Otherwise widen the window and try again
}
return 104; // Max 2-year streak
} }
function isoWeekKey(date: Date): string { function isoWeekKey(date: Date): string {

View File

@@ -22,13 +22,10 @@ export const GET: RequestHandler = async ({ url, locals }) => {
query.date = dateFilter; query.date = dateFilter;
} }
const measurements = await BodyMeasurement.find(query) const [measurements, total] = await Promise.all([
.sort({ date: -1 }) BodyMeasurement.find(query).sort({ date: -1 }).skip(offset).limit(limit).lean(),
.skip(offset) BodyMeasurement.countDocuments(query)
.limit(limit) ]);
.lean();
const total = await BodyMeasurement.countDocuments(query);
return json({ measurements, total, limit, offset }); return json({ measurements, total, limit, offset });
}; };

View File

@@ -8,38 +8,16 @@ export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals); const user = await requireAuth(locals);
await dbConnect(); await dbConnect();
// Get latest measurement that has each field // Get latest measurement that has each field (parallel)
const latestWithWeight = await BodyMeasurement.findOne({ const base = { createdBy: user.nickname };
createdBy: user.nickname, const [latestWithWeight, latestWithBodyFat, latestWithMeasurements] = await Promise.all([
weight: { $exists: true, $ne: null } BodyMeasurement.findOne({ ...base, weight: { $exists: true, $ne: null } })
}) .sort({ date: -1 }).select('weight date').lean(),
.sort({ date: -1 }) BodyMeasurement.findOne({ ...base, bodyFatPercent: { $exists: true, $ne: null } })
.select('weight date') .sort({ date: -1 }).select('bodyFatPercent date').lean(),
.lean(); BodyMeasurement.findOne({ ...base, measurements: { $exists: true, $ne: null } })
.sort({ date: -1 }).select('measurements date').lean(),
const latestWithBodyFat = await BodyMeasurement.findOne({ ]);
createdBy: user.nickname,
bodyFatPercent: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('bodyFatPercent date')
.lean();
const latestWithCalories = await BodyMeasurement.findOne({
createdBy: user.nickname,
caloricIntake: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('caloricIntake date')
.lean();
const latestWithMeasurements = await BodyMeasurement.findOne({
createdBy: user.nickname,
measurements: { $exists: true, $ne: null }
})
.sort({ date: -1 })
.select('measurements date')
.lean();
return json({ return json({
weight: latestWithWeight weight: latestWithWeight
@@ -48,9 +26,6 @@ export const GET: RequestHandler = async ({ locals }) => {
bodyFatPercent: latestWithBodyFat bodyFatPercent: latestWithBodyFat
? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date } ? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date }
: null, : null,
caloricIntake: latestWithCalories
? { value: latestWithCalories.caloricIntake, date: latestWithCalories.date }
: null,
measurements: latestWithMeasurements measurements: latestWithMeasurements
? { ? {
value: latestWithMeasurements.measurements, value: latestWithMeasurements.measurements,

View File

@@ -28,13 +28,12 @@ export const GET: RequestHandler = async ({ url, locals }) => {
const limit = parseInt(url.searchParams.get('limit') || '20'); const limit = parseInt(url.searchParams.get('limit') || '20');
const offset = parseInt(url.searchParams.get('offset') || '0'); const offset = parseInt(url.searchParams.get('offset') || '0');
const sessions = await WorkoutSession.find({ createdBy: session.user.nickname }) const query = { createdBy: session.user.nickname };
.select('-exercises.gpsTrack -gpsTrack') const [sessions, total] = await Promise.all([
.sort({ startTime: -1 }) WorkoutSession.find(query).select('-exercises.gpsTrack -gpsTrack')
.limit(limit) .sort({ startTime: -1 }).limit(limit).skip(offset),
.skip(offset); WorkoutSession.countDocuments(query)
]);
const total = await WorkoutSession.countDocuments({ createdBy: session.user.nickname });
return json({ sessions, total, limit, offset }); return json({ sessions, total, limit, offset });
} catch (error) { } catch (error) {

View File

@@ -16,31 +16,13 @@ export const GET: RequestHandler = async ({ locals }) => {
const tenWeeksAgo = new Date(); const tenWeeksAgo = new Date();
tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70); tenWeeksAgo.setDate(tenWeeksAgo.getDate() - 70);
const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname }); const [totalWorkouts, weeklyAgg, goal, latestMeasurement] = await Promise.all([
WorkoutSession.countDocuments({ createdBy: user.nickname }),
const weeklyAgg = await WorkoutSession.aggregate([ WorkoutSession.aggregate([
{ { $match: { createdBy: user.nickname, startTime: { $gte: tenWeeksAgo } } },
$match: { { $group: { _id: { year: { $isoWeekYear: '$startTime' }, week: { $isoWeek: '$startTime' } }, count: { $sum: 1 } } },
createdBy: user.nickname, { $sort: { '_id.year': 1, '_id.week': 1 } }
startTime: { $gte: tenWeeksAgo } ]),
}
},
{
$group: {
_id: {
year: { $isoWeekYear: '$startTime' },
week: { $isoWeek: '$startTime' }
},
count: { $sum: 1 }
}
},
{
$sort: { '_id.year': 1, '_id.week': 1 }
}
]);
// Fetch user demographics for kcal estimation
const [goal, latestMeasurement] = await Promise.all([
FitnessGoal.findOne({ username: user.nickname }).lean() as any, FitnessGoal.findOne({ username: user.nickname }).lean() as any,
BodyMeasurement.findOne( BodyMeasurement.findOne(
{ createdBy: user.nickname, weight: { $ne: null } }, { createdBy: user.nickname, weight: { $ne: null } },
@@ -57,10 +39,19 @@ export const GET: RequestHandler = async ({ locals }) => {
// Lifetime totals: tonnage lifted + cardio km + kcal estimate // Lifetime totals: tonnage lifted + cardio km + kcal estimate
// Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions // Use stored kcalEstimate when available; fall back to on-the-fly for legacy sessions
const allSessions = await WorkoutSession.find( const DISPLAY_LIMIT = 30;
const SMA_LOOKBACK = 6; // w - 1 where w = 7 max
const [allSessions, weightMeasurements] = await Promise.all([
WorkoutSession.find(
{ createdBy: user.nickname }, { createdBy: user.nickname },
{ 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 } { 'exercises.exerciseId': 1, 'exercises.sets': 1, 'exercises.totalDistance': 1, kcalEstimate: 1 }
).lean(); ).lean(),
BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
).sort({ date: -1 }).limit(DISPLAY_LIMIT + SMA_LOOKBACK).lean()
]);
weightMeasurements.reverse(); // back to chronological order
let totalTonnage = 0; let totalTonnage = 0;
let totalCardioKm = 0; let totalCardioKm = 0;
@@ -146,18 +137,6 @@ export const GET: RequestHandler = async ({ locals }) => {
upper: totalKcal + combinedMargin, upper: totalKcal + combinedMargin,
}; };
// Fetch extra measurements beyond the display limit to fill the SMA lookback window
const DISPLAY_LIMIT = 30;
const SMA_LOOKBACK = 6; // w - 1 where w = 7 max
const weightMeasurements = await BodyMeasurement.find(
{ createdBy: user.nickname, weight: { $ne: null } },
{ date: 1, weight: 1, _id: 0 }
)
.sort({ date: -1 })
.limit(DISPLAY_LIMIT + SMA_LOOKBACK)
.lean();
weightMeasurements.reverse(); // back to chronological order
// Split into lookback-only (not displayed) and display portions // Split into lookback-only (not displayed) and display portions
const displayStart = Math.max(0, weightMeasurements.length - DISPLAY_LIMIT); const displayStart = Math.max(0, weightMeasurements.length - DISPLAY_LIMIT);

View File

@@ -9,25 +9,19 @@ export const GET: RequestHandler = async ({ locals }) => {
await dbConnect(); await dbConnect();
// Completions per user const [userStats, userStickers, recentCompletions] = await Promise.all([
const userStats = await TaskCompletion.aggregate([ TaskCompletion.aggregate([
{ $group: { _id: '$completedBy', count: { $sum: 1 } } }, { $group: { _id: '$completedBy', count: { $sum: 1 } } },
{ $sort: { count: -1 } } { $sort: { count: -1 } }
]); ]),
TaskCompletion.aggregate([
// Stickers per user
const userStickers = await TaskCompletion.aggregate([
{ $match: { stickerId: { $exists: true, $ne: null } } }, { $match: { stickerId: { $exists: true, $ne: null } } },
{ $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } }, { $group: { _id: { user: '$completedBy', sticker: '$stickerId' }, count: { $sum: 1 } } },
{ $sort: { '_id.user': 1, count: -1 } } { $sort: { '_id.user': 1, count: -1 } }
]),
TaskCompletion.find().sort({ completedAt: -1 }).limit(500).lean()
]); ]);
// Recent completions (enough for ~3 months of calendar)
const recentCompletions = await TaskCompletion.find()
.sort({ completedAt: -1 })
.limit(500)
.lean();
return json({ userStats, userStickers, recentCompletions }); return json({ userStats, userStickers, recentCompletions });
}; };

View File

@@ -99,7 +99,6 @@
const parts = []; const parts = [];
if (m.weight != null) parts.push(`${m.weight} kg`); if (m.weight != null) parts.push(`${m.weight} kg`);
if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`); if (m.bodyFatPercent != null) parts.push(`${m.bodyFatPercent}% bf`);
if (m.caloricIntake != null) parts.push(`${m.caloricIntake} kcal`);
return parts.join(' · ') || t('body_measurements_only', lang); return parts.join(' · ') || t('body_measurements_only', lang);
} }
</script> </script>
@@ -151,10 +150,6 @@
<span class="stat-label">{t('body_fat', lang)}</span> <span class="stat-label">{t('body_fat', lang)}</span>
<span class="stat-value">{latest.bodyFatPercent?.value ?? '—'}<small>%</small></span> <span class="stat-value">{latest.bodyFatPercent?.value ?? '—'}<small>%</small></span>
</div> </div>
<div class="stat-card">
<span class="stat-label">{t('calories', lang)}</span>
<span class="stat-value">{latest.caloricIntake?.value ?? '—'} <small>kcal</small></span>
</div>
</div> </div>
</section> </section>
@@ -303,7 +298,7 @@
/* Latest */ /* Latest */
.stat-grid { .stat-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 0.6rem; gap: 0.6rem;
} }
.stat-card { .stat-card {

View File

@@ -13,7 +13,6 @@
let formDate = $state(new Date().toISOString().slice(0, 10)); let formDate = $state(new Date().toISOString().slice(0, 10));
let formWeight = $state(''); let formWeight = $state('');
let formBodyFat = $state(''); let formBodyFat = $state('');
let formCalories = $state('');
let formNeck = $state(''); let formNeck = $state('');
let formShoulders = $state(''); let formShoulders = $state('');
let formChest = $state(''); let formChest = $state('');
@@ -35,9 +34,6 @@
else body.weight = null; else body.weight = null;
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat); if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
else body.bodyFatPercent = null; else body.bodyFatPercent = null;
if (formCalories) body.caloricIntake = Number(formCalories);
else body.caloricIntake = null;
/** @type {any} */ /** @type {any} */
const m = {}; const m = {};
if (formNeck) m.neck = Number(formNeck); if (formNeck) m.neck = Number(formNeck);
@@ -102,10 +98,6 @@
<label for="m-bf">{t('body_fat_pct', lang)}</label> <label for="m-bf">{t('body_fat_pct', lang)}</label>
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" /> <input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" />
</div> </div>
<div class="form-group">
<label for="m-cal">{t('calories_kcal', lang)}</label>
<input id="m-cal" type="number" bind:value={formCalories} placeholder="--" />
</div>
</div> </div>
<h3>{t('body_parts_cm', lang)}</h3> <h3>{t('body_parts_cm', lang)}</h3>

View File

@@ -19,8 +19,6 @@
let formDate = $state(m ? new Date(m.date).toISOString().slice(0, 10) : ''); let formDate = $state(m ? new Date(m.date).toISOString().slice(0, 10) : '');
let formWeight = $state(m?.weight != null ? String(m.weight) : ''); let formWeight = $state(m?.weight != null ? String(m.weight) : '');
let formBodyFat = $state(m?.bodyFatPercent != null ? String(m.bodyFatPercent) : ''); let formBodyFat = $state(m?.bodyFatPercent != null ? String(m.bodyFatPercent) : '');
let formCalories = $state(m?.caloricIntake != null ? String(m.caloricIntake) : '');
const bp = m?.measurements ?? {}; const bp = m?.measurements ?? {};
let formNeck = $state(bp.neck != null ? String(bp.neck) : ''); let formNeck = $state(bp.neck != null ? String(bp.neck) : '');
let formShoulders = $state(bp.shoulders != null ? String(bp.shoulders) : ''); let formShoulders = $state(bp.shoulders != null ? String(bp.shoulders) : '');
@@ -43,9 +41,6 @@
else body.weight = null; else body.weight = null;
if (formBodyFat) body.bodyFatPercent = Number(formBodyFat); if (formBodyFat) body.bodyFatPercent = Number(formBodyFat);
else body.bodyFatPercent = null; else body.bodyFatPercent = null;
if (formCalories) body.caloricIntake = Number(formCalories);
else body.caloricIntake = null;
/** @type {any} */ /** @type {any} */
const ms = {}; const ms = {};
if (formNeck) ms.neck = Number(formNeck); if (formNeck) ms.neck = Number(formNeck);
@@ -128,10 +123,6 @@
<label for="m-bf">{t('body_fat_pct', lang)}</label> <label for="m-bf">{t('body_fat_pct', lang)}</label>
<input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" /> <input id="m-bf" type="number" step="0.1" bind:value={formBodyFat} placeholder="--" />
</div> </div>
<div class="form-group">
<label for="m-cal">{t('calories_kcal', lang)}</label>
<input id="m-cal" type="number" bind:value={formCalories} placeholder="--" />
</div>
</div> </div>
<h3>{t('body_parts_cm', lang)}</h3> <h3>{t('body_parts_cm', lang)}</h3>

View File

@@ -8,28 +8,32 @@ import mongoose from 'mongoose';
export const load: PageServerLoad = async ({ fetch, url, locals }) => { export const load: PageServerLoad = async ({ fetch, url, locals }) => {
const dateParam = url.searchParams.get('date') || new Date().toISOString().slice(0, 10); const dateParam = url.searchParams.get('date') || new Date().toISOString().slice(0, 10);
const [foodRes, goalRes, weightRes] = await Promise.all([ // Run all independent work in parallel: 3 API calls + workout kcal DB query
fetch(`/api/fitness/food-log?date=${dateParam}`), const dayStart = new Date(dateParam + 'T00:00:00.000Z');
fetch('/api/fitness/goal'), const dayEnd = new Date(dateParam + 'T23:59:59.999Z');
fetch('/api/fitness/measurements/latest')
]);
// Fetch today's workout kcal burned const exercisePromise = (async () => {
let exerciseKcal = 0;
try { try {
const user = await requireAuth(locals); const user = await requireAuth(locals);
await dbConnect(); await dbConnect();
const dayStart = new Date(dateParam + 'T00:00:00.000Z');
const dayEnd = new Date(dateParam + 'T23:59:59.999Z');
const sessions = await WorkoutSession.find({ const sessions = await WorkoutSession.find({
createdBy: user.nickname, createdBy: user.nickname,
startTime: { $gte: dayStart, $lte: dayEnd } startTime: { $gte: dayStart, $lte: dayEnd }
}).select('kcalEstimate').lean(); }).select('kcalEstimate').lean();
let kcal = 0;
for (const s of sessions) { for (const s of sessions) {
if (s.kcalEstimate?.kcal) exerciseKcal += s.kcalEstimate.kcal; if (s.kcalEstimate?.kcal) kcal += s.kcalEstimate.kcal;
} }
} catch {} return kcal;
} catch { return 0; }
})();
const [foodRes, goalRes, weightRes, exerciseKcal] = await Promise.all([
fetch(`/api/fitness/food-log?date=${dateParam}`),
fetch('/api/fitness/goal'),
fetch('/api/fitness/measurements/latest'),
exercisePromise
]);
const foodLog = foodRes.ok ? await foodRes.json() : { entries: [] }; const foodLog = foodRes.ok ? await foodRes.json() : { entries: [] };

View File

@@ -731,7 +731,7 @@
{@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)} {@const mealCal = mealEntries.reduce((s, e) => s + entryCalories(e), 0)}
{@const meta = mealMeta[meal]} {@const meta = mealMeta[meal]}
{@const MealSectionIcon = meta.icon} {@const MealSectionIcon = meta.icon}
<div class="meal-section" style="--meal-color: {meta.color}; animation-delay: {mi * 60}ms"> <div class="meal-section" style="--meal-color: {meta.color}">
<div class="meal-header"> <div class="meal-header">
<div class="meal-title"> <div class="meal-title">
<div class="meal-icon"> <div class="meal-icon">
@@ -862,11 +862,6 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* ── Entrance animations ── */
@keyframes fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Date Navigator ── */ /* ── Date Navigator ── */
.date-nav { .date-nav {
@@ -874,7 +869,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.25rem; gap: 0.25rem;
animation: fade-up 0.3s ease both;
} }
.date-btn { .date-btn {
background: none; background: none;
@@ -927,8 +922,8 @@
padding: 1.25rem; padding: 1.25rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
position: relative; position: relative;
animation: fade-up 0.35s ease both;
animation-delay: 50ms;
} }
.daily-summary::before { .daily-summary::before {
content: ''; content: '';
@@ -1148,7 +1143,7 @@
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 1rem; gap: 1rem;
animation: fade-up 0.25s ease both;
} }
.micro-section h4 { .micro-section h4 {
margin: 0 0 0.4rem; margin: 0 0 0.4rem;
@@ -1205,7 +1200,7 @@
background: var(--color-surface); background: var(--color-surface);
border-radius: 12px; border-radius: 12px;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
animation: fade-up 0.35s ease both;
} }
.no-goal-icon { .no-goal-icon {
display: flex; display: flex;
@@ -1247,7 +1242,7 @@
border-radius: 12px; border-radius: 12px;
padding: 1.25rem; padding: 1.25rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
animation: fade-up 0.25s ease both;
} }
.preset-section { .preset-section {
margin-bottom: 1rem; margin-bottom: 1rem;
@@ -1370,7 +1365,7 @@
/* ── Meal Sections ── */ /* ── Meal Sections ── */
.meal-section { .meal-section {
animation: fade-up 0.35s ease both;
} }
.meal-header { .meal-header {
display: flex; display: flex;
@@ -1568,7 +1563,7 @@
border-radius: 10px; border-radius: 10px;
padding: 0.85rem; padding: 0.85rem;
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
animation: fade-up 0.2s ease both;
} }
/* Search/food selection handled by FoodSearch component */ /* Search/food selection handled by FoodSearch component */