Files
homepage/src/routes/api/fitness/goal/+server.ts
Alexander Bocken 201847400e 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.
2026-04-06 13:12:29 +02:00

129 lines
5.2 KiB
TypeScript

import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { requireAuth } from '$lib/server/middleware/auth';
import { dbConnect } from '$utils/db';
import { FitnessGoal } from '$models/FitnessGoal';
import { WorkoutSession } from '$models/WorkoutSession';
export const GET: RequestHandler = async ({ locals }) => {
const user = await requireAuth(locals);
await dbConnect();
const goal = await FitnessGoal.findOne({ username: user.nickname }).lean() as any;
const weeklyWorkouts = goal?.weeklyWorkouts ?? null;
const nutritionGoals = {
activityLevel: goal?.activityLevel ?? 'light',
dailyCalories: goal?.dailyCalories ?? null,
proteinMode: goal?.proteinMode ?? null,
proteinTarget: goal?.proteinTarget ?? null,
fatPercent: goal?.fatPercent ?? null,
carbPercent: goal?.carbPercent ?? null,
};
// If no goal set, return early
if (weeklyWorkouts === null) {
return json({ weeklyWorkouts: null, streak: 0, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null, ...nutritionGoals });
}
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({ weeklyWorkouts, streak, sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null, ...nutritionGoals });
};
export const PUT: RequestHandler = async ({ request, locals }) => {
const user = await requireAuth(locals);
const body = await request.json();
const { weeklyWorkouts, sex, heightCm } = body;
if (typeof weeklyWorkouts !== 'number' || weeklyWorkouts < 1 || weeklyWorkouts > 14 || !Number.isInteger(weeklyWorkouts)) {
return json({ error: 'weeklyWorkouts must be an integer between 1 and 14' }, { status: 400 });
}
const update: Record<string, unknown> = { weeklyWorkouts };
if (sex === 'male' || sex === 'female') update.sex = sex;
if (typeof heightCm === 'number' && heightCm >= 100 && heightCm <= 250) update.heightCm = heightCm;
if (typeof body.birthYear === 'number' && body.birthYear >= 1900 && body.birthYear <= 2020) update.birthYear = body.birthYear;
const validActivity = ['sedentary', 'light', 'moderate', 'very_active'];
if (validActivity.includes(body.activityLevel)) update.activityLevel = body.activityLevel;
if (typeof body.dailyCalories === 'number' && body.dailyCalories >= 500 && body.dailyCalories <= 10000) update.dailyCalories = body.dailyCalories;
if (body.proteinMode === 'fixed' || body.proteinMode === 'per_kg') update.proteinMode = body.proteinMode;
if (typeof body.proteinTarget === 'number' && body.proteinTarget >= 0) update.proteinTarget = body.proteinTarget;
if (typeof body.fatPercent === 'number' && body.fatPercent >= 0 && body.fatPercent <= 100) update.fatPercent = body.fatPercent;
if (typeof body.carbPercent === 'number' && body.carbPercent >= 0 && body.carbPercent <= 100) update.carbPercent = body.carbPercent;
await dbConnect();
const goal = await FitnessGoal.findOneAndUpdate(
{ username: user.nickname },
update,
{ upsert: true, returnDocument: 'after' }
).lean() as any;
const streak = await computeStreak(user.nickname, weeklyWorkouts);
return json({
weeklyWorkouts, streak,
sex: goal?.sex ?? 'male', heightCm: goal?.heightCm ?? null, birthYear: goal?.birthYear ?? null,
activityLevel: goal?.activityLevel ?? 'light',
dailyCalories: goal?.dailyCalories ?? null,
proteinMode: goal?.proteinMode ?? null,
proteinTarget: goal?.proteinTarget ?? null,
fatPercent: goal?.fatPercent ?? null,
carbPercent: goal?.carbPercent ?? null,
});
};
async function computeStreak(username: string, weeklyGoal: number): Promise<number> {
// Get weekly workout counts — only scan back enough to find the streak break.
// Start with 13 weeks; if the streak fills the entire window, widen the search.
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([
{ $match: { createdBy: username, startTime: { $gte: cutoff } } },
{ $group: { _id: { year: { $isoWeekYear: '$startTime' }, week: { $isoWeek: '$startTime' } }, count: { $sum: 1 } } },
{ $sort: { '_id.year': -1, '_id.week': -1 } }
]);
const metGoal = new Set<string>();
for (const item of weeklyAgg) {
if (item.count >= weeklyGoal) metGoal.add(`${item._id.year}-${item._id.week}`);
}
let streak = 0;
const currentKey = isoWeekKey(now);
if (metGoal.has(currentKey)) streak = 1;
const maxWeeks = Math.ceil(months * 4.35);
let foundBreak = false;
for (let i = 1; i <= maxWeeks; i++) {
const weekDate = new Date(now);
weekDate.setDate(weekDate.getDate() - i * 7);
if (metGoal.has(isoWeekKey(weekDate))) {
streak++;
} else {
foundBreak = true;
break;
}
}
// 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 {
const d = new Date(date.getTime());
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7));
const year = d.getFullYear();
const week1 = new Date(year, 0, 4);
const week = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
return `${year}-${week}`;
}