From cee20f6bb3e418d5c1856997e8c8589ad4ce97c5 Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Thu, 2 Apr 2026 22:24:24 +0200 Subject: [PATCH] fitness: improve weight SMA with lookback and partial-window scaling Fetch up to 6 extra measurements beyond the display limit so the SMA window is fully populated from the first displayed point. For users with fewer total measurements, use a reduced window with Bessel's correction and sqrt(w/k) sigma scaling to reflect increased uncertainty. --- .../api/fitness/stats/overview/+server.ts | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/routes/api/fitness/stats/overview/+server.ts b/src/routes/api/fitness/stats/overview/+server.ts index 6f0495f..535acc5 100644 --- a/src/routes/api/fitness/stats/overview/+server.ts +++ b/src/routes/api/fitness/stats/overview/+server.ts @@ -120,13 +120,20 @@ export const GET: RequestHandler = async ({ locals }) => { 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(30) + .sort({ date: -1 }) + .limit(DISPLAY_LIMIT + SMA_LOOKBACK) .lean(); + weightMeasurements.reverse(); // back to chronological order + + // Split into lookback-only (not displayed) and display portions + const displayStart = Math.max(0, weightMeasurements.length - DISPLAY_LIMIT); // Build chart-ready workouts-per-week with filled gaps const weekMap = new Map(); @@ -161,38 +168,37 @@ export const GET: RequestHandler = async ({ locals }) => { upper: (number | null)[]; lower: (number | null)[]; } = { labels: [], dates: [], data: [], sma: [], upper: [], lower: [] }; - const weights: number[] = []; - for (const m of weightMeasurements) { - const d = new Date(m.date); + const allWeights: number[] = weightMeasurements.map(m => m.weight!); + for (let idx = displayStart; idx < weightMeasurements.length; idx++) { + const d = new Date(weightMeasurements[idx].date); weightChart.labels.push( d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) ); weightChart.dates.push(d.toISOString()); - weightChart.data.push(m.weight!); - weights.push(m.weight!); + weightChart.data.push(allWeights[idx]); } // Adaptive window: 7 if enough data, otherwise half the data (min 2) - const w = Math.min(7, Math.max(2, Math.floor(weights.length / 2))); - for (let i = 0; i < weights.length; i++) { - if (i < w - 1) { - weightChart.sma.push(null); - weightChart.upper.push(null); - weightChart.lower.push(null); - } else { - let sum = 0; - for (let j = i - w + 1; j <= i; j++) sum += weights[j]; - const mean = sum / w; + const w = Math.min(7, Math.max(2, Math.floor(allWeights.length / 2))); + for (let idx = displayStart; idx < allWeights.length; idx++) { + // Use full window when available, otherwise use all points so far + const k = Math.min(w, idx + 1); + let sum = 0; + for (let j = idx - k + 1; j <= idx; j++) sum += allWeights[j]; + const mean = sum / k; - let variance = 0; - for (let j = i - w + 1; j <= i; j++) variance += (weights[j] - mean) ** 2; - const std = Math.sqrt(variance / w); + let variance = 0; + for (let j = idx - k + 1; j <= idx; j++) variance += (allWeights[j] - mean) ** 2; + // Bessel's correction (k-1) for unbiased sample variance; + // scale by sqrt(w/k) so the band widens when k < w + const std = k > 1 + ? Math.sqrt(variance / (k - 1)) * Math.sqrt(w / k) + : Math.sqrt(variance) * Math.sqrt(w); - const round = (v: number) => Math.round(v * 100) / 100; - weightChart.sma.push(round(mean)); - weightChart.upper.push(round(mean + std)); - weightChart.lower.push(round(mean - std)); - } + const round = (v: number) => Math.round(v * 100) / 100; + weightChart.sma.push(round(mean)); + weightChart.upper.push(round(mean + std)); + weightChart.lower.push(round(mean - std)); } return json({