From 6103bb75a0bbb852a86d7b00fdb42e2730896a8b Mon Sep 17 00:00:00 2001 From: Alexander Bocken Date: Sat, 21 Mar 2026 10:53:42 +0100 Subject: [PATCH] fitness: add workout schedule rotation with next-workout suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can define a custom order of templates (e.g., Push → Pull → Legs). Based on the last completed session, the next workout in rotation is recommended via a prominent banner and the floating action button. - New WorkoutSchedule MongoDB model (per-user template order) - GET/PUT /api/fitness/schedule API endpoints - Schedule editor modal with reorder and add/remove - Action button starts next scheduled workout when schedule exists --- src/models/WorkoutSchedule.ts | 31 ++ src/routes/api/fitness/schedule/+server.ts | 96 ++++++ src/routes/fitness/workout/+page.server.ts | 9 +- src/routes/fitness/workout/+page.svelte | 380 ++++++++++++++++++++- 4 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 src/models/WorkoutSchedule.ts create mode 100644 src/routes/api/fitness/schedule/+server.ts diff --git a/src/models/WorkoutSchedule.ts b/src/models/WorkoutSchedule.ts new file mode 100644 index 0000000..a6e34eb --- /dev/null +++ b/src/models/WorkoutSchedule.ts @@ -0,0 +1,31 @@ +import mongoose from 'mongoose'; + +export interface IWorkoutSchedule { + _id?: string; + userId: string; + templateOrder: string[]; // array of WorkoutTemplate _id strings in rotation order + createdAt?: Date; + updatedAt?: Date; +} + +const WorkoutScheduleSchema = new mongoose.Schema( + { + userId: { + type: String, + required: true, + unique: true, + trim: true + }, + templateOrder: { + type: [String], + default: [] + } + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } + } +); + +export const WorkoutSchedule = mongoose.model('WorkoutSchedule', WorkoutScheduleSchema); diff --git a/src/routes/api/fitness/schedule/+server.ts b/src/routes/api/fitness/schedule/+server.ts new file mode 100644 index 0000000..39998f8 --- /dev/null +++ b/src/routes/api/fitness/schedule/+server.ts @@ -0,0 +1,96 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { dbConnect } from '$utils/db'; +import { WorkoutSchedule } from '$models/WorkoutSchedule'; +import { WorkoutSession } from '$models/WorkoutSession'; +import { WorkoutTemplate } from '$models/WorkoutTemplate'; +import { requireAuth } from '$lib/server/middleware/auth'; + +// GET /api/fitness/schedule - Get the user's workout schedule and next workout +export const GET: RequestHandler = async ({ locals }) => { + const user = await requireAuth(locals); + + try { + await dbConnect(); + + const schedule = await WorkoutSchedule.findOne({ userId: user.nickname }); + + if (!schedule || schedule.templateOrder.length === 0) { + return json({ schedule: null, nextTemplateId: null }); + } + + // Find the most recent session that used a template in the schedule + const lastSession = await WorkoutSession.findOne({ + createdBy: user.nickname, + templateId: { $in: schedule.templateOrder } + }).sort({ startTime: -1 }); + + let nextTemplateId: string; + + if (!lastSession?.templateId) { + // No previous session — start at the first template + nextTemplateId = schedule.templateOrder[0]; + } else { + const lastId = lastSession.templateId.toString(); + const idx = schedule.templateOrder.indexOf(lastId); + if (idx === -1) { + // Last session's template no longer in schedule — start at first + nextTemplateId = schedule.templateOrder[0]; + } else { + // Next in rotation (wraps around) + nextTemplateId = schedule.templateOrder[(idx + 1) % schedule.templateOrder.length]; + } + } + + // Verify the template still exists + const templateExists = await WorkoutTemplate.exists({ _id: nextTemplateId }); + if (!templateExists) { + nextTemplateId = schedule.templateOrder[0]; + } + + return json({ + schedule: { templateOrder: schedule.templateOrder }, + nextTemplateId + }); + } catch (error) { + console.error('Error fetching workout schedule:', error); + return json({ error: 'Failed to fetch workout schedule' }, { status: 500 }); + } +}; + +// PUT /api/fitness/schedule - Save the user's workout schedule (template order) +export const PUT: RequestHandler = async ({ request, locals }) => { + const user = await requireAuth(locals); + + try { + await dbConnect(); + + const { templateOrder } = await request.json(); + + if (!Array.isArray(templateOrder)) { + return json({ error: 'templateOrder must be an array' }, { status: 400 }); + } + + // Validate all template IDs belong to this user + if (templateOrder.length > 0) { + const count = await WorkoutTemplate.countDocuments({ + _id: { $in: templateOrder }, + createdBy: user.nickname + }); + if (count !== templateOrder.length) { + return json({ error: 'Some template IDs are invalid' }, { status: 400 }); + } + } + + const schedule = await WorkoutSchedule.findOneAndUpdate( + { userId: user.nickname }, + { templateOrder }, + { upsert: true, new: true } + ); + + return json({ schedule: { templateOrder: schedule.templateOrder } }); + } catch (error) { + console.error('Error saving workout schedule:', error); + return json({ error: 'Failed to save workout schedule' }, { status: 500 }); + } +}; diff --git a/src/routes/fitness/workout/+page.server.ts b/src/routes/fitness/workout/+page.server.ts index f13762a..62c1587 100644 --- a/src/routes/fitness/workout/+page.server.ts +++ b/src/routes/fitness/workout/+page.server.ts @@ -1,8 +1,13 @@ import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ fetch }) => { - const res = await fetch('/api/fitness/templates'); + const [templatesRes, scheduleRes] = await Promise.all([ + fetch('/api/fitness/templates'), + fetch('/api/fitness/schedule') + ]); + return { - templates: await res.json() + templates: await templatesRes.json(), + schedule: await scheduleRes.json() }; }; diff --git a/src/routes/fitness/workout/+page.svelte b/src/routes/fitness/workout/+page.svelte index 4c20a58..b62266e 100644 --- a/src/routes/fitness/workout/+page.svelte +++ b/src/routes/fitness/workout/+page.svelte @@ -1,7 +1,7 @@
+ {#if hasSchedule && nextTemplate} +
+
+ + Next in schedule +
+ +
+ {#each scheduleOrder as id, i} + {getTemplateName(id)} + {#if i < scheduleOrder.length - 1} + + {/if} + {/each} +
+
+ {/if} +
-

Templates

+
+

Templates

+ +
{#if templates.length > 0}

My Templates ({templates.length})

@@ -328,8 +442,70 @@ {/if} {/if} + +{#if showScheduleEditor} + + +{/if} + {#if !workout.active} - + {#if hasSchedule && nextTemplate} + + {:else} + + {/if} {/if}