fitness: improve weight SMA with lookback and partial-window scaling
All checks were successful
CI / update (push) Successful in 3m54s

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.
This commit is contained in:
2026-04-02 22:24:24 +02:00
parent eda87a8231
commit cee20f6bb3

View File

@@ -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<string, number>();
@@ -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({