diff --git a/src/lib/js/fitnessI18n.ts b/src/lib/js/fitnessI18n.ts index 87a5ed4..a4a878e 100644 --- a/src/lib/js/fitnessI18n.ts +++ b/src/lib/js/fitnessI18n.ts @@ -216,6 +216,15 @@ const translations: Translations = { // WorkoutFab active_workout: { en: 'Active Workout', de: 'Aktives Training' }, + + // Streak / Goal + streak: { en: 'Streak', de: 'Serie' }, + streak_weeks: { en: 'Weeks', de: 'Wochen' }, + streak_week: { en: 'Week', de: 'Woche' }, + weekly_goal: { en: 'Weekly Goal', de: 'Wochenziel' }, + workouts_per_week_goal: { en: 'workouts / week', de: 'Trainings / Woche' }, + set_goal: { en: 'Set Goal', de: 'Ziel setzen' }, + goal_set: { en: 'Goal set', de: 'Ziel gesetzt' }, }; /** Get a translated string */ diff --git a/src/models/FitnessGoal.ts b/src/models/FitnessGoal.ts new file mode 100644 index 0000000..74bfa33 --- /dev/null +++ b/src/models/FitnessGoal.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; + +const FitnessGoalSchema = new mongoose.Schema( + { + username: { type: String, required: true, unique: true }, + weeklyWorkouts: { type: Number, required: true, default: 4, min: 1, max: 14 } + }, + { timestamps: true } +); + +interface IFitnessGoal { + username: string; + weeklyWorkouts: number; +} + +let _model: mongoose.Model; +try { _model = mongoose.model("FitnessGoal"); } catch { _model = mongoose.model("FitnessGoal", FitnessGoalSchema); } +export const FitnessGoal = _model; diff --git a/src/routes/api/fitness/goal/+server.ts b/src/routes/api/fitness/goal/+server.ts new file mode 100644 index 0000000..0052f04 --- /dev/null +++ b/src/routes/api/fitness/goal/+server.ts @@ -0,0 +1,111 @@ +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; + + // If no goal set, return early + if (weeklyWorkouts === null) { + return json({ weeklyWorkouts: null, streak: 0 }); + } + + const streak = await computeStreak(user.nickname, weeklyWorkouts); + return json({ weeklyWorkouts, streak }); +}; + +export const PUT: RequestHandler = async ({ request, locals }) => { + const user = await requireAuth(locals); + const { weeklyWorkouts } = await request.json(); + + 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 }); + } + + await dbConnect(); + + await FitnessGoal.findOneAndUpdate( + { username: user.nickname }, + { weeklyWorkouts }, + { upsert: true } + ); + + const streak = await computeStreak(user.nickname, weeklyWorkouts); + return json({ weeklyWorkouts, streak }); +}; + +async function computeStreak(username: string, weeklyGoal: number): Promise { + // Get weekly workout counts going back up to 2 years + const cutoff = new Date(); + cutoff.setFullYear(cutoff.getFullYear() - 2); + + 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 } + } + ]); + + // Build a set of weeks that met the goal + const metGoal = new Set(); + for (const item of weeklyAgg) { + if (item.count >= weeklyGoal) { + 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; + + const currentKey = isoWeekKey(now); + const currentWeekMet = metGoal.has(currentKey); + + // If current week already met: count it, then check previous weeks. + // If not: start checking from last week (current week still in progress). + if (currentWeekMet) streak = 1; + + for (let i = 1; i <= 104; i++) { + const weekDate = new Date(now); + weekDate.setDate(weekDate.getDate() - i * 7); + if (metGoal.has(isoWeekKey(weekDate))) { + streak++; + } else { + break; + } + } + + return 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}`; +} diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.server.ts b/src/routes/fitness/[stats=fitnessStats]/+page.server.ts index 862596c..a3ed35d 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.server.ts +++ b/src/routes/fitness/[stats=fitnessStats]/+page.server.ts @@ -2,7 +2,11 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ fetch, locals }) => { const session = await locals.auth(); - const res = await fetch('/api/fitness/stats/overview'); + const [res, goalRes] = await Promise.all([ + fetch('/api/fitness/stats/overview'), + fetch('/api/fitness/goal') + ]); const stats = await res.json(); - return { session, stats }; + const goal = goalRes.ok ? await goalRes.json() : { weeklyWorkouts: null, streak: 0 }; + return { session, stats, goal }; }; diff --git a/src/routes/fitness/[stats=fitnessStats]/+page.svelte b/src/routes/fitness/[stats=fitnessStats]/+page.svelte index a7c2db3..57258ba 100644 --- a/src/routes/fitness/[stats=fitnessStats]/+page.svelte +++ b/src/routes/fitness/[stats=fitnessStats]/+page.svelte @@ -1,7 +1,7 @@