From 0b86f72d92cab0a619edc10606e6c3ae40cc4a1e Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 11 Apr 2026 13:11:29 +0200 Subject: [PATCH] feat: project exercise kcal from next scheduled template When no workout is logged for the day, look up the next template in the schedule rotation and show the kcal from its most recent session as a projection. Tappable toggle includes/excludes it from the calorie goal, ring, and macro bars for meal planning. --- package.json | 2 +- .../[[date=fitnessDate]]/+page.server.ts | 47 ++++++++++++++-- .../[[date=fitnessDate]]/+page.svelte | 53 +++++++++++++++++-- 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index 2b294b7..e50fc96 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "homepage", - "version": "1.23.10", + "version": "1.24.0", "private": true, "type": "module", "scripts": { diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.server.ts b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.server.ts index 19d1a6c..a20b6ee 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.server.ts +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.server.ts @@ -2,6 +2,8 @@ import type { PageServerLoad } from './$types'; import { requireAuth } from '$lib/server/middleware/auth'; import { dbConnect } from '$utils/db'; import { WorkoutSession } from '$models/WorkoutSession'; +import { WorkoutSchedule } from '$models/WorkoutSchedule'; +import { WorkoutTemplate } from '$models/WorkoutTemplate'; import { Recipe } from '$models/Recipe'; import { RoundOffCache } from '$models/RoundOffCache'; import mongoose from 'mongoose'; @@ -25,8 +27,43 @@ export const load: PageServerLoad = async ({ fetch, params, locals }) => { for (const s of sessions) { if (s.kcalEstimate?.kcal) kcal += s.kcalEstimate.kcal; } - return kcal; - } catch { return 0; } + + // If no exercise done today, project kcal from the next scheduled template + let projected = null; + if (kcal === 0) { + const schedule = await WorkoutSchedule.findOne({ userId: user.nickname }); + if (schedule?.templateOrder?.length) { + const lastScheduled = await WorkoutSession.findOne({ + createdBy: user.nickname, + templateId: { $in: schedule.templateOrder } + }).sort({ startTime: -1 }); + + let nextId; + if (!lastScheduled?.templateId) { + nextId = schedule.templateOrder[0]; + } else { + const idx = schedule.templateOrder.indexOf(lastScheduled.templateId.toString()); + nextId = schedule.templateOrder[(idx === -1 ? 0 : idx + 1) % schedule.templateOrder.length]; + } + + const prevSession = await WorkoutSession.findOne({ + createdBy: user.nickname, + templateId: nextId, + 'kcalEstimate.kcal': { $gt: 0 } + }).sort({ startTime: -1 }).select('kcalEstimate templateName').lean(); + + if (prevSession?.kcalEstimate?.kcal) { + const tmpl = await WorkoutTemplate.findById(nextId).select('name').lean(); + projected = { + kcal: Math.round(prevSession.kcalEstimate.kcal), + templateName: tmpl?.name || prevSession.templateName || '?', + }; + } + } + } + + return { kcal, projected }; + } catch { return { kcal: 0, projected: null }; } })(); const recentFrom = new Date(); @@ -34,7 +71,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals }) => { const recentFromStr = recentFrom.toISOString().slice(0, 10); const todayStr = new Date().toISOString().slice(0, 10); - const [foodRes, goalRes, weightRes, exerciseKcal, favRes, recentRes] = await Promise.all([ + const [foodRes, goalRes, weightRes, exerciseData, favRes, recentRes] = await Promise.all([ fetch(`/api/fitness/food-log?date=${dateParam}`), fetch('/api/fitness/goal'), fetch('/api/fitness/measurements/latest'), @@ -103,7 +140,8 @@ export const load: PageServerLoad = async ({ fetch, params, locals }) => { .slice(0, 10); const goal = goalRes.ok ? await goalRes.json() : {}; - const roundedExerciseKcal = Math.round(exerciseKcal); + const roundedExerciseKcal = Math.round(exerciseData.kcal); + const projectedExercise = exerciseData.projected; // Compute initial showRoundOff server-side to avoid flicker const today = new Date().toISOString().slice(0, 10); @@ -123,6 +161,7 @@ export const load: PageServerLoad = async ({ fetch, params, locals }) => { goal, latestWeight: weightRes.ok ? await weightRes.json() : {}, exerciseKcal: roundedExerciseKcal, + projectedExercise, recipeImages, favorites: favData.favorites ?? [], recentFoods, diff --git a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte index 3f62fab..e5898e8 100644 --- a/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte +++ b/src/routes/fitness/[nutrition=fitnessNutrition]/[[date=fitnessDate]]/+page.svelte @@ -52,6 +52,7 @@ entries = data.foodLog?.entries ?? []; recipeImages = data.recipeImages ?? {}; exerciseKcal = Number(data.exerciseKcal) || 0; + projectedExercise = data.projectedExercise ?? null; currentDate = data.date; }); @@ -414,9 +415,14 @@ // --- Burned kcal --- // svelte-ignore state_referenced_locally let exerciseKcal = $state(Number(data.exerciseKcal) || 0); + // svelte-ignore state_referenced_locally + let projectedExercise = $state(data.projectedExercise ?? null); + let includeProjection = $state(false); + + const effectiveBurned = $derived(exerciseKcal || (includeProjection && projectedExercise ? projectedExercise.kcal : 0)); // Effective daily calorie goal including exercise burned calories - const effectiveCalorieGoal = $derived(goalCalories ? goalCalories + (exerciseKcal || 0) : null); + const effectiveCalorieGoal = $derived(goalCalories ? goalCalories + effectiveBurned : null); // Protein goal in grams (fixed by body weight, unaffected by exercise) const proteinGoalGrams = $derived.by(() => { @@ -1220,8 +1226,19 @@
- {fmtCal(exerciseKcal)} - {isEn ? 'BURNED' : 'VERBRANNT'} + {#if exerciseKcal > 0} + {fmtCal(exerciseKcal)} + {isEn ? 'BURNED' : 'VERBRANNT'} + {:else if projectedExercise} + + {:else} + {fmtCal(0)} + {isEn ? 'BURNED' : 'VERBRANNT'} + {/if} +{fmtCal(Math.round(dailyTdee))} TDEE {#if showTdeeInfo} @@ -2028,6 +2045,36 @@ text-transform: uppercase; color: var(--color-text-secondary); } + .projection-toggle { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1rem; + background: none; + border: none; + padding: 0; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + } + .cal-stat-value.projected { + opacity: 0.45; + font-style: italic; + } + .cal-stat-value.projected.included { + opacity: 0.8; + color: var(--nord14); + } + .projected-label { + font-style: italic; + } + .projected-template { + font-size: 0.55rem; + color: var(--color-text-tertiary); + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } .burned-bmr { margin-top: 0.1rem; font-size: 0.6rem;