diff --git a/src/lib/components/AddActionButton.svelte b/src/lib/components/AddActionButton.svelte new file mode 100644 index 00000000..d0780bf0 --- /dev/null +++ b/src/lib/components/AddActionButton.svelte @@ -0,0 +1,82 @@ + + + + diff --git a/src/lib/components/fitness/ExerciseName.svelte b/src/lib/components/fitness/ExerciseName.svelte new file mode 100644 index 00000000..f93ac8dc --- /dev/null +++ b/src/lib/components/fitness/ExerciseName.svelte @@ -0,0 +1,28 @@ + + +{#if exercise} + {exercise.name} +{:else} + Unknown Exercise +{/if} + + diff --git a/src/lib/components/fitness/ExercisePicker.svelte b/src/lib/components/fitness/ExercisePicker.svelte new file mode 100644 index 00000000..0a5c1786 --- /dev/null +++ b/src/lib/components/fitness/ExercisePicker.svelte @@ -0,0 +1,217 @@ + + + +
e.key === 'Escape' && onClose()}> + +
+
+
+

Add Exercise

+ +
+ + + +
+ + +
+ + +
+
+ + diff --git a/src/lib/components/fitness/FitnessChart.svelte b/src/lib/components/fitness/FitnessChart.svelte new file mode 100644 index 00000000..b9ccbe31 --- /dev/null +++ b/src/lib/components/fitness/FitnessChart.svelte @@ -0,0 +1,168 @@ + + +
+ +
+ + diff --git a/src/lib/components/fitness/RestTimer.svelte b/src/lib/components/fitness/RestTimer.svelte new file mode 100644 index 00000000..bd8ab0dc --- /dev/null +++ b/src/lib/components/fitness/RestTimer.svelte @@ -0,0 +1,76 @@ + + +
+ + + + + {formatTime(seconds)} +
+ + diff --git a/src/lib/components/fitness/SessionCard.svelte b/src/lib/components/fitness/SessionCard.svelte new file mode 100644 index 00000000..9683f397 --- /dev/null +++ b/src/lib/components/fitness/SessionCard.svelte @@ -0,0 +1,170 @@ + + + +
+

{session.name}

+ {formatDate(session.startTime)} · {formatTime(session.startTime)} +
+ +
+ {#each session.exercises.slice(0, 4) as ex (ex.exerciseId)} + {@const exercise = getExerciseById(ex.exerciseId)} + {@const best = bestSet(ex.sets)} +
+ {ex.sets.length} × {exercise?.name ?? ex.exerciseId} + {#if best} + {best.weight} kg × {best.reps}{#if best.rpe} @ {best.rpe}{/if} + {/if} +
+ {/each} + {#if session.exercises.length > 4} +
+{session.exercises.length - 4} more exercises
+ {/if} +
+ + +
+ + diff --git a/src/lib/components/fitness/SetTable.svelte b/src/lib/components/fitness/SetTable.svelte new file mode 100644 index 00000000..c129c313 --- /dev/null +++ b/src/lib/components/fitness/SetTable.svelte @@ -0,0 +1,206 @@ + + + + + + + {#if previousSets} + + {/if} + + + {#if editable} + + + {/if} + + + + {#each sets as set, i (i)} + + + {#if previousSets} + + {/if} + + + {#if editable} + + + {/if} + + {/each} + +
SETPREVIOUSKGREPSRPE
{i + 1} + {#if previousSets[i]} + {previousSets[i].weight} × {previousSets[i].reps} + {:else} + — + {/if} + + {#if editable} + handleInput(i, 'weight', e)} + /> + {:else} + {set.weight ?? '—'} + {/if} + + {#if editable} + handleInput(i, 'reps', e)} + /> + {:else} + {set.reps ?? '—'} + {/if} + + handleInput(i, 'rpe', e)} + /> + + +
+ + diff --git a/src/lib/components/fitness/TemplateCard.svelte b/src/lib/components/fitness/TemplateCard.svelte new file mode 100644 index 00000000..64b2fc3a --- /dev/null +++ b/src/lib/components/fitness/TemplateCard.svelte @@ -0,0 +1,130 @@ + + + + {/if} + + + {#if lastUsed} +

Last performed: {formatDate(lastUsed)}

+ {/if} + + + diff --git a/src/lib/components/fitness/WorkoutFab.svelte b/src/lib/components/fitness/WorkoutFab.svelte new file mode 100644 index 00000000..abfec7d7 --- /dev/null +++ b/src/lib/components/fitness/WorkoutFab.svelte @@ -0,0 +1,88 @@ + + + + + {elapsed} + + diff --git a/src/lib/data/exercises.ts b/src/lib/data/exercises.ts new file mode 100644 index 00000000..5cd81f63 --- /dev/null +++ b/src/lib/data/exercises.ts @@ -0,0 +1,947 @@ +export interface Exercise { + id: string; + name: string; + bodyPart: string; + equipment: string; + target: string; + secondaryMuscles: string[]; + instructions: string[]; + imageUrl?: string; +} + +export const exercises: Exercise[] = [ + // === CHEST === + { + id: 'bench-press-barbell', + name: 'Bench Press (Barbell)', + bodyPart: 'chest', + equipment: 'barbell', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Lie flat on the bench holding the barbell with a shoulder-width pronated grip.', + 'Retract scapula and have elbows between 45 to 90 degree angle.', + 'Lower the bar to mid-chest level.', + 'Press the bar back up to the starting position, fully extending the arms.' + ] + }, + { + id: 'incline-bench-press-barbell', + name: 'Incline Bench Press (Barbell)', + bodyPart: 'chest', + equipment: 'barbell', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Set the bench to a 30-45 degree incline.', + 'Lie back and grip the barbell slightly wider than shoulder width.', + 'Lower the bar to the upper chest.', + 'Press back up to full extension.' + ] + }, + { + id: 'decline-bench-press-barbell', + name: 'Decline Bench Press (Barbell)', + bodyPart: 'chest', + equipment: 'barbell', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Set the bench to a decline angle and secure your legs.', + 'Grip the barbell slightly wider than shoulder width.', + 'Lower the bar to the lower chest.', + 'Press back up to full extension.' + ] + }, + { + id: 'bench-press-close-grip-barbell', + name: 'Bench Press - Close Grip (Barbell)', + bodyPart: 'arms', + equipment: 'barbell', + target: 'triceps', + secondaryMuscles: ['pectorals', 'anterior deltoids'], + instructions: [ + 'Lie flat on the bench and grip the barbell with hands shoulder-width apart or slightly narrower.', + 'Lower the bar to the lower chest, keeping elbows close to the body.', + 'Press the bar back up to full extension.' + ] + }, + { + id: 'bench-press-dumbbell', + name: 'Bench Press (Dumbbell)', + bodyPart: 'chest', + equipment: 'dumbbell', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Lie flat on the bench holding a dumbbell in each hand at chest level.', + 'Press the dumbbells up until arms are fully extended.', + 'Lower the dumbbells back to chest level with control.' + ] + }, + { + id: 'incline-bench-press-dumbbell', + name: 'Incline Bench Press (Dumbbell)', + bodyPart: 'chest', + equipment: 'dumbbell', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Set the bench to a 30-45 degree incline.', + 'Hold a dumbbell in each hand at chest level.', + 'Press the dumbbells up until arms are fully extended.', + 'Lower with control.' + ] + }, + { + id: 'chest-fly-dumbbell', + name: 'Chest Fly (Dumbbell)', + bodyPart: 'chest', + equipment: 'dumbbell', + target: 'pectorals', + secondaryMuscles: ['anterior deltoids'], + instructions: [ + 'Lie flat on the bench holding dumbbells above your chest with arms slightly bent.', + 'Lower the dumbbells out to the sides in a wide arc.', + 'Bring the dumbbells back together above your chest.' + ] + }, + { + id: 'chest-dip', + name: 'Chest Dip', + bodyPart: 'chest', + equipment: 'body weight', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids'], + instructions: [ + 'Grip the parallel bars and lift yourself up.', + 'Lean forward slightly and lower your body by bending the elbows.', + 'Lower until you feel a stretch in the chest.', + 'Push back up to the starting position.' + ] + }, + { + id: 'push-up', + name: 'Push Up', + bodyPart: 'chest', + equipment: 'body weight', + target: 'pectorals', + secondaryMuscles: ['triceps', 'anterior deltoids', 'core'], + instructions: [ + 'Start in a plank position with hands slightly wider than shoulder width.', + 'Lower your body until your chest nearly touches the floor.', + 'Push back up to the starting position.' + ] + }, + { + id: 'cable-crossover', + name: 'Cable Crossover', + bodyPart: 'chest', + equipment: 'cable', + target: 'pectorals', + secondaryMuscles: ['anterior deltoids'], + instructions: [ + 'Set both pulleys to the highest position and grip the handles.', + 'Step forward and bring the handles together in front of your chest in a hugging motion.', + 'Slowly return to the starting position.' + ] + }, + + // === BACK === + { + id: 'bent-over-row-barbell', + name: 'Bent Over Row (Barbell)', + bodyPart: 'back', + equipment: 'barbell', + target: 'lats', + secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'], + instructions: [ + 'Stand with feet shoulder-width apart, bend at the hips with a slight knee bend.', + 'Grip the barbell with an overhand grip, hands slightly wider than shoulder width.', + 'Pull the bar towards your lower chest/upper abdomen.', + 'Lower the bar back down with control.' + ] + }, + { + id: 'deadlift-barbell', + name: 'Deadlift (Barbell)', + bodyPart: 'back', + equipment: 'barbell', + target: 'erector spinae', + secondaryMuscles: ['glutes', 'hamstrings', 'lats', 'traps'], + instructions: [ + 'Stand with feet hip-width apart, barbell over mid-foot.', + 'Bend at hips and knees, grip the bar just outside your knees.', + 'Keep your back flat, chest up, and drive through your heels to stand up.', + 'Lower the bar back to the floor with control.' + ] + }, + { + id: 'pull-up', + name: 'Pull Up', + bodyPart: 'back', + equipment: 'body weight', + target: 'lats', + secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'], + instructions: [ + 'Hang from a pull-up bar with an overhand grip, hands slightly wider than shoulder width.', + 'Pull yourself up until your chin is above the bar.', + 'Lower yourself back down with control.' + ] + }, + { + id: 'chin-up', + name: 'Chin Up', + bodyPart: 'back', + equipment: 'body weight', + target: 'lats', + secondaryMuscles: ['biceps', 'rhomboids'], + instructions: [ + 'Hang from a pull-up bar with an underhand (supinated) grip, hands shoulder-width apart.', + 'Pull yourself up until your chin is above the bar.', + 'Lower yourself back down with control.' + ] + }, + { + id: 'lat-pulldown-cable', + name: 'Lat Pulldown (Cable)', + bodyPart: 'back', + equipment: 'cable', + target: 'lats', + secondaryMuscles: ['biceps', 'rhomboids', 'rear deltoids'], + instructions: [ + 'Sit at the lat pulldown machine and grip the bar wider than shoulder width.', + 'Pull the bar down to your upper chest.', + 'Slowly return the bar to the starting position.' + ] + }, + { + id: 'seated-row-cable', + name: 'Seated Row (Cable)', + bodyPart: 'back', + equipment: 'cable', + target: 'lats', + secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'], + instructions: [ + 'Sit at the cable row machine with feet on the platform.', + 'Grip the handle and pull it towards your abdomen.', + 'Squeeze your shoulder blades together at the end of the movement.', + 'Slowly return to the starting position.' + ] + }, + { + id: 'dumbbell-row', + name: 'Dumbbell Row', + bodyPart: 'back', + equipment: 'dumbbell', + target: 'lats', + secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids'], + instructions: [ + 'Place one knee and hand on a bench, holding a dumbbell in the other hand.', + 'Pull the dumbbell up to your hip, keeping the elbow close to your body.', + 'Lower the dumbbell back down with control.' + ] + }, + { + id: 't-bar-row', + name: 'T-Bar Row', + bodyPart: 'back', + equipment: 'barbell', + target: 'lats', + secondaryMuscles: ['rhomboids', 'biceps', 'rear deltoids', 'traps'], + instructions: [ + 'Straddle the T-bar row machine or landmine attachment.', + 'Bend at the hips and grip the handle.', + 'Pull the weight towards your chest.', + 'Lower with control.' + ] + }, + { + id: 'face-pull-cable', + name: 'Face Pull (Cable)', + bodyPart: 'back', + equipment: 'cable', + target: 'rear deltoids', + secondaryMuscles: ['rhomboids', 'traps', 'rotator cuff'], + instructions: [ + 'Set the cable to upper chest height with a rope attachment.', + 'Pull the rope towards your face, separating the ends.', + 'Squeeze your shoulder blades and externally rotate at the end.', + 'Slowly return to the starting position.' + ] + }, + + // === SHOULDERS === + { + id: 'overhead-press-barbell', + name: 'Overhead Press (Barbell)', + bodyPart: 'shoulders', + equipment: 'barbell', + target: 'anterior deltoids', + secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'], + instructions: [ + 'Stand with feet shoulder-width apart, barbell at shoulder height.', + 'Press the bar overhead until arms are fully extended.', + 'Lower the bar back to shoulder height with control.' + ] + }, + { + id: 'overhead-press-dumbbell', + name: 'Overhead Press (Dumbbell)', + bodyPart: 'shoulders', + equipment: 'dumbbell', + target: 'anterior deltoids', + secondaryMuscles: ['triceps', 'lateral deltoids', 'traps'], + instructions: [ + 'Sit or stand holding dumbbells at shoulder height.', + 'Press the dumbbells overhead until arms are fully extended.', + 'Lower the dumbbells back to shoulder height with control.' + ] + }, + { + id: 'lateral-raise-dumbbell', + name: 'Lateral Raise (Dumbbell)', + bodyPart: 'shoulders', + equipment: 'dumbbell', + target: 'lateral deltoids', + secondaryMuscles: ['traps'], + instructions: [ + 'Stand with dumbbells at your sides.', + 'Raise the dumbbells out to the sides until arms are parallel to the floor.', + 'Lower with control.' + ] + }, + { + id: 'lateral-raise-cable', + name: 'Lateral Raise (Cable)', + bodyPart: 'shoulders', + equipment: 'cable', + target: 'lateral deltoids', + secondaryMuscles: ['traps'], + instructions: [ + 'Stand sideways to a low cable pulley, gripping the handle with the far hand.', + 'Raise your arm out to the side until parallel to the floor.', + 'Lower with control.' + ] + }, + { + id: 'front-raise-dumbbell', + name: 'Front Raise (Dumbbell)', + bodyPart: 'shoulders', + equipment: 'dumbbell', + target: 'anterior deltoids', + secondaryMuscles: ['lateral deltoids'], + instructions: [ + 'Stand with dumbbells in front of your thighs.', + 'Raise one or both dumbbells to the front until arms are parallel to the floor.', + 'Lower with control.' + ] + }, + { + id: 'reverse-fly-dumbbell', + name: 'Reverse Fly (Dumbbell)', + bodyPart: 'shoulders', + equipment: 'dumbbell', + target: 'rear deltoids', + secondaryMuscles: ['rhomboids', 'traps'], + instructions: [ + 'Bend forward at the hips holding dumbbells.', + 'Raise the dumbbells out to the sides, squeezing shoulder blades together.', + 'Lower with control.' + ] + }, + { + id: 'upright-row-barbell', + name: 'Upright Row (Barbell)', + bodyPart: 'shoulders', + equipment: 'barbell', + target: 'lateral deltoids', + secondaryMuscles: ['traps', 'biceps'], + instructions: [ + 'Stand holding a barbell with a narrow grip in front of your thighs.', + 'Pull the bar up along your body to chin height, leading with the elbows.', + 'Lower with control.' + ] + }, + { + id: 'shrug-barbell', + name: 'Shrug (Barbell)', + bodyPart: 'shoulders', + equipment: 'barbell', + target: 'traps', + secondaryMuscles: [], + instructions: [ + 'Stand holding a barbell with arms extended.', + 'Shrug your shoulders straight up towards your ears.', + 'Hold briefly at the top, then lower with control.' + ] + }, + { + id: 'shrug-dumbbell', + name: 'Shrug (Dumbbell)', + bodyPart: 'shoulders', + equipment: 'dumbbell', + target: 'traps', + secondaryMuscles: [], + instructions: [ + 'Stand holding dumbbells at your sides.', + 'Shrug your shoulders straight up towards your ears.', + 'Hold briefly at the top, then lower with control.' + ] + }, + + // === ARMS — BICEPS === + { + id: 'bicep-curl-barbell', + name: 'Bicep Curl (Barbell)', + bodyPart: 'arms', + equipment: 'barbell', + target: 'biceps', + secondaryMuscles: ['forearms'], + instructions: [ + 'Stand holding a barbell with an underhand grip, arms extended.', + 'Curl the bar up towards your shoulders.', + 'Lower with control.' + ] + }, + { + id: 'bicep-curl-dumbbell', + name: 'Bicep Curl (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'biceps', + secondaryMuscles: ['forearms'], + instructions: [ + 'Stand holding dumbbells at your sides with palms facing forward.', + 'Curl the dumbbells up towards your shoulders.', + 'Lower with control.' + ] + }, + { + id: 'hammer-curl-dumbbell', + name: 'Hammer Curl (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'biceps', + secondaryMuscles: ['brachioradialis', 'forearms'], + instructions: [ + 'Stand holding dumbbells at your sides with palms facing each other (neutral grip).', + 'Curl the dumbbells up towards your shoulders.', + 'Lower with control.' + ] + }, + { + id: 'preacher-curl-barbell', + name: 'Preacher Curl (Barbell)', + bodyPart: 'arms', + equipment: 'barbell', + target: 'biceps', + secondaryMuscles: ['forearms'], + instructions: [ + 'Sit at a preacher bench with upper arms resting on the pad.', + 'Grip the barbell with an underhand grip.', + 'Curl the bar up, then lower with control.' + ] + }, + { + id: 'concentration-curl-dumbbell', + name: 'Concentration Curl (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'biceps', + secondaryMuscles: [], + instructions: [ + 'Sit on a bench, rest your elbow against the inside of your thigh.', + 'Curl the dumbbell up towards your shoulder.', + 'Lower with control.' + ] + }, + { + id: 'cable-curl', + name: 'Cable Curl', + bodyPart: 'arms', + equipment: 'cable', + target: 'biceps', + secondaryMuscles: ['forearms'], + instructions: [ + 'Stand facing a low cable pulley with a straight or EZ-bar attachment.', + 'Curl the bar up towards your shoulders.', + 'Lower with control.' + ] + }, + + // === ARMS — TRICEPS === + { + id: 'tricep-pushdown-cable', + name: 'Tricep Pushdown (Cable)', + bodyPart: 'arms', + equipment: 'cable', + target: 'triceps', + secondaryMuscles: [], + instructions: [ + 'Stand facing a high cable pulley with a straight bar or rope attachment.', + 'Push the bar down until arms are fully extended.', + 'Slowly return to the starting position.' + ] + }, + { + id: 'skullcrusher-dumbbell', + name: 'Skullcrusher (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'triceps', + secondaryMuscles: [], + instructions: [ + 'Lie flat on a bench holding dumbbells with arms extended above your chest.', + 'Lower the dumbbells towards your forehead by bending at the elbows.', + 'Extend the arms back to the starting position.' + ] + }, + { + id: 'skullcrusher-barbell', + name: 'Skullcrusher (Barbell)', + bodyPart: 'arms', + equipment: 'barbell', + target: 'triceps', + secondaryMuscles: [], + instructions: [ + 'Lie flat on a bench holding a barbell with arms extended above your chest.', + 'Lower the bar towards your forehead by bending at the elbows.', + 'Extend the arms back to the starting position.' + ] + }, + { + id: 'overhead-tricep-extension-dumbbell', + name: 'Overhead Tricep Extension (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'triceps', + secondaryMuscles: [], + instructions: [ + 'Hold a dumbbell overhead with both hands.', + 'Lower the dumbbell behind your head by bending at the elbows.', + 'Extend back to the starting position.' + ] + }, + { + id: 'tricep-dip', + name: 'Tricep Dip', + bodyPart: 'arms', + equipment: 'body weight', + target: 'triceps', + secondaryMuscles: ['pectorals', 'anterior deltoids'], + instructions: [ + 'Grip the parallel bars and lift yourself up, keeping torso upright.', + 'Lower your body by bending the elbows, keeping them close to your body.', + 'Push back up to the starting position.' + ] + }, + { + id: 'kickback-dumbbell', + name: 'Kickback (Dumbbell)', + bodyPart: 'arms', + equipment: 'dumbbell', + target: 'triceps', + secondaryMuscles: [], + instructions: [ + 'Bend forward at the hips, upper arm parallel to the floor.', + 'Extend the dumbbell backwards until the arm is straight.', + 'Lower with control.' + ] + }, + + // === LEGS === + { + id: 'squat-barbell', + name: 'Squat (Barbell)', + bodyPart: 'legs', + equipment: 'barbell', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'hamstrings', 'core'], + instructions: [ + 'Position the barbell on your upper back (high bar) or rear deltoids (low bar).', + 'Stand with feet shoulder-width apart.', + 'Squat down until thighs are at least parallel to the floor.', + 'Drive through your heels to stand back up.' + ] + }, + { + id: 'front-squat-barbell', + name: 'Front Squat (Barbell)', + bodyPart: 'legs', + equipment: 'barbell', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'core'], + instructions: [ + 'Position the barbell across the front of your shoulders.', + 'Squat down, keeping the elbows high and torso upright.', + 'Drive through your heels to stand back up.' + ] + }, + { + id: 'leg-press-machine', + name: 'Leg Press (Machine)', + bodyPart: 'legs', + equipment: 'machine', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'hamstrings'], + instructions: [ + 'Sit in the leg press machine with feet shoulder-width apart on the platform.', + 'Lower the platform by bending your knees to about 90 degrees.', + 'Push the platform back up without locking your knees.' + ] + }, + { + id: 'lunge-dumbbell', + name: 'Lunge (Dumbbell)', + bodyPart: 'legs', + equipment: 'dumbbell', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'hamstrings'], + instructions: [ + 'Stand holding dumbbells at your sides.', + 'Step forward and lower your body until both knees are at 90 degrees.', + 'Push back to the starting position.' + ] + }, + { + id: 'bulgarian-split-squat-dumbbell', + name: 'Bulgarian Split Squat (Dumbbell)', + bodyPart: 'legs', + equipment: 'dumbbell', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'hamstrings'], + instructions: [ + 'Stand with one foot on a bench behind you, holding dumbbells.', + 'Lower your body until the front thigh is parallel to the floor.', + 'Drive through the front heel to stand back up.' + ] + }, + { + id: 'leg-extension-machine', + name: 'Leg Extension (Machine)', + bodyPart: 'legs', + equipment: 'machine', + target: 'quadriceps', + secondaryMuscles: [], + instructions: [ + 'Sit in the leg extension machine with the pad against your lower shins.', + 'Extend your legs until they are straight.', + 'Lower with control.' + ] + }, + { + id: 'leg-curl-machine', + name: 'Leg Curl (Machine)', + bodyPart: 'legs', + equipment: 'machine', + target: 'hamstrings', + secondaryMuscles: ['calves'], + instructions: [ + 'Lie face down on the leg curl machine with the pad against the back of your ankles.', + 'Curl your legs up towards your glutes.', + 'Lower with control.' + ] + }, + { + id: 'romanian-deadlift-barbell', + name: 'Romanian Deadlift (Barbell)', + bodyPart: 'legs', + equipment: 'barbell', + target: 'hamstrings', + secondaryMuscles: ['glutes', 'erector spinae'], + instructions: [ + 'Stand holding a barbell with an overhand grip.', + 'Hinge at the hips, pushing them back while keeping legs nearly straight.', + 'Lower the bar along your legs until you feel a stretch in the hamstrings.', + 'Drive the hips forward to return to standing.' + ] + }, + { + id: 'romanian-deadlift-dumbbell', + name: 'Romanian Deadlift (Dumbbell)', + bodyPart: 'legs', + equipment: 'dumbbell', + target: 'hamstrings', + secondaryMuscles: ['glutes', 'erector spinae'], + instructions: [ + 'Stand holding dumbbells in front of your thighs.', + 'Hinge at the hips, pushing them back while keeping legs nearly straight.', + 'Lower the dumbbells along your legs until you feel a stretch in the hamstrings.', + 'Drive the hips forward to return to standing.' + ] + }, + { + id: 'hip-thrust-barbell', + name: 'Hip Thrust (Barbell)', + bodyPart: 'legs', + equipment: 'barbell', + target: 'glutes', + secondaryMuscles: ['hamstrings'], + instructions: [ + 'Sit on the floor with your upper back against a bench, barbell over your hips.', + 'Drive through your heels to lift your hips until your body forms a straight line.', + 'Squeeze your glutes at the top.', + 'Lower with control.' + ] + }, + { + id: 'calf-raise-machine', + name: 'Calf Raise (Machine)', + bodyPart: 'legs', + equipment: 'machine', + target: 'calves', + secondaryMuscles: [], + instructions: [ + 'Stand on the machine platform with the balls of your feet on the edge.', + 'Lower your heels as far as comfortable.', + 'Push up onto your toes as high as possible.', + 'Lower with control.' + ] + }, + { + id: 'calf-raise-standing', + name: 'Calf Raise (Standing)', + bodyPart: 'legs', + equipment: 'body weight', + target: 'calves', + secondaryMuscles: [], + instructions: [ + 'Stand on a step or platform with the balls of your feet on the edge.', + 'Lower your heels below the platform.', + 'Push up onto your toes as high as possible.', + 'Lower with control.' + ] + }, + { + id: 'goblet-squat-dumbbell', + name: 'Goblet Squat (Dumbbell)', + bodyPart: 'legs', + equipment: 'dumbbell', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'core'], + instructions: [ + 'Hold a dumbbell vertically at chest level.', + 'Squat down, keeping the torso upright.', + 'Drive through your heels to stand back up.' + ] + }, + { + id: 'hack-squat-machine', + name: 'Hack Squat (Machine)', + bodyPart: 'legs', + equipment: 'machine', + target: 'quadriceps', + secondaryMuscles: ['glutes', 'hamstrings'], + instructions: [ + 'Position yourself in the hack squat machine with shoulders against the pads.', + 'Lower the platform by bending your knees.', + 'Push back up without locking your knees.' + ] + }, + + // === CORE === + { + id: 'plank', + name: 'Plank', + bodyPart: 'core', + equipment: 'body weight', + target: 'abdominals', + secondaryMuscles: ['obliques', 'erector spinae'], + instructions: [ + 'Start in a forearm plank position with elbows under shoulders.', + 'Keep your body in a straight line from head to heels.', + 'Hold the position for the desired duration.' + ] + }, + { + id: 'crunch', + name: 'Crunch', + bodyPart: 'core', + equipment: 'body weight', + target: 'abdominals', + secondaryMuscles: [], + instructions: [ + 'Lie on your back with knees bent and feet flat on the floor.', + 'Place hands behind your head or across your chest.', + 'Curl your upper body towards your knees.', + 'Lower with control.' + ] + }, + { + id: 'hanging-leg-raise', + name: 'Hanging Leg Raise', + bodyPart: 'core', + equipment: 'body weight', + target: 'abdominals', + secondaryMuscles: ['hip flexors'], + instructions: [ + 'Hang from a pull-up bar with arms extended.', + 'Raise your legs until they are parallel to the floor (or higher).', + 'Lower with control.' + ] + }, + { + id: 'cable-crunch', + name: 'Cable Crunch', + bodyPart: 'core', + equipment: 'cable', + target: 'abdominals', + secondaryMuscles: [], + instructions: [ + 'Kneel in front of a high cable pulley with a rope attachment.', + 'Hold the rope behind your head.', + 'Crunch down, bringing your elbows towards your knees.', + 'Return to the starting position with control.' + ] + }, + { + id: 'russian-twist', + name: 'Russian Twist', + bodyPart: 'core', + equipment: 'body weight', + target: 'obliques', + secondaryMuscles: ['abdominals'], + instructions: [ + 'Sit on the floor with knees bent, lean back slightly.', + 'Rotate your torso from side to side.', + 'Optionally hold a weight for added resistance.' + ] + }, + { + id: 'ab-wheel-rollout', + name: 'Ab Wheel Rollout', + bodyPart: 'core', + equipment: 'other', + target: 'abdominals', + secondaryMuscles: ['erector spinae', 'lats'], + instructions: [ + 'Kneel on the floor holding an ab wheel.', + 'Roll the wheel forward, extending your body as far as possible.', + 'Use your core to pull back to the starting position.' + ] + }, + + // === CARDIO / FULL BODY === + { + id: 'running', + name: 'Running', + bodyPart: 'cardio', + equipment: 'body weight', + target: 'cardiovascular system', + secondaryMuscles: ['quadriceps', 'hamstrings', 'calves', 'glutes'], + instructions: ['Run at a steady pace for the desired duration or distance.'] + }, + { + id: 'cycling-indoor', + name: 'Cycling (Indoor)', + bodyPart: 'cardio', + equipment: 'machine', + target: 'cardiovascular system', + secondaryMuscles: ['quadriceps', 'hamstrings', 'calves'], + instructions: ['Cycle at a steady pace on a stationary bike for the desired duration.'] + }, + { + id: 'rowing-machine', + name: 'Rowing Machine', + bodyPart: 'cardio', + equipment: 'machine', + target: 'cardiovascular system', + secondaryMuscles: ['lats', 'biceps', 'quadriceps', 'core'], + instructions: [ + 'Sit at the rowing machine and strap your feet in.', + 'Drive with your legs first, then pull the handle to your lower chest.', + 'Return to the starting position by extending arms, then bending knees.' + ] + }, + + // === ADDITIONAL COMPOUND MOVEMENTS === + { + id: 'clean-and-press-barbell', + name: 'Clean and Press (Barbell)', + bodyPart: 'shoulders', + equipment: 'barbell', + target: 'anterior deltoids', + secondaryMuscles: ['traps', 'quadriceps', 'glutes', 'core'], + instructions: [ + 'Start with the barbell on the floor.', + 'Pull the bar explosively to your shoulders (clean).', + 'Press the bar overhead.', + 'Lower back to shoulders, then to the floor.' + ] + }, + { + id: 'farmers-walk', + name: "Farmer's Walk", + bodyPart: 'core', + equipment: 'dumbbell', + target: 'forearms', + secondaryMuscles: ['traps', 'core', 'grip'], + instructions: [ + 'Hold heavy dumbbells or farmer walk handles at your sides.', + 'Walk with controlled steps for the desired distance or duration.', + 'Keep your core tight and shoulders back.' + ] + } +]; + +// Lookup map for O(1) access by ID +const exerciseMap = new Map(exercises.map((e) => [e.id, e])); + +export function getExerciseById(id: string): Exercise | undefined { + return exerciseMap.get(id); +} + +export function getFilterOptions(): { + bodyParts: string[]; + equipment: string[]; + targets: string[]; +} { + const bodyParts = new Set(); + const equipment = new Set(); + const targets = new Set(); + + for (const e of exercises) { + bodyParts.add(e.bodyPart); + equipment.add(e.equipment); + targets.add(e.target); + } + + return { + bodyParts: [...bodyParts].sort(), + equipment: [...equipment].sort(), + targets: [...targets].sort() + }; +} + +export function searchExercises(opts: { + search?: string; + bodyPart?: string; + equipment?: string; + target?: string; +}): Exercise[] { + let results = exercises; + + if (opts.bodyPart) { + results = results.filter((e) => e.bodyPart === opts.bodyPart); + } + if (opts.equipment) { + results = results.filter((e) => e.equipment === opts.equipment); + } + if (opts.target) { + results = results.filter((e) => e.target === opts.target); + } + if (opts.search) { + const query = opts.search.toLowerCase(); + results = results.filter( + (e) => + e.name.toLowerCase().includes(query) || + e.target.toLowerCase().includes(query) || + e.bodyPart.toLowerCase().includes(query) || + e.equipment.toLowerCase().includes(query) || + e.secondaryMuscles.some((m) => m.toLowerCase().includes(query)) + ); + } + + return results; +} diff --git a/src/lib/js/workout.svelte.ts b/src/lib/js/workout.svelte.ts new file mode 100644 index 00000000..81a61f64 --- /dev/null +++ b/src/lib/js/workout.svelte.ts @@ -0,0 +1,381 @@ +/** + * Active workout state store — factory pattern. + * Client-side only; persisted to localStorage so state survives navigation. + * Saved to server on finish via POST /api/fitness/sessions. + */ + +import { getExerciseById } from '$lib/data/exercises'; + +export interface WorkoutSet { + reps: number | null; + weight: number | null; + rpe: number | null; + completed: boolean; +} + +export interface WorkoutExercise { + exerciseId: string; + sets: WorkoutSet[]; + restTime: number; // seconds +} + +export interface TemplateData { + _id: string; + name: string; + exercises: Array<{ + exerciseId: string; + sets: Array<{ reps?: number; weight?: number; rpe?: number }>; + restTime?: number; + }>; +} + +const STORAGE_KEY = 'fitness-active-workout'; + +interface StoredState { + active: boolean; + paused: boolean; + name: string; + templateId: string | null; + exercises: WorkoutExercise[]; + elapsed: number; // total elapsed seconds at time of save + savedAt: number; // Date.now() at time of save +} + +function createEmptySet(): WorkoutSet { + return { reps: null, weight: null, rpe: null, completed: false }; +} + +function saveToStorage(state: StoredState) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch {} +} + +function loadFromStorage(): StoredState | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +function clearStorage() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch {} +} + +export function createWorkout() { + let active = $state(false); + let paused = $state(false); + let name = $state(''); + let templateId: string | null = $state(null); + let exercises = $state([]); + let startTime: Date | null = $state(null); + let _pausedElapsed = $state(0); // seconds accumulated before current run + let _elapsed = $state(0); + let _restSeconds = $state(0); + let _restTotal = $state(0); + let _restActive = $state(false); + + let _timerInterval: ReturnType | null = null; + let _restInterval: ReturnType | null = null; + + function _persist() { + if (!active) return; + // When running, compute current elapsed before saving + if (!paused && startTime) { + _elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000); + } + saveToStorage({ + active, + paused, + name, + templateId, + exercises: JSON.parse(JSON.stringify(exercises)), + elapsed: _elapsed, + savedAt: Date.now() + }); + } + + function _computeElapsed() { + if (paused || !startTime) return; + _elapsed = _pausedElapsed + Math.floor((Date.now() - startTime.getTime()) / 1000); + } + + function _startTimer() { + _stopTimer(); + _timerInterval = setInterval(() => { + _computeElapsed(); + }, 1000); + } + + function _stopTimer() { + if (_timerInterval) { + clearInterval(_timerInterval); + _timerInterval = null; + } + } + + function _stopRestTimer() { + if (_restInterval) { + clearInterval(_restInterval); + _restInterval = null; + } + _restActive = false; + _restSeconds = 0; + _restTotal = 0; + } + + // Restore from localStorage on creation + function restore() { + const stored = loadFromStorage(); + if (!stored || !stored.active) return; + + active = true; + paused = stored.paused; + name = stored.name; + templateId = stored.templateId; + exercises = stored.exercises; + + if (stored.paused) { + // Was paused: elapsed is exactly what was saved + _pausedElapsed = stored.elapsed; + _elapsed = stored.elapsed; + startTime = null; + } else { + // Was running: add the time that passed since we last saved + const secondsSinceSave = Math.floor((Date.now() - stored.savedAt) / 1000); + const totalElapsed = stored.elapsed + secondsSinceSave; + _pausedElapsed = totalElapsed; + _elapsed = totalElapsed; + startTime = new Date(); // start counting from now + _startTimer(); + } + } + + function startFromTemplate(template: TemplateData) { + name = template.name; + templateId = template._id; + exercises = template.exercises.map((e) => ({ + exerciseId: e.exerciseId, + sets: e.sets.length > 0 + ? e.sets.map((s) => ({ + reps: s.reps ?? null, + weight: s.weight ?? null, + rpe: s.rpe ?? null, + completed: false + })) + : [createEmptySet()], + restTime: e.restTime ?? 120 + })); + startTime = new Date(); + _pausedElapsed = 0; + _elapsed = 0; + paused = false; + active = true; + _startTimer(); + _persist(); + } + + function startEmpty() { + name = 'Quick Workout'; + templateId = null; + exercises = []; + startTime = new Date(); + _pausedElapsed = 0; + _elapsed = 0; + paused = false; + active = true; + _startTimer(); + _persist(); + } + + function pauseTimer() { + if (!active || paused) return; + _computeElapsed(); + _pausedElapsed = _elapsed; + paused = true; + startTime = null; + _stopTimer(); + _persist(); + } + + function resumeTimer() { + if (!active || !paused) return; + paused = false; + startTime = new Date(); + _startTimer(); + _persist(); + } + + function addExercise(exerciseId: string) { + exercises.push({ + exerciseId, + sets: [createEmptySet()], + restTime: 120 + }); + _persist(); + } + + function removeExercise(index: number) { + exercises.splice(index, 1); + _persist(); + } + + function addSet(exerciseIndex: number) { + const ex = exercises[exerciseIndex]; + if (ex) { + ex.sets.push(createEmptySet()); + _persist(); + } + } + + function removeSet(exerciseIndex: number, setIndex: number) { + const ex = exercises[exerciseIndex]; + if (ex && ex.sets.length > 1) { + ex.sets.splice(setIndex, 1); + _persist(); + } + } + + function updateSet(exerciseIndex: number, setIndex: number, data: Partial) { + const ex = exercises[exerciseIndex]; + if (ex?.sets[setIndex]) { + Object.assign(ex.sets[setIndex], data); + _persist(); + } + } + + function toggleSetComplete(exerciseIndex: number, setIndex: number) { + const ex = exercises[exerciseIndex]; + if (ex?.sets[setIndex]) { + const wasCompleted = ex.sets[setIndex].completed; + ex.sets[setIndex].completed = !wasCompleted; + + if (wasCompleted) { + // Unticked — cancel rest timer + _stopRestTimer(); + } + + _persist(); + } + } + + function startRestTimer(seconds: number) { + _stopRestTimer(); + _restSeconds = seconds; + _restTotal = seconds; + _restActive = true; + _restInterval = setInterval(() => { + _restSeconds--; + if (_restSeconds <= 0) { + _stopRestTimer(); + } + }, 1000); + } + + function cancelRestTimer() { + _stopRestTimer(); + } + + function finish() { + _stopTimer(); + _stopRestTimer(); + + const endTime = new Date(); + _computeElapsed(); + + const sessionData = { + templateId, + templateName: templateId ? name : undefined, + name, + exercises: exercises + .filter((e) => e.sets.some((s) => s.completed)) + .map((e) => ({ + exerciseId: e.exerciseId, + name: getExerciseById(e.exerciseId)?.name ?? e.exerciseId, + sets: e.sets + .filter((s) => s.completed) + .map((s) => ({ + reps: s.reps ?? 0, + weight: s.weight ?? 0, + rpe: s.rpe ?? undefined, + completed: true + })) + })), + startTime: _getOriginalStartTime()?.toISOString() ?? new Date().toISOString(), + endTime: endTime.toISOString() + }; + + _reset(); + return sessionData; + } + + function _getOriginalStartTime(): Date | null { + // Compute original start from elapsed + if (_elapsed > 0) { + return new Date(Date.now() - _elapsed * 1000); + } + return startTime; + } + + function _reset() { + active = false; + paused = false; + name = ''; + templateId = null; + exercises = []; + startTime = null; + _pausedElapsed = 0; + _elapsed = 0; + clearStorage(); + } + + function cancel() { + _stopTimer(); + _stopRestTimer(); + _reset(); + } + + return { + get active() { return active; }, + get paused() { return paused; }, + get name() { return name; }, + set name(v: string) { name = v; _persist(); }, + get templateId() { return templateId; }, + get exercises() { return exercises; }, + get startTime() { return startTime; }, + get elapsedSeconds() { return _elapsed; }, + get restTimerSeconds() { return _restSeconds; }, + get restTimerTotal() { return _restTotal; }, + get restTimerActive() { return _restActive; }, + restore, + startFromTemplate, + startEmpty, + pauseTimer, + resumeTimer, + addExercise, + removeExercise, + addSet, + removeSet, + updateSet, + toggleSetComplete, + startRestTimer, + cancelRestTimer, + finish, + cancel + }; +} + +/** Shared singleton — use this instead of createWorkout() in components */ +let _instance: ReturnType | null = null; + +export function getWorkout() { + if (!_instance) { + _instance = createWorkout(); + } + return _instance; +} diff --git a/src/models/BodyMeasurement.ts b/src/models/BodyMeasurement.ts new file mode 100644 index 00000000..51f6f217 --- /dev/null +++ b/src/models/BodyMeasurement.ts @@ -0,0 +1,99 @@ +import mongoose from 'mongoose'; + +export interface IBodyPartMeasurements { + neck?: number; + shoulders?: number; + chest?: number; + leftBicep?: number; + rightBicep?: number; + leftForearm?: number; + rightForearm?: number; + waist?: number; + hips?: number; + leftThigh?: number; + rightThigh?: number; + leftCalf?: number; + rightCalf?: number; +} + +export interface IBodyMeasurement { + _id?: string; + date: Date; + weight?: number; + bodyFatPercent?: number; + caloricIntake?: number; + measurements?: IBodyPartMeasurements; + notes?: string; + createdBy: string; + createdAt?: Date; + updatedAt?: Date; +} + +const BodyPartMeasurementsSchema = new mongoose.Schema( + { + neck: { type: Number, min: 0 }, + shoulders: { type: Number, min: 0 }, + chest: { type: Number, min: 0 }, + leftBicep: { type: Number, min: 0 }, + rightBicep: { type: Number, min: 0 }, + leftForearm: { type: Number, min: 0 }, + rightForearm: { type: Number, min: 0 }, + waist: { type: Number, min: 0 }, + hips: { type: Number, min: 0 }, + leftThigh: { type: Number, min: 0 }, + rightThigh: { type: Number, min: 0 }, + leftCalf: { type: Number, min: 0 }, + rightCalf: { type: Number, min: 0 } + }, + { _id: false } +); + +const BodyMeasurementSchema = new mongoose.Schema( + { + date: { + type: Date, + required: true, + default: Date.now + }, + weight: { + type: Number, + min: 0, + max: 500 + }, + bodyFatPercent: { + type: Number, + min: 0, + max: 100 + }, + caloricIntake: { + type: Number, + min: 0, + max: 50000 + }, + measurements: { + type: BodyPartMeasurementsSchema + }, + notes: { + type: String, + trim: true, + maxlength: 500 + }, + createdBy: { + type: String, + required: true, + trim: true + } + }, + { + timestamps: true, + toJSON: { virtuals: true }, + toObject: { virtuals: true } + } +); + +BodyMeasurementSchema.index({ createdBy: 1, date: -1 }); + +export const BodyMeasurement = mongoose.model( + 'BodyMeasurement', + BodyMeasurementSchema +); diff --git a/src/routes/api/fitness/exercises/[id]/+server.ts b/src/routes/api/fitness/exercises/[id]/+server.ts index a86ca0b7..bd2aadb6 100644 --- a/src/routes/api/fitness/exercises/[id]/+server.ts +++ b/src/routes/api/fitness/exercises/[id]/+server.ts @@ -1,30 +1,18 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { dbConnect } from '$utils/db'; -import { Exercise } from '$models/Exercise'; +import { getExerciseById } from '$lib/data/exercises'; -// GET /api/fitness/exercises/[id] - Get detailed exercise information +// GET /api/fitness/exercises/[id] - Get exercise from static data export const GET: RequestHandler = async ({ params, locals }) => { - const session = await locals.auth(); - if (!session || !session.user?.nickname) { - return json({ error: 'Unauthorized' }, { status: 401 }); - } + const session = await locals.auth(); + if (!session || !session.user?.nickname) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } - try { - await dbConnect(); - - const exercise = await Exercise.findOne({ - exerciseId: params.id, - isActive: true - }); + const exercise = getExerciseById(params.id); + if (!exercise) { + return json({ error: 'Exercise not found' }, { status: 404 }); + } - if (!exercise) { - return json({ error: 'Exercise not found' }, { status: 404 }); - } - - return json({ exercise }); - } catch (error) { - console.error('Error fetching exercise details:', error); - return json({ error: 'Failed to fetch exercise details' }, { status: 500 }); - } -}; \ No newline at end of file + return json({ exercise }); +}; diff --git a/src/routes/api/fitness/exercises/[id]/history/+server.ts b/src/routes/api/fitness/exercises/[id]/history/+server.ts new file mode 100644 index 00000000..43f5d32d --- /dev/null +++ b/src/routes/api/fitness/exercises/[id]/history/+server.ts @@ -0,0 +1,48 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { getExerciseById } from '$lib/data/exercises'; +import { dbConnect } from '$utils/db'; +import { WorkoutSession } from '$models/WorkoutSession'; + +export const GET: RequestHandler = async ({ params, url, locals }) => { + const user = await requireAuth(locals); + + const exercise = getExerciseById(params.id); + if (!exercise) { + return json({ error: 'Exercise not found' }, { status: 404 }); + } + + const limit = parseInt(url.searchParams.get('limit') || '20'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + + await dbConnect(); + + const sessions = await WorkoutSession.find({ + createdBy: user.nickname, + 'exercises.exerciseId': params.id + }) + .sort({ startTime: -1 }) + .skip(offset) + .limit(limit) + .lean(); + + // Extract only the relevant exercise data from each session + const history = sessions.map((session) => { + const exerciseData = session.exercises.find((e) => e.exerciseId === params.id); + return { + sessionId: session._id, + sessionName: session.name, + date: session.startTime, + sets: exerciseData?.sets ?? [], + notes: exerciseData?.notes + }; + }); + + const total = await WorkoutSession.countDocuments({ + createdBy: user.nickname, + 'exercises.exerciseId': params.id + }); + + return json({ history, total, limit, offset }); +}; diff --git a/src/routes/api/fitness/exercises/[id]/stats/+server.ts b/src/routes/api/fitness/exercises/[id]/stats/+server.ts new file mode 100644 index 00000000..aa62e7ad --- /dev/null +++ b/src/routes/api/fitness/exercises/[id]/stats/+server.ts @@ -0,0 +1,114 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { getExerciseById } from '$lib/data/exercises'; +import { dbConnect } from '$utils/db'; +import { WorkoutSession } from '$models/WorkoutSession'; + +/** + * Epley formula for estimated 1RM + */ +function estimatedOneRepMax(weight: number, reps: number): number { + if (reps <= 0 || weight <= 0) return 0; + if (reps === 1) return weight; + return Math.round(weight * (1 + reps / 30) * 10) / 10; +} + +export const GET: RequestHandler = async ({ params, locals }) => { + const user = await requireAuth(locals); + + const exercise = getExerciseById(params.id); + if (!exercise) { + return json({ error: 'Exercise not found' }, { status: 404 }); + } + + await dbConnect(); + + const sessions = await WorkoutSession.find({ + createdBy: user.nickname, + 'exercises.exerciseId': params.id + }) + .sort({ startTime: 1 }) + .lean(); + + // Build time-series and records data + const est1rmOverTime: { date: Date; value: number }[] = []; + const maxWeightOverTime: { date: Date; value: number }[] = []; + const totalVolumeOverTime: { date: Date; value: number }[] = []; + + // Track best performance at each rep count: { reps -> { weight, date, estimated1rm } } + const repRecords = new Map< + number, + { weight: number; reps: number; date: Date; estimated1rm: number } + >(); + let bestEst1rm = 0; + let bestMaxWeight = 0; + let bestMaxVolume = 0; + + for (const session of sessions) { + const exerciseData = session.exercises.find((e) => e.exerciseId === params.id); + if (!exerciseData) continue; + + const completedSets = exerciseData.sets.filter((s) => s.completed && s.weight && s.reps > 0); + if (completedSets.length === 0) continue; + + // Best set est. 1RM for this session + let sessionBestEst1rm = 0; + let sessionMaxWeight = 0; + let sessionVolume = 0; + + for (const set of completedSets) { + const weight = set.weight!; + const reps = set.reps; + const est1rm = estimatedOneRepMax(weight, reps); + + sessionBestEst1rm = Math.max(sessionBestEst1rm, est1rm); + sessionMaxWeight = Math.max(sessionMaxWeight, weight); + sessionVolume += weight * reps; + + // Update rep records + const existing = repRecords.get(reps); + if (!existing || weight > existing.weight) { + repRecords.set(reps, { + weight, + reps, + date: session.startTime, + estimated1rm: est1rm + }); + } + } + + est1rmOverTime.push({ date: session.startTime, value: sessionBestEst1rm }); + maxWeightOverTime.push({ date: session.startTime, value: sessionMaxWeight }); + totalVolumeOverTime.push({ date: session.startTime, value: sessionVolume }); + + bestEst1rm = Math.max(bestEst1rm, sessionBestEst1rm); + bestMaxWeight = Math.max(bestMaxWeight, sessionMaxWeight); + bestMaxVolume = Math.max(bestMaxVolume, sessionVolume); + } + + // Convert rep records to sorted array + const records = [...repRecords.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([reps, data]) => ({ + reps, + weight: data.weight, + date: data.date, + estimated1rm: data.estimated1rm + })); + + return json({ + charts: { + est1rmOverTime, + maxWeightOverTime, + totalVolumeOverTime + }, + personalRecords: { + estimatedOneRepMax: bestEst1rm, + maxWeight: bestMaxWeight, + maxVolume: bestMaxVolume + }, + records, + totalSessions: sessions.length + }); +}; diff --git a/src/routes/api/fitness/measurements/+server.ts b/src/routes/api/fitness/measurements/+server.ts new file mode 100644 index 00000000..f2dc9604 --- /dev/null +++ b/src/routes/api/fitness/measurements/+server.ts @@ -0,0 +1,55 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { BodyMeasurement } from '$models/BodyMeasurement'; + +export const GET: RequestHandler = async ({ url, locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + const limit = parseInt(url.searchParams.get('limit') || '50'); + const offset = parseInt(url.searchParams.get('offset') || '0'); + const from = url.searchParams.get('from'); + const to = url.searchParams.get('to'); + + const query: Record = { createdBy: user.nickname }; + + if (from || to) { + const dateFilter: Record = {}; + if (from) dateFilter.$gte = new Date(from); + if (to) dateFilter.$lte = new Date(to); + query.date = dateFilter; + } + + const measurements = await BodyMeasurement.find(query) + .sort({ date: -1 }) + .skip(offset) + .limit(limit) + .lean(); + + const total = await BodyMeasurement.countDocuments(query); + + return json({ measurements, total, limit, offset }); +}; + +export const POST: RequestHandler = async ({ request, locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + const data = await request.json(); + const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data; + + const measurement = new BodyMeasurement({ + date: date ? new Date(date) : new Date(), + weight, + bodyFatPercent, + caloricIntake, + measurements, + notes, + createdBy: user.nickname + }); + + await measurement.save(); + return json({ measurement }, { status: 201 }); +}; diff --git a/src/routes/api/fitness/measurements/[id]/+server.ts b/src/routes/api/fitness/measurements/[id]/+server.ts new file mode 100644 index 00000000..926c94c7 --- /dev/null +++ b/src/routes/api/fitness/measurements/[id]/+server.ts @@ -0,0 +1,78 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { BodyMeasurement } from '$models/BodyMeasurement'; +import mongoose from 'mongoose'; + +export const GET: RequestHandler = async ({ params, locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + if (!mongoose.Types.ObjectId.isValid(params.id)) { + return json({ error: 'Invalid measurement ID' }, { status: 400 }); + } + + const measurement = await BodyMeasurement.findOne({ + _id: params.id, + createdBy: user.nickname + }); + + if (!measurement) { + return json({ error: 'Measurement not found' }, { status: 404 }); + } + + return json({ measurement }); +}; + +export const PUT: RequestHandler = async ({ params, request, locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + if (!mongoose.Types.ObjectId.isValid(params.id)) { + return json({ error: 'Invalid measurement ID' }, { status: 400 }); + } + + const data = await request.json(); + const { date, weight, bodyFatPercent, caloricIntake, measurements, notes } = data; + + const updateData: Record = {}; + if (date) updateData.date = new Date(date); + if (weight !== undefined) updateData.weight = weight; + if (bodyFatPercent !== undefined) updateData.bodyFatPercent = bodyFatPercent; + if (caloricIntake !== undefined) updateData.caloricIntake = caloricIntake; + if (measurements !== undefined) updateData.measurements = measurements; + if (notes !== undefined) updateData.notes = notes; + + const measurement = await BodyMeasurement.findOneAndUpdate( + { _id: params.id, createdBy: user.nickname }, + updateData, + { new: true } + ); + + if (!measurement) { + return json({ error: 'Measurement not found or unauthorized' }, { status: 404 }); + } + + return json({ measurement }); +}; + +export const DELETE: RequestHandler = async ({ params, locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + if (!mongoose.Types.ObjectId.isValid(params.id)) { + return json({ error: 'Invalid measurement ID' }, { status: 400 }); + } + + const measurement = await BodyMeasurement.findOneAndDelete({ + _id: params.id, + createdBy: user.nickname + }); + + if (!measurement) { + return json({ error: 'Measurement not found or unauthorized' }, { status: 404 }); + } + + return json({ message: 'Measurement deleted successfully' }); +}; diff --git a/src/routes/api/fitness/measurements/latest/+server.ts b/src/routes/api/fitness/measurements/latest/+server.ts new file mode 100644 index 00000000..2421398c --- /dev/null +++ b/src/routes/api/fitness/measurements/latest/+server.ts @@ -0,0 +1,61 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { BodyMeasurement } from '$models/BodyMeasurement'; + +export const GET: RequestHandler = async ({ locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + // Get latest measurement that has each field + const latestWithWeight = await BodyMeasurement.findOne({ + createdBy: user.nickname, + weight: { $exists: true, $ne: null } + }) + .sort({ date: -1 }) + .select('weight date') + .lean(); + + const latestWithBodyFat = await BodyMeasurement.findOne({ + createdBy: user.nickname, + bodyFatPercent: { $exists: true, $ne: null } + }) + .sort({ date: -1 }) + .select('bodyFatPercent date') + .lean(); + + const latestWithCalories = await BodyMeasurement.findOne({ + createdBy: user.nickname, + caloricIntake: { $exists: true, $ne: null } + }) + .sort({ date: -1 }) + .select('caloricIntake date') + .lean(); + + const latestWithMeasurements = await BodyMeasurement.findOne({ + createdBy: user.nickname, + measurements: { $exists: true, $ne: null } + }) + .sort({ date: -1 }) + .select('measurements date') + .lean(); + + return json({ + weight: latestWithWeight + ? { value: latestWithWeight.weight, date: latestWithWeight.date } + : null, + bodyFatPercent: latestWithBodyFat + ? { value: latestWithBodyFat.bodyFatPercent, date: latestWithBodyFat.date } + : null, + caloricIntake: latestWithCalories + ? { value: latestWithCalories.caloricIntake, date: latestWithCalories.date } + : null, + measurements: latestWithMeasurements + ? { + value: latestWithMeasurements.measurements, + date: latestWithMeasurements.date + } + : null + }); +}; diff --git a/src/routes/api/fitness/stats/profile/+server.ts b/src/routes/api/fitness/stats/profile/+server.ts new file mode 100644 index 00000000..5daa1b25 --- /dev/null +++ b/src/routes/api/fitness/stats/profile/+server.ts @@ -0,0 +1,138 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { WorkoutSession } from '$models/WorkoutSession'; +import { BodyMeasurement } from '$models/BodyMeasurement'; + +export const GET: RequestHandler = async ({ locals }) => { + console.time('[stats/profile] total'); + + console.time('[stats/profile] auth'); + const user = await requireAuth(locals); + console.timeEnd('[stats/profile] auth'); + + console.time('[stats/profile] dbConnect'); + await dbConnect(); + console.timeEnd('[stats/profile] dbConnect'); + + const twelveWeeksAgo = new Date(); + twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 84); + + console.time('[stats/profile] countDocuments'); + const totalWorkouts = await WorkoutSession.countDocuments({ createdBy: user.nickname }); + console.timeEnd('[stats/profile] countDocuments'); + + console.time('[stats/profile] aggregate'); + const weeklyAgg = await WorkoutSession.aggregate([ + { + $match: { + createdBy: user.nickname, + startTime: { $gte: twelveWeeksAgo } + } + }, + { + $group: { + _id: { + year: { $isoWeekYear: '$startTime' }, + week: { $isoWeek: '$startTime' } + }, + count: { $sum: 1 } + } + }, + { + $sort: { '_id.year': 1, '_id.week': 1 } + } + ]); + console.timeEnd('[stats/profile] aggregate'); + + console.time('[stats/profile] measurements'); + const weightMeasurements = await BodyMeasurement.find( + { createdBy: user.nickname, weight: { $ne: null } }, + { date: 1, weight: 1, _id: 0 } + ) + .sort({ date: 1 }) + .limit(30) + .lean(); + console.timeEnd('[stats/profile] measurements'); + + // Build chart-ready workouts-per-week with filled gaps + const weekMap = new Map(); + for (const item of weeklyAgg) { + weekMap.set(`${item._id.year}-${item._id.week}`, item.count); + } + + const workoutsChart: { labels: string[]; data: number[] } = { labels: [], data: [] }; + const now = new Date(); + for (let i = 11; i >= 0; i--) { + const d = new Date(now); + d.setDate(d.getDate() - i * 7); + const year = getISOWeekYear(d); + const week = getISOWeek(d); + const key = `${year}-${week}`; + workoutsChart.labels.push(`W${week}`); + workoutsChart.data.push(weekMap.get(key) ?? 0); + } + + // Build chart-ready weight data with SMA ± 1 std dev confidence band + const weightChart: { + labels: string[]; + data: number[]; + sma: (number | null)[]; + upper: (number | null)[]; + lower: (number | null)[]; + } = { labels: [], data: [], sma: [], upper: [], lower: [] }; + const weights: number[] = []; + for (const m of weightMeasurements) { + const d = new Date(m.date); + weightChart.labels.push( + d.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ); + weightChart.data.push(m.weight); + weights.push(m.weight); + } + + // 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; + + let variance = 0; + for (let j = i - w + 1; j <= i; j++) variance += (weights[j] - mean) ** 2; + const std = Math.sqrt(variance / 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)); + } + } + + console.timeEnd('[stats/profile] total'); + return json({ + totalWorkouts, + workoutsChart, + weightChart + }); +}; + +function getISOWeek(date: Date): number { + const d = new Date(date.getTime()); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); + const week1 = new Date(d.getFullYear(), 0, 4); + return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); +} + +function getISOWeekYear(date: Date): number { + const d = new Date(date.getTime()); + d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); + return d.getFullYear(); +} diff --git a/src/routes/api/fitness/templates/seed/+server.ts b/src/routes/api/fitness/templates/seed/+server.ts new file mode 100644 index 00000000..abd32ac3 --- /dev/null +++ b/src/routes/api/fitness/templates/seed/+server.ts @@ -0,0 +1,163 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { requireAuth } from '$lib/server/middleware/auth'; +import { dbConnect } from '$utils/db'; +import { WorkoutTemplate } from '$models/WorkoutTemplate'; + +const defaultTemplates = [ + { + name: 'Day 1 - Pull', + description: 'Back and biceps focused pull day', + exercises: [ + { + exerciseId: 'bent-over-row-barbell', + sets: [ + { reps: 10, weight: 60, rpe: 7 }, + { reps: 10, weight: 60, rpe: 8 }, + { reps: 10, weight: 60, rpe: 9 } + ], + restTime: 120 + }, + { + exerciseId: 'pull-up', + sets: [ + { reps: 6, rpe: 8 }, + { reps: 6, rpe: 8 }, + { reps: 6, rpe: 9 } + ], + restTime: 120 + }, + { + exerciseId: 'lateral-raise-dumbbell', + sets: [ + { reps: 15, weight: 10, rpe: 7 }, + { reps: 15, weight: 10, rpe: 8 } + ], + restTime: 90 + }, + { + exerciseId: 'front-raise-dumbbell', + sets: [ + { reps: 10, weight: 10, rpe: 7 }, + { reps: 10, weight: 10, rpe: 8 } + ], + restTime: 90 + } + ] + }, + { + name: 'Day 2 - Push', + description: 'Chest, shoulders, and triceps push day', + exercises: [ + { + exerciseId: 'bench-press-barbell', + sets: [ + { reps: 8, weight: 80, rpe: 7 }, + { reps: 8, weight: 80, rpe: 8 }, + { reps: 8, weight: 80, rpe: 9 } + ], + restTime: 120 + }, + { + exerciseId: 'incline-bench-press-barbell', + sets: [ + { reps: 10, weight: 60, rpe: 7 }, + { reps: 10, weight: 60, rpe: 8 } + ], + restTime: 120 + }, + { + exerciseId: 'skullcrusher-dumbbell', + sets: [ + { reps: 15, weight: 15, rpe: 7 }, + { reps: 15, weight: 15, rpe: 8 } + ], + restTime: 90 + }, + { + exerciseId: 'hammer-curl-dumbbell', + sets: [ + { reps: 15, weight: 12, rpe: 7 }, + { reps: 15, weight: 12, rpe: 8 } + ], + restTime: 90 + }, + { + exerciseId: 'bicep-curl-dumbbell', + sets: [ + { reps: 15, weight: 10, rpe: 7 }, + { reps: 15, weight: 10, rpe: 8 } + ], + restTime: 90 + } + ] + }, + { + name: 'Day 3 - Legs', + description: 'Lower body leg day', + exercises: [ + { + exerciseId: 'squat-barbell', + sets: [ + { reps: 8, weight: 80, rpe: 7 }, + { reps: 8, weight: 80, rpe: 8 }, + { reps: 8, weight: 80, rpe: 9 } + ], + restTime: 150 + }, + { + exerciseId: 'romanian-deadlift-barbell', + sets: [ + { reps: 10, weight: 70, rpe: 7 }, + { reps: 10, weight: 70, rpe: 8 } + ], + restTime: 120 + }, + { + exerciseId: 'leg-press-machine', + sets: [ + { reps: 12, weight: 100, rpe: 7 }, + { reps: 12, weight: 100, rpe: 8 } + ], + restTime: 120 + }, + { + exerciseId: 'leg-curl-machine', + sets: [ + { reps: 12, weight: 40, rpe: 7 }, + { reps: 12, weight: 40, rpe: 8 } + ], + restTime: 90 + }, + { + exerciseId: 'calf-raise-machine', + sets: [ + { reps: 15, weight: 60, rpe: 7 }, + { reps: 15, weight: 60, rpe: 8 } + ], + restTime: 60 + } + ] + } +]; + +export const POST: RequestHandler = async ({ locals }) => { + const user = await requireAuth(locals); + await dbConnect(); + + // Check if user already has templates (don't re-seed) + const existingCount = await WorkoutTemplate.countDocuments({ createdBy: user.nickname }); + if (existingCount > 0) { + return json({ message: 'Templates already exist', seeded: false }); + } + + const templates = await WorkoutTemplate.insertMany( + defaultTemplates.map((t) => ({ + ...t, + createdBy: user.nickname, + isDefault: true + })) + ); + + return json({ message: 'Default templates created', templates, seeded: true }, { status: 201 }); +}; diff --git a/src/routes/fitness/+layout.server.ts b/src/routes/fitness/+layout.server.ts new file mode 100644 index 00000000..4ec3500e --- /dev/null +++ b/src/routes/fitness/+layout.server.ts @@ -0,0 +1,7 @@ +import type { LayoutServerLoad } from './$types'; + +export const load: LayoutServerLoad = async ({ locals }) => { + return { + session: await locals.auth() + }; +}; diff --git a/src/routes/fitness/+layout.svelte b/src/routes/fitness/+layout.svelte new file mode 100644 index 00000000..1a50836f --- /dev/null +++ b/src/routes/fitness/+layout.svelte @@ -0,0 +1,69 @@ + + +
+ {#snippet links()} + + {/snippet} + + {#snippet right_side()} + + {/snippet} + +
+ {@render children()} +
+
+ +{#if workout.active && !isOnActivePage} + +{/if} + + diff --git a/src/routes/fitness/+page.server.ts b/src/routes/fitness/+page.server.ts new file mode 100644 index 00000000..700e889b --- /dev/null +++ b/src/routes/fitness/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + redirect(302, '/fitness/workout'); +}; diff --git a/src/routes/fitness/exercises/+page.server.ts b/src/routes/fitness/exercises/+page.server.ts new file mode 100644 index 00000000..9fed75c3 --- /dev/null +++ b/src/routes/fitness/exercises/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch }) => { + const res = await fetch('/api/fitness/exercises'); + return { + exercises: await res.json() + }; +}; diff --git a/src/routes/fitness/exercises/+page.svelte b/src/routes/fitness/exercises/+page.svelte new file mode 100644 index 00000000..c725e76d --- /dev/null +++ b/src/routes/fitness/exercises/+page.svelte @@ -0,0 +1,152 @@ + + +
+

Exercises

+ + + +
+ + +
+ + +
+ + diff --git a/src/routes/fitness/exercises/[id]/+page.server.ts b/src/routes/fitness/exercises/[id]/+page.server.ts new file mode 100644 index 00000000..f32b3059 --- /dev/null +++ b/src/routes/fitness/exercises/[id]/+page.server.ts @@ -0,0 +1,20 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const [exerciseRes, historyRes, statsRes] = await Promise.all([ + fetch(`/api/fitness/exercises/${params.id}`), + fetch(`/api/fitness/exercises/${params.id}/history?limit=20`), + fetch(`/api/fitness/exercises/${params.id}/stats`) + ]); + + if (!exerciseRes.ok) { + error(404, 'Exercise not found'); + } + + return { + exercise: await exerciseRes.json(), + history: await historyRes.json(), + stats: await statsRes.json() + }; +}; diff --git a/src/routes/fitness/exercises/[id]/+page.svelte b/src/routes/fitness/exercises/[id]/+page.svelte new file mode 100644 index 00000000..05e7381b --- /dev/null +++ b/src/routes/fitness/exercises/[id]/+page.svelte @@ -0,0 +1,367 @@ + + +
+

{exercise?.name ?? 'Exercise'}

+ +
+ {#each tabs as tab} + + {/each} +
+ + {#if activeTab === 'about'} +
+ {#if exercise?.imageUrl} + {exercise.name} + {/if} +
+ {exercise?.bodyPart} + {exercise?.equipment} + {exercise?.target} +
+ {#if exercise?.secondaryMuscles?.length} +

Also works: {exercise.secondaryMuscles.join(', ')}

+ {/if} + {#if exercise?.instructions?.length} +

Instructions

+
    + {#each exercise.instructions as step} +
  1. {step}
  2. + {/each} +
+ {/if} +
+ {:else if activeTab === 'history'} +
+ {#if history.length === 0} +

No history for this exercise yet.

+ {:else} + {#each history as entry (entry.sessionId)} +
+
+ {entry.sessionName || 'Workout'} + {new Date(entry.date).toLocaleDateString()} +
+ + + + + + {#each entry.sets as set, i (i)} + + + + + + + {/each} + +
SETKGREPSEST. 1RM
{i + 1}{set.weight}{set.reps}{#if set.rpe} @{set.rpe}{/if}{epley1rm(set.weight, set.reps)} kg
+
+ {/each} + {/if} +
+ {:else if activeTab === 'charts'} +
+ {#if (charts.est1rmOverTime?.length ?? 0) > 0} + + + + {:else} +

Not enough data to display charts yet.

+ {/if} +
+ {:else if activeTab === 'records'} +
+
+ {#if prs.estimatedOneRepMax} +
+ Estimated 1RM + {prs.estimatedOneRepMax} kg +
+ {/if} + {#if prs.maxVolume} +
+ Max Volume + {prs.maxVolume} kg +
+ {/if} + {#if prs.maxWeight} +
+ Max Weight + {prs.maxWeight} kg +
+ {/if} +
+ + {#if records.length} +

Rep Records

+ + + + + + {#each records as rec (rec.reps)} + + + + + + {/each} + +
REPSBEST PERFORMANCEEST. 1RM
{rec.reps}{rec.weight} kg × {rec.reps}{#if rec.date} ({new Date(rec.date).toLocaleDateString()}){/if}{rec.estimated1rm ?? epley1rm(rec.weight, rec.reps)} kg
+ {/if} +
+ {/if} +
+ + diff --git a/src/routes/fitness/history/+page.server.ts b/src/routes/fitness/history/+page.server.ts new file mode 100644 index 00000000..febbe1e7 --- /dev/null +++ b/src/routes/fitness/history/+page.server.ts @@ -0,0 +1,12 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch, url }) => { + const month = url.searchParams.get('month') || ''; + const params = new URLSearchParams({ limit: '50' }); + if (month) params.set('month', month); + + const res = await fetch(`/api/fitness/sessions?${params}`); + return { + sessions: await res.json() + }; +}; diff --git a/src/routes/fitness/history/+page.svelte b/src/routes/fitness/history/+page.svelte new file mode 100644 index 00000000..e90cc22a --- /dev/null +++ b/src/routes/fitness/history/+page.svelte @@ -0,0 +1,111 @@ + + +
+

History

+ + {#if sessions.length === 0} +

No workouts yet. Start your first workout!

+ {:else} + {#each Object.entries(grouped) as [month, monthSessions] (month)} +
+

{month} — {monthSessions.length} workout{monthSessions.length !== 1 ? 's' : ''}

+
+ {#each monthSessions as session (session._id)} + + {/each} +
+
+ {/each} + + {#if sessions.length < total} + + {/if} + {/if} +
+ + diff --git a/src/routes/fitness/history/[id]/+page.server.ts b/src/routes/fitness/history/[id]/+page.server.ts new file mode 100644 index 00000000..1fe78d34 --- /dev/null +++ b/src/routes/fitness/history/[id]/+page.server.ts @@ -0,0 +1,14 @@ +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params, fetch }) => { + const res = await fetch(`/api/fitness/sessions/${params.id}`); + + if (!res.ok) { + error(404, 'Session not found'); + } + + return { + session: (await res.json()).session + }; +}; diff --git a/src/routes/fitness/history/[id]/+page.svelte b/src/routes/fitness/history/[id]/+page.svelte new file mode 100644 index 00000000..5047bd84 --- /dev/null +++ b/src/routes/fitness/history/[id]/+page.svelte @@ -0,0 +1,313 @@ + + +
+
+
+

{session.name}

+

{formatDate(session.startTime)} · {formatTime(session.startTime)}

+
+ +
+ +
+ {#if session.duration} +
+ + {formatDuration(session.duration)} +
+ {/if} + {#if session.totalVolume} +
+ + {Math.round(session.totalVolume).toLocaleString()} kg +
+ {/if} + {#if session.prs?.length > 0} +
+ + {session.prs.length} PR{session.prs.length !== 1 ? 's' : ''} +
+ {/if} +
+ + {#each session.exercises as ex, exIdx (ex.exerciseId + '-' + exIdx)} +
+

+ +

+ + + + + + + + + + + + {#each ex.sets as set, i (i)} + + + + + + + + {/each} + +
SETKGREPSRPEEST. 1RM
{i + 1}{set.weight ?? '—'}{set.reps ?? '—'}{set.rpe ?? '—'}{set.weight && set.reps ? epley1rm(set.weight, set.reps) : '—'}
+
+ {/each} + + {#if session.prs?.length > 0} +
+

Personal Records

+
+ {#each session.prs as pr (pr.exerciseId + pr.type)} + {@const exercise = getExerciseById(pr.exerciseId)} +
+ + {exercise?.name ?? pr.exerciseId} + + {#if pr.type === 'est1rm'}Est. 1RM + {:else if pr.type === 'maxWeight'}Max Weight + {:else if pr.type === 'repMax'}{pr.reps}-rep max + {:else}{pr.type}{/if} + + {pr.value} kg +
+ {/each} +
+
+ {/if} + + {#if session.notes} +
+

Notes

+

{session.notes}

+
+ {/if} +
+ + diff --git a/src/routes/fitness/measure/+page.server.ts b/src/routes/fitness/measure/+page.server.ts new file mode 100644 index 00000000..9cb4865b --- /dev/null +++ b/src/routes/fitness/measure/+page.server.ts @@ -0,0 +1,13 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch }) => { + const [latestRes, listRes] = await Promise.all([ + fetch('/api/fitness/measurements/latest'), + fetch('/api/fitness/measurements?limit=20') + ]); + + return { + latest: await latestRes.json(), + measurements: await listRes.json() + }; +}; diff --git a/src/routes/fitness/measure/+page.svelte b/src/routes/fitness/measure/+page.svelte new file mode 100644 index 00000000..d5c5c15b --- /dev/null +++ b/src/routes/fitness/measure/+page.svelte @@ -0,0 +1,364 @@ + + +
+

Measure

+ + {#if showForm} +
{ e.preventDefault(); saveMeasurement(); }}> +
+ + +
+ +

General

+
+
+ + +
+
+ + +
+
+ + +
+
+ +

Body Parts (cm)

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ {/if} + +
+

Latest

+
+
+ Weight + {latest.weight?.value ?? '—'} kg +
+
+ Body Fat + {latest.bodyFatPercent?.value ?? '—'}% +
+
+ Calories + {latest.caloricIntake?.value ?? '—'} kcal +
+
+
+ + {#if bodyPartFields.some(f => f.value != null)} +
+

Body Parts

+
+ {#each bodyPartFields.filter(f => f.value != null) as field} +
+ {field.label} + {field.value} cm +
+ {/each} +
+
+ {/if} +
+ +{#if !workout.active} + showForm = !showForm} ariaLabel="Add measurement" /> +{/if} + + diff --git a/src/routes/fitness/profile/+page.server.ts b/src/routes/fitness/profile/+page.server.ts new file mode 100644 index 00000000..acd6a36b --- /dev/null +++ b/src/routes/fitness/profile/+page.server.ts @@ -0,0 +1,20 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch, locals }) => { + console.time('[profile] total load'); + + console.time('[profile] auth'); + const session = await locals.auth(); + console.timeEnd('[profile] auth'); + + console.time('[profile] fetch /api/fitness/stats/profile'); + const res = await fetch('/api/fitness/stats/profile'); + console.timeEnd('[profile] fetch /api/fitness/stats/profile'); + + console.time('[profile] parse json'); + const stats = await res.json(); + console.timeEnd('[profile] parse json'); + + console.timeEnd('[profile] total load'); + return { session, stats }; +}; diff --git a/src/routes/fitness/profile/+page.svelte b/src/routes/fitness/profile/+page.svelte new file mode 100644 index 00000000..0fe32e39 --- /dev/null +++ b/src/routes/fitness/profile/+page.svelte @@ -0,0 +1,162 @@ + + +
+

Profile

+ +
+ {#if user} + {user.name} + + {/if} +
+ +

Dashboard

+ + {#if (stats.workoutsChart?.data?.length ?? 0) > 0} + + {:else} +

No workout data to display yet.

+ {/if} + + {#if (stats.weightChart?.data?.length ?? 0) > 1} + + {/if} +
+ + diff --git a/src/routes/fitness/workout/+page.server.ts b/src/routes/fitness/workout/+page.server.ts new file mode 100644 index 00000000..f13762ac --- /dev/null +++ b/src/routes/fitness/workout/+page.server.ts @@ -0,0 +1,8 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch }) => { + const res = await fetch('/api/fitness/templates'); + return { + templates: await res.json() + }; +}; diff --git a/src/routes/fitness/workout/+page.svelte b/src/routes/fitness/workout/+page.svelte new file mode 100644 index 00000000..40bd3661 --- /dev/null +++ b/src/routes/fitness/workout/+page.svelte @@ -0,0 +1,640 @@ + + +
+
+ +
+ +
+

Templates

+ {#if templates.length > 0} +

My Templates ({templates.length})

+
+ {#each templates as template (template._id)} + openTemplateDetail(template)} + /> + {/each} +
+ {:else} +

No templates yet. Create one or start an empty workout.

+ {/if} +
+
+ + +{#if selectedTemplate} + + +{/if} + + +{#if showTemplateEditor} + + + + {#if editorPicker} + { editorAddExercise(id); editorPicker = false; }} + onClose={() => editorPicker = false} + /> + {/if} +{/if} + +{#if !workout.active} + +{/if} + + diff --git a/src/routes/fitness/workout/active/+page.svelte b/src/routes/fitness/workout/active/+page.svelte new file mode 100644 index 00000000..238b2168 --- /dev/null +++ b/src/routes/fitness/workout/active/+page.svelte @@ -0,0 +1,352 @@ + + +{#if workout.active} +
+
+
+ + {formatElapsed(workout.elapsedSeconds)} +
+ +
+ + + + {#if workout.restTimerActive} +
+ workout.cancelRestTimer()} + /> + +
+ {/if} + + {#each workout.exercises as ex, exIdx (exIdx)} +
+
+ + +
+ + workout.updateSet(exIdx, setIdx, d)} + onToggleComplete={(setIdx) => { + workout.toggleSetComplete(exIdx, setIdx); + if (ex.sets[setIdx]?.completed && !workout.restTimerActive) { + workout.startRestTimer(ex.restTime); + } + }} + /> + + +
+ {/each} + +
+ + +
+
+{/if} + +{#if showPicker} + showPicker = false} + /> +{/if} + +