Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export class AdminController {
return this.adminService.getActivity();
}

@Get('estimation')
getEstimation() {
return this.adminService.getEstimationAnalysis();
}

@Get('users')
getAllUsers() {
return this.adminService.getAllUsers();
Expand Down
118 changes: 118 additions & 0 deletions backend/src/admin/admin.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,124 @@ export class AdminService {
};
}

/**
* Estimation-accuracy analysis. Two complementary views:
* - retro: replay the (unchanged) algorithm over existing history — for each
* exercise predict each session's weight from the previous session and
* compare to what was actually lifted.
* - live: for sets logged since we began storing `suggestedWeight`, compare
* the shown suggestion to the actual weight (captures user overrides too).
*/
async getEstimationAnalysis() {
const users = await this.members();
const retro: any[] = [];
const live: any[] = [];
const r1 = (n: number) => Math.round(n * 10) / 10;

for (const u of users) {
const sessions = (await this.workoutsService.getUserSessions(u.id))
.filter((s) => s.completedAt)
.sort((a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime());

const prevByExercise: Record<string, any[]> = {};
for (const s of sessions) {
const working = (s.sets || []).filter((x: any) => !x.isWarmup);
const byEx: Record<string, any[]> = {};
working.forEach((st: any) => { (byEx[st.exerciseName] ||= []).push(st); });

for (const [ex, sets] of Object.entries(byEx)) {
const prev = prevByExercise[ex];
if (prev && prev.length) {
const pred = this.workoutsService.predictExerciseWeight(prev as any, ex, u.weightKg);
const actual = sets.reduce((a, st) => a + st.weightKg, 0) / sets.length;
if (pred.weightKg > 0 && actual > 0) {
retro.push({ user: u.name || u.email, exercise: ex, predicted: r1(pred.weightKg), actual: r1(actual), date: s.startedAt });
}
}
prevByExercise[ex] = sets;
}

// Live: stored suggestion vs actual
working.forEach((st: any) => {
if (st.suggestedWeight != null && st.suggestedWeight > 0) {
live.push({ user: u.name || u.email, exercise: st.exerciseName, suggested: r1(st.suggestedWeight), actual: r1(st.weightKg), date: s.startedAt });
}
});
}
}

return { retro: this.summarizePredictions(retro), live: this.summarizeLive(live) };
}

private withinTolerance(predicted: number, actual: number) {
return Math.abs(actual - predicted) <= Math.max(2.5, actual * 0.05);
}

private summarizePredictions(samples: any[]) {
const n = samples.length;
if (!n) return { count: 0, perExercise: [], buckets: [], samples: [] };
let absSum = 0, signedSum = 0, correct = 0;
const bucketDefs = [
{ label: '≤ -5', test: (e: number) => e <= -5 },
{ label: '-5…-2.5', test: (e: number) => e > -5 && e <= -2.5 },
{ label: '-2.5…0', test: (e: number) => e > -2.5 && e < 0 },
{ label: 'spot on', test: (e: number) => e === 0 },
{ label: '0…2.5', test: (e: number) => e > 0 && e < 2.5 },
{ label: '2.5…5', test: (e: number) => e >= 2.5 && e < 5 },
{ label: '≥ 5', test: (e: number) => e >= 5 },
];
const buckets = bucketDefs.map((b) => ({ label: b.label, count: 0 }));
const perEx: Record<string, { exercise: string; n: number; biasSum: number; correct: number }> = {};

for (const s of samples) {
const err = s.actual - s.predicted; // +ve = lifted more than predicted
absSum += Math.abs(err);
signedSum += err;
const ok = this.withinTolerance(s.predicted, s.actual);
if (ok) correct++;
buckets[bucketDefs.findIndex((b) => b.test(err))].count++;
const pe = (perEx[s.exercise] ||= { exercise: s.exercise, n: 0, biasSum: 0, correct: 0 });
pe.n++; pe.biasSum += err; if (ok) pe.correct++;
}

const perExercise = Object.values(perEx)
.map((p) => ({ exercise: p.exercise, n: p.n, bias: Math.round((p.biasSum / p.n) * 10) / 10, accuracy: Math.round((p.correct / p.n) * 100) }))
.sort((a, b) => b.n - a.n);

return {
count: n,
accuracy: Math.round((correct / n) * 100),
meanAbsError: Math.round((absSum / n) * 10) / 10,
bias: Math.round((signedSum / n) * 10) / 10,
perExercise,
buckets,
samples: [...samples].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 25),
};
}

private summarizeLive(samples: any[]) {
const n = samples.length;
if (!n) return { count: 0, kept: 0, increased: 0, decreased: 0, meanOverride: 0, accuracy: 0, samples: [] };
let kept = 0, up = 0, down = 0, overrideSum = 0, correct = 0;
for (const s of samples) {
const diff = s.actual - s.suggested;
if (Math.abs(diff) < 0.01) kept++;
else if (diff > 0) up++;
else down++;
overrideSum += diff;
if (this.withinTolerance(s.suggested, s.actual)) correct++;
}
return {
count: n,
kept: Math.round((kept / n) * 100),
increased: Math.round((up / n) * 100),
decreased: Math.round((down / n) * 100),
meanOverride: Math.round((overrideSum / n) * 10) / 10,
accuracy: Math.round((correct / n) * 100),
samples: [...samples].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).slice(0, 25),
};
}

async getUserReport(userId: number, period: 'weekly' | 'monthly') {
const user = await this.usersService.findById(userId);
if (!user) throw new Error('User not found');
Expand Down
5 changes: 5 additions & 0 deletions backend/src/workouts/workout-set.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export class WorkoutSet {
@Column({ default: false })
isWarmup: boolean;

// The weight the estimator suggested when this set was logged (hint shown to
// the user). Captured for accuracy analysis; null for warm-ups / off-plan.
@Column({ type: 'real', nullable: true })
suggestedWeight: number;

@CreateDateColumn()
loggedAt: Date;
}
4 changes: 2 additions & 2 deletions backend/src/workouts/workouts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,10 @@ export class WorkoutsController {
logSet(
@CurrentUser() user: User,
@Param('id', ParseIntPipe) sessionId: number,
@Body() body: { exerciseName: string; setNumber: number; actualReps: number; weightKg: number; targetReps?: number; isWarmup?: boolean },
@Body() body: { exerciseName: string; setNumber: number; actualReps: number; weightKg: number; targetReps?: number; isWarmup?: boolean; suggestedWeight?: number },
) {
return this.workoutsService.logSet(
sessionId, user.id, body.exerciseName, body.setNumber, body.actualReps, body.weightKg, body.targetReps, body.isWarmup,
sessionId, user.id, body.exerciseName, body.setNumber, body.actualReps, body.weightKg, body.targetReps, body.isWarmup, body.suggestedWeight,
);
}

Expand Down
63 changes: 36 additions & 27 deletions backend/src/workouts/workouts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,16 @@ export class WorkoutsService {
weightKg: number,
targetReps?: number,
isWarmup = false,
suggestedWeight?: number,
) {
// Verify session belongs to user
const session = await this.sessionRepo.findOne({ where: { id: sessionId, userId } });
if (!session) throw new NotFoundException('Session not found');

const set = this.setRepo.create({ sessionId, exerciseName, setNumber, actualReps, weightKg, targetReps, isWarmup });
const set = this.setRepo.create({
sessionId, exerciseName, setNumber, actualReps, weightKg, targetReps, isWarmup,
suggestedWeight: isWarmup ? null : suggestedWeight ?? null,
});
Comment on lines +94 to +97
const saved = await this.setRepo.save(set);

// Warm-up sets are ramp-up only — they never count toward PRs.
Expand Down Expand Up @@ -185,37 +189,42 @@ export class WorkoutsService {
});

for (const [exercise, sets] of Object.entries(exerciseMap)) {
const totalTargetReps = sets.reduce((sum, s) => sum + (s.targetReps || s.actualReps), 0);
const totalActualReps = sets.reduce((sum, s) => sum + s.actualReps, 0);
const completionRate = totalTargetReps > 0 ? totalActualReps / totalTargetReps : 1;
const avgWeight = sets.reduce((sum, s) => sum + s.weightKg, 0) / sets.length;
const avgReps = Math.round(totalActualReps / sets.length);

// Determine user's relative strength vs baseline
const relativeBonus = user?.weightKg ? this.getRelativeStrengthBonus(exercise, avgWeight, user.weightKg) : 1;

let suggestedWeight = avgWeight;
let reason = '';

if (completionRate >= 1.0) {
const increment = this.getIncrement(exercise) * relativeBonus;
suggestedWeight = Math.round((avgWeight + increment) * 4) / 4; // round to nearest 0.25
reason = `Great job! All reps completed. Increase by ${increment}kg.`;
} else if (completionRate >= 0.8) {
suggestedWeight = avgWeight;
reason = `Good effort. Stay at same weight — aim to hit all reps next time.`;
} else {
const deload = avgWeight * 0.9;
suggestedWeight = Math.round(deload * 4) / 4;
reason = `Tough session. Reduced weight by 10% to ensure proper form.`;
}

suggestions[exercise] = { weightKg: suggestedWeight, reps: avgReps, reason };
suggestions[exercise] = this.predictExerciseWeight(sets, exercise, user?.weightKg);
}

return { workoutType, suggestions, basedOn: lastSession.startedAt };
}

/**
* Pure double-progression prediction for one exercise from a prior session's
* working sets. Single source of truth used by both live suggestions and the
* admin estimation-accuracy analysis. (Behaviour unchanged from before.)
*/
predictExerciseWeight(sets: WorkoutSet[], exercise: string, bodyWeight?: number) {
const totalTargetReps = sets.reduce((sum, s) => sum + (s.targetReps || s.actualReps), 0);
const totalActualReps = sets.reduce((sum, s) => sum + s.actualReps, 0);
const completionRate = totalTargetReps > 0 ? totalActualReps / totalTargetReps : 1;
const avgWeight = sets.reduce((sum, s) => sum + s.weightKg, 0) / sets.length;
const avgReps = Math.round(totalActualReps / sets.length);

const relativeBonus = bodyWeight ? this.getRelativeStrengthBonus(exercise, avgWeight, bodyWeight) : 1;

let weightKg = avgWeight;
let reason = '';
if (completionRate >= 1.0) {
const increment = this.getIncrement(exercise) * relativeBonus;
weightKg = Math.round((avgWeight + increment) * 4) / 4;
reason = `Great job! All reps completed. Increase by ${increment}kg.`;
} else if (completionRate >= 0.8) {
weightKg = avgWeight;
reason = 'Good effort. Stay at same weight — aim to hit all reps next time.';
} else {
weightKg = Math.round(avgWeight * 0.9 * 4) / 4;
reason = 'Tough session. Reduced weight by 10% to ensure proper form.';
}
return { weightKg, reps: avgReps, reason };
}

private getIncrement(exercise: string): number {
const compound = ['bench', 'squat', 'deadlift', 'row', 'press'];
const isCompound = compound.some((c) => exercise.toLowerCase().includes(c));
Expand Down
2 changes: 1 addition & 1 deletion frontend/public/sw.js

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions frontend/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import {
Users, Dumbbell, Plus, Mail, Trash2, Shield, BarChart2, TrendingUp,
CheckCircle, Clock, Pencil, X, Save, Send, LogOut, Search, Copy, UserPlus,
Activity, Zap, RefreshCw, Layers, ChevronRight, Flame, Trophy,
Activity, Zap, RefreshCw, Layers, ChevronRight, Flame, Trophy, Target,
} from 'lucide-react';
import {
AreaChart, Area, BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
Expand All @@ -22,8 +22,9 @@ import { AnimatedNumber } from '@/components/ui/animated-number';
import { spring } from '@/lib/motion';
import MemberDetailModal from '@/components/admin/member-detail-modal';
import AssignPlanModal from '@/components/admin/assign-plan-modal';
import EstimationPanel from '@/components/admin/estimation-panel';

type Tab = 'overview' | 'members' | 'plans' | 'insights';
type Tab = 'overview' | 'members' | 'plans' | 'insights' | 'estimation';
type SortKey = 'recent' | 'name' | 'onboarding' | 'volume';
type FilterKey = 'all' | 'active' | 'pending';

Expand Down Expand Up @@ -163,6 +164,7 @@ export default function AdminPage() {
{ key: 'members' as Tab, label: 'Members', icon: <Users size={15} /> },
{ key: 'plans' as Tab, label: 'Plans', icon: <Dumbbell size={15} /> },
{ key: 'insights' as Tab, label: 'Insights', icon: <BarChart2 size={15} /> },
{ key: 'estimation' as Tab, label: 'Estimation', icon: <Target size={15} /> },
];

return (
Expand Down Expand Up @@ -485,6 +487,9 @@ export default function AdminPage() {
)}
</div>
)}

{/* ───────── Estimation ───────── */}
{activeTab === 'estimation' && <EstimationPanel />}
</motion.div>
</AnimatePresence>

Expand Down
6 changes: 4 additions & 2 deletions frontend/src/app/workout/session/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,9 @@ export default function SessionPage() {
const logWorking = async (ex: PlanExercise, slot: Extract<WSlot, { type: 'pending' }>) => {
const w = parseFloat(slot.weight || slot.phW), r = parseInt(slot.reps || slot.phR);
if (!w || !r) return;
const sug = parseFloat(slot.phW); // the hint we showed — recorded for accuracy analysis
try {
await workoutsApi.logSet(sessionId, { exerciseName: ex.name, setNumber: slot.setNumber, actualReps: r, weightKg: w, targetReps: slot.target, isWarmup: false });
await workoutsApi.logSet(sessionId, { exerciseName: ex.name, setNumber: slot.setNumber, actualReps: r, weightKg: w, targetReps: slot.target, isWarmup: false, suggestedWeight: isNaN(sug) ? undefined : sug });
const data = await refresh();
setTimer(0); setTimerActive(true);
const done = (data.sets || []).filter((s: any) => s.exerciseName === ex.name && !s.isWarmup).length;
Expand Down Expand Up @@ -224,7 +225,8 @@ export default function SessionPage() {
for (const s of pend) {
const w = parseFloat(s.weight || s.phW), r = parseInt(s.reps || s.phR);
if (!w || !r) continue;
await workoutsApi.logSet(sessionId, { exerciseName: ex.name, setNumber: s.setNumber, actualReps: r, weightKg: w, targetReps: s.target, isWarmup: false });
const sug = parseFloat(s.phW);
await workoutsApi.logSet(sessionId, { exerciseName: ex.name, setNumber: s.setNumber, actualReps: r, weightKg: w, targetReps: s.target, isWarmup: false, suggestedWeight: isNaN(sug) ? undefined : sug });
}
const data = await refresh();
setTimer(0); setTimerActive(true);
Expand Down
Loading