From 1470f2627ccc405f505584722a3520628e9e876e Mon Sep 17 00:00:00 2001 From: Rezwoan Date: Wed, 10 Jun 2026 13:38:44 +0600 Subject: [PATCH] feat(admin): estimation-accuracy tab + suggestion data collection - Persist suggestedWeight on each working set (data collection; algorithm unchanged) - Extract predictExerciseWeight as shared pure helper (single source of truth) - New GET /admin/estimation: retrospective replay (predicted vs actual from history) + live suggestion tracking (kept/increased/decreased, override, accuracy) - Estimation admin tab: KPIs, error distribution, per-exercise bias/accuracy, recent samples --- backend/src/admin/admin.controller.ts | 5 + backend/src/admin/admin.service.ts | 118 +++++++++++++ backend/src/workouts/workout-set.entity.ts | 5 + backend/src/workouts/workouts.controller.ts | 4 +- backend/src/workouts/workouts.service.ts | 63 ++++--- frontend/public/sw.js | 2 +- frontend/src/app/admin/page.tsx | 9 +- .../src/app/workout/session/[id]/page.tsx | 6 +- .../src/components/admin/estimation-panel.tsx | 158 ++++++++++++++++++ frontend/src/lib/api.ts | 1 + 10 files changed, 337 insertions(+), 34 deletions(-) create mode 100644 frontend/src/components/admin/estimation-panel.tsx diff --git a/backend/src/admin/admin.controller.ts b/backend/src/admin/admin.controller.ts index 96b6254..2865aee 100644 --- a/backend/src/admin/admin.controller.ts +++ b/backend/src/admin/admin.controller.ts @@ -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(); diff --git a/backend/src/admin/admin.service.ts b/backend/src/admin/admin.service.ts index 0253f31..dd3c911 100644 --- a/backend/src/admin/admin.service.ts +++ b/backend/src/admin/admin.service.ts @@ -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 = {}; + for (const s of sessions) { + const working = (s.sets || []).filter((x: any) => !x.isWarmup); + const byEx: Record = {}; + 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 = {}; + + 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'); diff --git a/backend/src/workouts/workout-set.entity.ts b/backend/src/workouts/workout-set.entity.ts index 21c0193..598bb47 100644 --- a/backend/src/workouts/workout-set.entity.ts +++ b/backend/src/workouts/workout-set.entity.ts @@ -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; } diff --git a/backend/src/workouts/workouts.controller.ts b/backend/src/workouts/workouts.controller.ts index 1bb9435..f5a3939 100644 --- a/backend/src/workouts/workouts.controller.ts +++ b/backend/src/workouts/workouts.controller.ts @@ -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, ); } diff --git a/backend/src/workouts/workouts.service.ts b/backend/src/workouts/workouts.service.ts index fc7c96d..d734fde 100644 --- a/backend/src/workouts/workouts.service.ts +++ b/backend/src/workouts/workouts.service.ts @@ -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, + }); const saved = await this.setRepo.save(set); // Warm-up sets are ramp-up only — they never count toward PRs. @@ -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)); diff --git a/frontend/public/sw.js b/frontend/public/sw.js index fff12d2..6e03dbe 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1 +1 @@ -if(!self.define){let e,s={};const i=(i,r)=>(i=new URL(i+".js",r).href,s[i]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=s,document.head.appendChild(e)}else e=i,importScripts(i),s()}).then(()=>{let e=s[i];if(!e)throw new Error(`Module ${i} didn’t register its module`);return e}));self.define=(r,c)=>{const a=e||("document"in self?document.currentScript.src:"")||location.href;if(s[a])return;let n={};const t=e=>i(e,a),o={module:{uri:a},exports:n,require:t};s[a]=Promise.all(r.map(e=>o[e]||t(e))).then(e=>(c(...e),n))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"df0c0fe8d623f8cae15ba0e79d5dffad"},{url:"/_next/static/chunks/117-9ea064cc63abc227.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/144-1e10cdbd890ba14b.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/306-9dbf591ad5e989ff.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/307-317ad926a638a060.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/319-d608e8f7c0ae7eba.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/400-cdaed14ba7708421.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/446-4455569f1b8cf4a0.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/455-4cadccc542b31c7c.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/624-b56181ca92eeb599.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/741-82d99277719e7009.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/917-aaea7ebbca180952.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/983-200e258cb54b4d52.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/_not-found/page-6f54315fdd5b7641.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/achievements/layout-31257e4db3aba299.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/achievements/page-a63bc3b856308d76.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/admin/layout-35413ec8bb1e790f.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/admin/page-8f32348009e97434.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/dashboard/layout-406fed96a8b8319d.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/dashboard/page-a1f16ef56cb0188b.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/layout-950303e8883c3140.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/leaderboard/layout-66fefb23eeb30745.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/leaderboard/page-9dce333dface59ae.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/login/page-fdf6f5686e1241f3.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/onboarding/layout-d19b988070455211.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/onboarding/page-438b6b714f91c9e0.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/page-613d88c7e7c9812d.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/profile/layout-94fd036ca4e69fdf.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/profile/page-0b51e15ae3e723e4.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/workout/layout-d0c9fca5e2a9866b.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/workout/page-7ab9cf73008dc1f1.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/workout/preview/%5Bid%5D/page-5622e962bf194e93.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/workout/session/%5Bid%5D/layout-7b422927639e1703.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/app/workout/session/%5Bid%5D/page-7daf50ccb8fcd59c.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/fd9d1056-b955c697e5096532.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/framework-00a8ba1a63cfdc9e.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/main-app-b67aa9b4d6987144.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/main-c93ce94f23572fe2.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/pages/_app-15e2daefa259f0b5.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/pages/_error-28b803cb2479b966.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-48b8eac261f765f7.js",revision:"oI26rlVrfMrSiwSdcFY2u"},{url:"/_next/static/css/15a99d0dd190ccc2.css",revision:"15a99d0dd190ccc2"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/3dc379dc9b5dec12-s.p.woff2",revision:"f544d77b0e9b284f5c9f06c0905d9f41"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5f10e9e72d35c52-s.woff2",revision:"806809d847e91b23af100c0aa2f40c56"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/_next/static/oI26rlVrfMrSiwSdcFY2u/_buildManifest.js",revision:"172e769da91baa11de9b258fb2d92f86"},{url:"/_next/static/oI26rlVrfMrSiwSdcFY2u/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/icon.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/icons/icon-192.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/icons/icon-512.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/manifest.json",revision:"0659b845bfac3e9d0f33a91277a5f983"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:i,state:r})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); +if(!self.define){let e,s={};const a=(a,n)=>(a=new URL(a+".js",n).href,s[a]||new Promise(s=>{if("document"in self){const e=document.createElement("script");e.src=a,e.onload=s,document.head.appendChild(e)}else e=a,importScripts(a),s()}).then(()=>{let e=s[a];if(!e)throw new Error(`Module ${a} didn’t register its module`);return e}));self.define=(n,t)=>{const i=e||("document"in self?document.currentScript.src:"")||location.href;if(s[i])return;let c={};const r=e=>a(e,i),o={module:{uri:i},exports:c,require:r};s[i]=Promise.all(n.map(e=>o[e]||r(e))).then(e=>(t(...e),c))}}define(["./workbox-4754cb34"],function(e){"use strict";importScripts(),self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"/_next/app-build-manifest.json",revision:"33818a416147af911fdef1614737e9d6"},{url:"/_next/static/chunks/117-9ea064cc63abc227.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/144-726508b4bccdf325.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/306-9dbf591ad5e989ff.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/307-317ad926a638a060.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/319-d608e8f7c0ae7eba.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/400-cdaed14ba7708421.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/455-ced49982d78778a1.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/624-8844c10418df2741.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/630-8f4132e399bd207c.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/741-82d99277719e7009.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/917-aaea7ebbca180952.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/983-200e258cb54b4d52.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/_not-found/page-6f54315fdd5b7641.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/achievements/layout-3a1d05233306731a.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/achievements/page-72b5030cbf3abd44.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/admin/layout-35413ec8bb1e790f.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/admin/page-8934783efababdb1.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/dashboard/layout-b2b5781d61d70d3b.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/dashboard/page-a1f16ef56cb0188b.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/layout-45b426b4831ad374.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/leaderboard/layout-6e26af3b18419a13.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/leaderboard/page-011b353e31d7cee9.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/login/page-40829ff3b7368fb9.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/onboarding/layout-83148a428d559da2.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/onboarding/page-000c9ef391fc7193.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/page-c517ea809e46a020.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/profile/layout-bfda02ed9c7f68b8.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/profile/page-0b51e15ae3e723e4.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/workout/layout-737ea35d587cb6f9.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/workout/page-7ab9cf73008dc1f1.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/workout/preview/%5Bid%5D/page-5622e962bf194e93.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/workout/session/%5Bid%5D/layout-4e04ae2efd11feed.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/app/workout/session/%5Bid%5D/page-a781a22cb27c84c2.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/fd9d1056-b955c697e5096532.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/framework-00a8ba1a63cfdc9e.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/main-app-b67aa9b4d6987144.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/main-c93ce94f23572fe2.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/pages/_app-15e2daefa259f0b5.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/pages/_error-28b803cb2479b966.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/chunks/polyfills-42372ed130431b0a.js",revision:"846118c33b2c0e922d7b3a7676f81f6f"},{url:"/_next/static/chunks/webpack-48b8eac261f765f7.js",revision:"m8jaY1ISZl4UyxG2gNBG9"},{url:"/_next/static/css/9e2c45ec11241156.css",revision:"9e2c45ec11241156"},{url:"/_next/static/m8jaY1ISZl4UyxG2gNBG9/_buildManifest.js",revision:"172e769da91baa11de9b258fb2d92f86"},{url:"/_next/static/m8jaY1ISZl4UyxG2gNBG9/_ssgManifest.js",revision:"b6652df95db52feb4daf4eca35380933"},{url:"/_next/static/media/19cfc7226ec3afaa-s.woff2",revision:"9dda5cfc9a46f256d0e131bb535e46f8"},{url:"/_next/static/media/21350d82a1f187e9-s.woff2",revision:"4e2553027f1d60eff32898367dd4d541"},{url:"/_next/static/media/3dc379dc9b5dec12-s.p.woff2",revision:"f544d77b0e9b284f5c9f06c0905d9f41"},{url:"/_next/static/media/8e9860b6e62d6359-s.woff2",revision:"01ba6c2a184b8cba08b0d57167664d75"},{url:"/_next/static/media/ba9851c3c22cd980-s.woff2",revision:"9e494903d6b0ffec1a1e14d34427d44d"},{url:"/_next/static/media/c5f10e9e72d35c52-s.woff2",revision:"806809d847e91b23af100c0aa2f40c56"},{url:"/_next/static/media/c5fe6dc8356a8c31-s.woff2",revision:"027a89e9ab733a145db70f09b8a18b42"},{url:"/_next/static/media/df0a9ae256c0569c-s.woff2",revision:"d54db44de5ccb18886ece2fda72bdfe0"},{url:"/_next/static/media/e4af272ccee01ff0-s.p.woff2",revision:"65850a373e258f1c897a2b3d75eb74de"},{url:"/icon.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/icons/icon-192.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/icons/icon-512.png",revision:"cb03436f58fe27e88876430ba0c9c05d"},{url:"/manifest.json",revision:"0659b845bfac3e9d0f33a91277a5f983"}],{ignoreURLParametersMatching:[]}),e.cleanupOutdatedCaches(),e.registerRoute("/",new e.NetworkFirst({cacheName:"start-url",plugins:[{cacheWillUpdate:async({request:e,response:s,event:a,state:n})=>s&&"opaqueredirect"===s.type?new Response(s.body,{status:200,statusText:"OK",headers:s.headers}):s}]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:gstatic)\.com\/.*/i,new e.CacheFirst({cacheName:"google-fonts-webfonts",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:31536e3})]}),"GET"),e.registerRoute(/^https:\/\/fonts\.(?:googleapis)\.com\/.*/i,new e.StaleWhileRevalidate({cacheName:"google-fonts-stylesheets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:eot|otf|ttc|ttf|woff|woff2|font.css)$/i,new e.StaleWhileRevalidate({cacheName:"static-font-assets",plugins:[new e.ExpirationPlugin({maxEntries:4,maxAgeSeconds:604800})]}),"GET"),e.registerRoute(/\.(?:jpg|jpeg|gif|png|svg|ico|webp)$/i,new e.StaleWhileRevalidate({cacheName:"static-image-assets",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/image\?url=.+$/i,new e.StaleWhileRevalidate({cacheName:"next-image",plugins:[new e.ExpirationPlugin({maxEntries:64,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp3|wav|ogg)$/i,new e.CacheFirst({cacheName:"static-audio-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:mp4)$/i,new e.CacheFirst({cacheName:"static-video-assets",plugins:[new e.RangeRequestsPlugin,new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:js)$/i,new e.StaleWhileRevalidate({cacheName:"static-js-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:css|less)$/i,new e.StaleWhileRevalidate({cacheName:"static-style-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/_next\/data\/.+\/.+\.json$/i,new e.StaleWhileRevalidate({cacheName:"next-data",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:json|xml|csv)$/i,new e.NetworkFirst({cacheName:"static-data-assets",plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;const s=e.pathname;return!s.startsWith("/api/auth/")&&!!s.startsWith("/api/")},new e.NetworkFirst({cacheName:"apis",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:16,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>{if(!(self.origin===e.origin))return!1;return!e.pathname.startsWith("/api/")},new e.NetworkFirst({cacheName:"others",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(({url:e})=>!(self.origin===e.origin),new e.NetworkFirst({cacheName:"cross-origin",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:32,maxAgeSeconds:3600})]}),"GET")}); diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 94fe1ac..d810bc7 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -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, @@ -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'; @@ -163,6 +164,7 @@ export default function AdminPage() { { key: 'members' as Tab, label: 'Members', icon: }, { key: 'plans' as Tab, label: 'Plans', icon: }, { key: 'insights' as Tab, label: 'Insights', icon: }, + { key: 'estimation' as Tab, label: 'Estimation', icon: }, ]; return ( @@ -485,6 +487,9 @@ export default function AdminPage() { )} )} + + {/* ───────── Estimation ───────── */} + {activeTab === 'estimation' && } diff --git a/frontend/src/app/workout/session/[id]/page.tsx b/frontend/src/app/workout/session/[id]/page.tsx index 44d821c..25a8de9 100644 --- a/frontend/src/app/workout/session/[id]/page.tsx +++ b/frontend/src/app/workout/session/[id]/page.tsx @@ -189,8 +189,9 @@ export default function SessionPage() { const logWorking = async (ex: PlanExercise, slot: Extract) => { 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; @@ -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); diff --git a/frontend/src/components/admin/estimation-panel.tsx b/frontend/src/components/admin/estimation-panel.tsx new file mode 100644 index 0000000..8be9b05 --- /dev/null +++ b/frontend/src/components/admin/estimation-panel.tsx @@ -0,0 +1,158 @@ +'use client'; +import { useEffect, useState } from 'react'; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, +} from 'recharts'; +import { Target, Activity, Scale, TrendingUp, TrendingDown, Gauge, FlaskConical, ArrowRight } from 'lucide-react'; +import { adminApi } from '@/lib/api'; +import { Card, CardHeader } from '@/components/ui/card'; +import { Item, Stagger } from '@/components/ui/motion-primitives'; + +const TT = { background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))', borderRadius: 12, fontSize: 12 }; +const fmtDate = (d?: string) => (d ? new Date(d).toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }) : ''); + +export default function EstimationPanel() { + const [data, setData] = useState(null); + useEffect(() => { adminApi.getEstimation().then((r) => setData(r.data)).catch(() => {}); }, []); + + if (!data) return
; + + const retro = data.retro || { count: 0 }; + const live = data.live || { count: 0 }; + + const biasLabel = retro.bias > 0 + ? `Members lift ${retro.bias}kg more than predicted (algorithm runs light)` + : retro.bias < 0 + ? `Members lift ${Math.abs(retro.bias)}kg less than predicted (algorithm runs heavy)` + : 'No directional bias'; + + const delta = (predicted: number, actual: number) => { + const e = Math.round((actual - predicted) * 10) / 10; + const within = Math.abs(actual - predicted) <= Math.max(2.5, actual * 0.05); + const color = within ? 'text-success' : e > 0 ? 'text-volt-400' : 'text-destructive'; + return {e > 0 ? '+' : ''}{e}kg; + }; + + return ( +
+ {/* Explainer */} +
+ +

+ The estimation algorithm is unchanged. This tab measures how accurate it currently is so we can + decide future tuning from real data. “Accuracy” = prediction within ±2.5 kg (or 5%) of what was actually lifted. +

+
+ + {/* ── Retrospective accuracy ── */} + + } title="Algorithm accuracy" + action={replayed over history} /> + {retro.count === 0 ? ( +

Not enough repeat sessions yet. Once members log the same exercise across multiple sessions, accuracy appears here.

+ ) : ( + <> + + } label="Predictions" value={retro.count} /> + } label="Accuracy" value={`${retro.accuracy}%`} accent="success" /> + } label="Mean error" value={`${retro.meanAbsError}kg`} /> + = 0 ? : } label="Bias" value={`${retro.bias > 0 ? '+' : ''}${retro.bias}kg`} accent="volt" /> + +

{biasLabel}.

+ +

Error distribution (actual − predicted)

+ + + + + + + + {retro.buckets.map((b: any, i: number) => ( + + ))} + + + + + {/* Per-exercise */} +
+

By exercise

+
+ {retro.perExercise.slice(0, 10).map((e: any) => ( +
+ {e.exercise} + n={e.n} + 0 ? 'text-volt-400' : e.bias < 0 ? 'text-destructive' : 'text-muted-foreground'}`}>{e.bias > 0 ? '+' : ''}{e.bias}kg + = 70 ? 'text-success' : e.accuracy >= 40 ? 'text-volt-400' : 'text-destructive'}`}>{e.accuracy}% +
+ ))} +
+
+ + {/* Samples */} +
+

Recent predictions vs actual

+
+ {retro.samples.map((s: any, i: number) => ( +
+ {fmtDate(s.date)} + {s.exercise} · {s.user} + {s.predicted}{s.actual}kg + {delta(s.predicted, s.actual)} +
+ ))} +
+
+ + )} +
+ + {/* ── Live suggestion tracking ── */} + + } title="Live suggestion tracking" + action={collected from now on} /> + {live.count === 0 ? ( +

+ Collecting… Every working set now records the suggested weight shown. As members train, you’ll see here how often they keep the + suggestion vs. override it. +

+ ) : ( + <> + + } label="Logged" value={live.count} /> + } label="Kept as-is" value={`${live.kept}%`} accent="success" /> + } label="Increased" value={`${live.increased}%`} accent="volt" /> + } label="Decreased" value={`${live.decreased}%`} /> + +

+ Accuracy {live.accuracy}% · mean override {live.meanOverride > 0 ? '+' : ''}{live.meanOverride}kg vs suggestion. +

+
+ {live.samples.map((s: any, i: number) => ( +
+ {fmtDate(s.date)} + {s.exercise} · {s.user} + {s.suggested}{s.actual}kg + {delta(s.suggested, s.actual)} +
+ ))} +
+ + )} +
+
+ ); +} + +function Kpi({ icon, label, value, accent }: { icon: React.ReactNode; label: string; value: any; accent?: 'volt' | 'success' }) { + const c = accent === 'volt' ? 'text-volt-400' : accent === 'success' ? 'text-success' : 'text-brand-400'; + return ( + +
+
{icon}{label}
+

{value}

+
+
+ ); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 980922e..663fbd0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -140,6 +140,7 @@ export const achievementsApi = { export const adminApi = { getStats: () => api.get('/admin/stats'), getActivity: () => api.get('/admin/activity'), + getEstimation: () => api.get('/admin/estimation'), getUsers: () => api.get('/admin/users'), getUserDetail: (id: number) => api.get(`/admin/users/${id}`), inviteUser: (email: string, name: string) =>