diff --git a/e2e/tests/nutrition-fueling.spec.ts b/e2e/tests/nutrition-fueling.spec.ts index a68ebd3c7..b99df31d4 100644 --- a/e2e/tests/nutrition-fueling.spec.ts +++ b/e2e/tests/nutrition-fueling.spec.ts @@ -28,8 +28,19 @@ test.describe('Nutrition fueling plan', () => { return startOfWeek(new Date(), { weekStartsOn: 1 }) } - const dayOffset = (offset: number) => new Date(getWeekStart().getTime() + offset * 86400000) - const dateKey = (date: Date) => date.toISOString().slice(0, 10) + const dayOffset = (offset: number) => { + const date = new Date(getWeekStart().getTime() + offset * 86400000) + // The API queries UTC calendar days while the calendar renders local date parts. + // Noon keeps the fixture on the same day in both representations. + date.setHours(12, 0, 0, 0) + return date + } + const dateKey = (date: Date) => + [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, '0'), + String(date.getDate()).padStart(2, '0') + ].join('-') // Keep this suite away from the current and next day. Other parallel E2E specs create // workouts there, which would otherwise change the number of generated fueling windows. diff --git a/server/api/oauth/register.post.ts b/server/api/oauth/register.post.ts index 70a5aa1f4..40030179e 100644 --- a/server/api/oauth/register.post.ts +++ b/server/api/oauth/register.post.ts @@ -1,5 +1,3 @@ -import { z } from 'zod' -import { oauthRepository } from '../../utils/repositories/oauthRepository' import { assertDcrRateLimit, getClientIpFromRequest, @@ -62,18 +60,17 @@ export default defineEventHandler(async (event) => { }) } - const app = isCursorCompatibleDcrRequest(registration.redirectUris) - ? await findOrCreateCursorMcpApp({ - ownerId, - redirectUris: registration.redirectUris - }) - : await oauthRepository.registerPublicClient({ - ownerId, - name: registration.client_name, - redirectUris: registration.redirectUris, - clientUri: registration.client_uri, - logoUri: registration.logo_uri - }) + if (!isCursorCompatibleDcrRequest(registration.redirectUris)) { + throw createError({ + statusCode: 400, + message: 'Unsupported dynamic client redirect URI' + }) + } + + const app = await findOrCreateCursorMcpApp({ + ownerId, + redirectUris: registration.redirectUris + }) setResponseStatus(event, 201) return serializeDcrRegistrationResponse(app) diff --git a/server/utils/strength-intensity.ts b/server/utils/strength-intensity.ts new file mode 100644 index 000000000..8968d4b4c --- /dev/null +++ b/server/utils/strength-intensity.ts @@ -0,0 +1,291 @@ +const KG_PER_LB = 0.45359237 + +export type StrengthHistorySet = { + exerciseName: string + reps: number | null + weight: number | null + weightUnit: string | null + rpe?: number | null + performedAt?: Date | string | null + setOrder?: number | null +} + +export type StrengthIntensityReference = { + exerciseName: string + e1rmKg: { minKg: number; maxKg: number } + sampleCount: number + latestRpe: number | null + estimatedRir: number | null + progressionAdjustment: number +} + +function round(value: number, decimals = 2) { + const factor = 10 ** decimals + return Math.round((value + Number.EPSILON) * factor) / factor +} + +function normalizeExerciseName(value: unknown) { + return String(value || '') + .trim() + .toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() +} + +function weightToKg(weight: unknown, unit: unknown): number | null { + const value = Number(weight) + if (!Number.isFinite(value) || value <= 0) return null + + const normalizedUnit = String(unit || '') + .trim() + .toLowerCase() + if (['kg', 'kgs', 'kilogram', 'kilograms'].includes(normalizedUnit)) return value + if (['lb', 'lbs', 'pound', 'pounds'].includes(normalizedUnit)) return value * KG_PER_LB + return null +} + +/** + * Estimate one-rep max as the spread across Epley, Brzycki, and Wathan. + * The formulas only stay acceptably close for logged sets of eight reps or fewer. + */ +export function estimateE1rmRange( + weightKg: number, + reps: number +): { minKg: number; maxKg: number } | null { + if ( + !Number.isFinite(weightKg) || + weightKg <= 0 || + !Number.isInteger(reps) || + reps < 1 || + reps > 8 + ) { + return null + } + + const estimates = [ + weightKg * (1 + reps / 30), + weightKg * (36 / (37 - reps)), + weightKg * (100 / (48.8 + 53.8 * Math.exp(-0.075 * reps))) + ] + + return { + minKg: round(Math.min(...estimates)), + maxKg: round(Math.max(...estimates)) + } +} + +function progressionFromRpe(rpe: number | null) { + if (rpe === null || !Number.isFinite(rpe)) { + return { estimatedRir: null, progressionAdjustment: 0 } + } + + const estimatedRir = round(Math.max(0, Math.min(10, 10 - rpe)), 1) + if (estimatedRir >= 3) return { estimatedRir, progressionAdjustment: 0.025 } + if (estimatedRir <= 0.5) return { estimatedRir, progressionAdjustment: -0.025 } + return { estimatedRir, progressionAdjustment: 0 } +} + +export function buildStrengthIntensityReferences( + history: StrengthHistorySet[] +): StrengthIntensityReference[] { + const grouped = new Map< + string, + { + exerciseName: string + bestRange: { minKg: number; maxKg: number } + bestMidpoint: number + sampleCount: number + latestRpe: number | null + latestTimestamp: number + latestSetOrder: number + } + >() + + history.forEach((set, index) => { + const key = normalizeExerciseName(set.exerciseName) + const weightKg = weightToKg(set.weight, set.weightUnit) + const reps = Number(set.reps) + if (!key || weightKg === null) return + + const range = estimateE1rmRange(weightKg, reps) + if (!range) return + + const midpoint = (range.minKg + range.maxKg) / 2 + const parsedTimestamp = set.performedAt ? new Date(set.performedAt).getTime() : index + const timestamp = Number.isFinite(parsedTimestamp) ? parsedTimestamp : index + const rpe = Number(set.rpe) + const validRpe = Number.isFinite(rpe) && rpe >= 1 && rpe <= 10 ? rpe : null + const parsedSetOrder = Number(set.setOrder) + const setOrder = Number.isFinite(parsedSetOrder) ? parsedSetOrder : -1 + const current = grouped.get(key) + + if (!current) { + grouped.set(key, { + exerciseName: String(set.exerciseName).trim(), + bestRange: range, + bestMidpoint: midpoint, + sampleCount: 1, + latestRpe: validRpe, + latestTimestamp: timestamp, + latestSetOrder: setOrder + }) + return + } + + current.sampleCount += 1 + if (midpoint > current.bestMidpoint) { + current.bestRange = range + current.bestMidpoint = midpoint + } + if ( + timestamp > current.latestTimestamp || + (timestamp === current.latestTimestamp && setOrder > current.latestSetOrder) + ) { + current.latestTimestamp = timestamp + current.latestSetOrder = setOrder + current.latestRpe = validRpe + } + }) + + return [...grouped.values()] + .map((group) => ({ + exerciseName: group.exerciseName, + e1rmKg: group.bestRange, + sampleCount: group.sampleCount, + latestRpe: group.latestRpe, + ...progressionFromRpe(group.latestRpe) + })) + .sort((a, b) => a.exerciseName.localeCompare(b.exerciseName)) +} + +export async function loadStrengthIntensityReferences( + client: any, + userId: string +): Promise { + const sets = await client.workoutSet.findMany({ + where: { + reps: { gte: 1, lte: 8 }, + weight: { gt: 0 }, + workoutExercise: { workout: { userId } } + }, + select: { + reps: true, + weight: true, + weightUnit: true, + rpe: true, + order: true, + workoutExercise: { + select: { + exercise: { select: { title: true } }, + workout: { select: { date: true } } + } + } + }, + orderBy: [{ workoutExercise: { workout: { date: 'desc' } } }, { order: 'desc' }, { id: 'asc' }], + take: 1000 + }) + + return buildStrengthIntensityReferences( + sets.map((set: any) => ({ + exerciseName: set.workoutExercise?.exercise?.title || '', + reps: set.reps, + weight: set.weight, + weightUnit: set.weightUnit, + rpe: set.rpe, + performedAt: set.workoutExercise?.workout?.date, + setOrder: set.order + })) + ) +} + +export function formatStrengthIntensityReferences(references: StrengthIntensityReference[]) { + if (references.length === 0) return '' + + const rows = references.map((reference) => { + const rpe = + reference.latestRpe === null + ? 'latest RPE unavailable' + : `latest RPE ${reference.latestRpe} (estimated RIR ${reference.estimatedRir})` + const progression = + reference.progressionAdjustment > 0 + ? 'progress target +2.5%' + : reference.progressionAdjustment < 0 + ? 'reduce target -2.5%' + : 'hold target' + return `- ${reference.exerciseName}: e1RM ${round(reference.e1rmKg.minKg, 1)}-${round(reference.e1rmKg.maxKg, 1)} kg; ${rpe}; ${progression}` + }) + + return `STRENGTH INTENSITY REFERENCES (logged sets of 1-8 reps only): +${rows.join('\n')} +Use a reference only for the same exercise. If an exercise is absent, leave its load blank rather than inventing one.` +} + +function parsePlannedReps(value: unknown): number | null { + const matches = String(value || '').match(/\d+(?:\.\d+)?/g) + if (!matches?.length) return null + const reps = Math.max(...matches.map(Number)) + return Number.isFinite(reps) && reps >= 1 && reps <= 20 ? reps : null +} + +function roundToIncrement(value: number, increment: number) { + return Math.max(increment, Math.round(value / increment) * increment) +} + +function formatLoad(value: number) { + return Number.isInteger(value) ? String(value) : String(round(value, 1)) +} + +export function applyStrengthIntensityTargets( + structuredWorkout: any, + references: StrengthIntensityReference[], + preferredWeightUnits: unknown +) { + if (!Array.isArray(structuredWorkout?.blocks) || references.length === 0) { + return structuredWorkout + } + + const referenceByName = new Map( + references.map((reference) => [normalizeExerciseName(reference.exerciseName), reference]) + ) + const usePounds = String(preferredWeightUnits || '').toLowerCase() === 'pounds' + + for (const block of structuredWorkout.blocks) { + for (const step of Array.isArray(block?.steps) ? block.steps : []) { + const reference = referenceByName.get(normalizeExerciseName(step?.name)) + if (!reference || !Array.isArray(step?.setRows)) continue + + const existingLoadMode = String(step?.loadMode || '') + const hasExplicitLoads = step.setRows.some((row: any) => + Boolean(String(row?.loadValue || '').trim()) + ) + const hasConcreteUnit = existingLoadMode === 'weight_kg' || existingLoadMode === 'weight_lb' + if (hasExplicitLoads && !hasConcreteUnit) continue + const stepUsesPounds = hasConcreteUnit ? existingLoadMode === 'weight_lb' : usePounds + + let filledAnyLoad = false + for (const row of step.setRows) { + if (String(row?.loadValue || '').trim()) continue + const reps = parsePlannedReps(row?.value) + if (reps === null) continue + + // Invert Epley against the conservative lower edge of the formula range, + // then apply a small progression adjustment from the latest logged RPE/RIR. + const repPercentage = 1 / (1 + reps / 30) + const targetKg = + reference.e1rmKg.minKg * repPercentage * (1 + reference.progressionAdjustment) + const target = stepUsesPounds + ? roundToIncrement(targetKg / KG_PER_LB, 5) + : roundToIncrement(targetKg, 2.5) + + row.loadValue = formatLoad(target) + filledAnyLoad = true + } + + if (filledAnyLoad) { + step.loadMode = stepUsesPounds ? 'weight_lb' : 'weight_kg' + } + } + } + + return structuredWorkout +} diff --git a/tests/unit/server/api/oauth/register.nuxt.test.ts b/tests/unit/server/api/oauth/register.nuxt.test.ts new file mode 100644 index 000000000..c7da3312b --- /dev/null +++ b/tests/unit/server/api/oauth/register.nuxt.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.stubGlobal('defineEventHandler', (fn: any) => fn) +vi.stubGlobal('defineRouteMeta', vi.fn()) +vi.stubGlobal('readBody', (event: any) => event.body) +vi.stubGlobal('setResponseStatus', (event: any, statusCode: number) => { + event.statusCode = statusCode +}) +vi.stubGlobal('createError', (details: any) => { + return Object.assign(new Error(details.message), { statusCode: details.statusCode }) +}) +vi.stubGlobal('useRuntimeConfig', () => ({ + mcpDcrEnabled: true, + mcpDcrOwnerUserId: 'owner-1', + mcpDcrOwnerEmail: '', + mcpDcrRateLimitPerHour: '10' +})) + +const findOrCreateCursorMcpApp = vi.fn() +const registerPublicClient = vi.fn() +const resolveMcpDcrOwnerId = vi.fn() + +vi.mock('../../../../../server/utils/repositories/oauthRepository', () => ({ + oauthRepository: { + registerPublicClient + } +})) + +vi.mock('../../../../../server/utils/oauth/mcp-dcr', async (importOriginal) => { + const actual = await importOriginal() + + return { + ...actual, + findOrCreateCursorMcpApp, + resolveMcpDcrOwnerId + } +}) + +const getHandler = async () => { + const mod = await import('../../../../../server/api/oauth/register.post') + return mod.default +} + +function registrationEvent(redirectUri: string) { + return { + node: { + req: { + headers: {}, + socket: { remoteAddress: '127.0.0.1' } + } + }, + body: { + client_name: 'Security research test', + redirect_uris: [redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none' + } + } +} + +describe('POST /api/oauth/register', () => { + beforeEach(() => { + vi.clearAllMocks() + resolveMcpDcrOwnerId.mockResolvedValue('owner-1') + findOrCreateCursorMcpApp.mockResolvedValue({ + clientId: 'cursor-client', + name: 'Cursor MCP', + redirectUris: ['http://127.0.0.1:8765/callback'], + createdAt: new Date('2026-08-28T00:00:00.000Z') + }) + registerPublicClient.mockResolvedValue({ + clientId: 'attacker-client', + name: 'Security research test', + redirectUris: ['https://attacker.evil-test.example/cb'], + createdAt: new Date('2026-08-28T00:00:00.000Z') + }) + }) + + it('rejects arbitrary HTTPS redirect URIs without creating a client', async () => { + const handler = await getHandler() + + await expect( + handler(registrationEvent('https://attacker.evil-test.example/cb') as any) + ).rejects.toMatchObject({ + statusCode: 400, + message: 'Unsupported dynamic client redirect URI' + }) + + expect(findOrCreateCursorMcpApp).not.toHaveBeenCalled() + expect(registerPublicClient).not.toHaveBeenCalled() + }) + + it('keeps known Cursor loopback callbacks registration-compatible', async () => { + const handler = await getHandler() + const event = registrationEvent('http://127.0.0.1:8765/callback') + + const result = await handler(event as any) + + expect(findOrCreateCursorMcpApp).toHaveBeenCalledWith({ + ownerId: 'owner-1', + redirectUris: ['http://127.0.0.1:8765/callback'] + }) + expect(registerPublicClient).not.toHaveBeenCalled() + expect(result).toMatchObject({ + client_id: 'cursor-client', + token_endpoint_auth_method: 'none' + }) + }) +}) diff --git a/tests/unit/server/utils/strength-intensity.test.ts b/tests/unit/server/utils/strength-intensity.test.ts new file mode 100644 index 000000000..460434efb --- /dev/null +++ b/tests/unit/server/utils/strength-intensity.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import { + applyStrengthIntensityTargets, + buildStrengthIntensityReferences, + estimateE1rmRange, + formatStrengthIntensityReferences +} from '../../../../server/utils/strength-intensity' + +describe('strength intensity references', () => { + it('returns the Epley, Brzycki, and Wathan spread for valid 1-8 rep sets', () => { + expect(estimateE1rmRange(100, 5)).toEqual({ + minKg: 112.5, + maxKg: 116.67 + }) + expect(estimateE1rmRange(100, 9)).toBeNull() + expect(estimateE1rmRange(0, 5)).toBeNull() + }) + + it('normalizes pounds, keeps the strongest estimate, and uses latest RPE for progression', () => { + const references = buildStrengthIntensityReferences([ + { + exerciseName: 'Back Squat', + reps: 5, + weight: 220.462, + weightUnit: 'lb', + rpe: 8, + performedAt: new Date('2026-08-01') + }, + { + exerciseName: 'Back Squat', + reps: 5, + weight: 95, + weightUnit: 'kg', + rpe: 7, + performedAt: new Date('2026-08-20') + } + ]) + + expect(references).toEqual([ + expect.objectContaining({ + exerciseName: 'Back Squat', + e1rmKg: { minKg: 112.5, maxKg: 116.67 }, + sampleCount: 2, + latestRpe: 7, + estimatedRir: 3, + progressionAdjustment: 0.025 + }) + ]) + }) + + it('uses the last ordered set RPE deterministically within the latest workout', () => { + const references = buildStrengthIntensityReferences([ + { + exerciseName: 'Back Squat', + reps: 5, + weight: 95, + weightUnit: 'kg', + rpe: 7, + performedAt: new Date('2026-08-20'), + setOrder: 2 + }, + { + exerciseName: 'Back Squat', + reps: 5, + weight: 100, + weightUnit: 'kg', + rpe: 10, + performedAt: new Date('2026-08-20'), + setOrder: 0 + } + ]) + + expect(references[0]).toMatchObject({ + latestRpe: 7, + estimatedRir: 3, + progressionAdjustment: 0.025 + }) + }) + + it('fills blank matching set loads with a conservative RIR-adjusted target', () => { + const structure = { + blocks: [ + { + steps: [ + { + name: 'Back Squat', + loadMode: 'weight_kg', + setRows: [ + { value: '5', loadValue: '' }, + { value: '5', loadValue: '90' } + ] + } + ] + } + ] + } + const references = buildStrengthIntensityReferences([ + { + exerciseName: 'Back Squat', + reps: 5, + weight: 100, + weightUnit: 'kg', + rpe: 7, + performedAt: new Date('2026-08-20') + } + ]) + + applyStrengthIntensityTargets(structure, references, 'Kilograms') + + expect(structure.blocks[0]!.steps[0]).toMatchObject({ + loadMode: 'weight_kg', + setRows: [ + { value: '5', loadValue: '100' }, + { value: '5', loadValue: '90' } + ] + }) + }) + + it('rounds targets in the preferred pounds unit', () => { + const structure = { + blocks: [ + { + steps: [ + { + name: 'Deadlift', + loadMode: 'none', + setRows: [{ value: '3', loadValue: '' }] + } + ] + } + ] + } + const references = buildStrengthIntensityReferences([ + { exerciseName: 'Deadlift', reps: 3, weight: 180, weightUnit: 'kg', rpe: 8 } + ]) + + applyStrengthIntensityTargets(structure, references, 'Pounds') + + expect(structure.blocks[0]!.steps[0]).toMatchObject({ + loadMode: 'weight_lb', + setRows: [{ value: '3', loadValue: '380' }] + }) + }) + + it('uses an existing concrete step unit when filling a partially prescribed exercise', () => { + const structure = { + blocks: [ + { + steps: [ + { + name: 'Back Squat', + loadMode: 'weight_lb', + setRows: [ + { value: '5', loadValue: '100' }, + { value: '5', loadValue: '' } + ] + } + ] + } + ] + } + const references = buildStrengthIntensityReferences([ + { exerciseName: 'Back Squat', reps: 5, weight: 100, weightUnit: 'kg', rpe: 8 } + ]) + + applyStrengthIntensityTargets(structure, references, 'Kilograms') + + expect(structure.blocks[0]!.steps[0]).toMatchObject({ + loadMode: 'weight_lb', + setRows: [ + { value: '5', loadValue: '100' }, + { value: '5', loadValue: '215' } + ] + }) + }) + + it('does not relabel partially populated rows whose existing load unit is ambiguous', () => { + const structure = { + blocks: [ + { + steps: [ + { + name: 'Back Squat', + loadMode: 'none', + setRows: [ + { value: '5', loadValue: '100' }, + { value: '5', loadValue: '' } + ] + } + ] + } + ] + } + const references = buildStrengthIntensityReferences([ + { exerciseName: 'Back Squat', reps: 5, weight: 100, weightUnit: 'kg', rpe: 8 } + ]) + + applyStrengthIntensityTargets(structure, references, 'Kilograms') + + expect(structure.blocks[0]!.steps[0]).toMatchObject({ + loadMode: 'none', + setRows: [ + { value: '5', loadValue: '100' }, + { value: '5', loadValue: '' } + ] + }) + }) + + it('does not fabricate a load when no exercise history matches', () => { + const structure = { + blocks: [ + { + steps: [ + { + name: 'Front Squat', + loadMode: 'none', + setRows: [{ value: '5', loadValue: '' }] + } + ] + } + ] + } + + applyStrengthIntensityTargets(structure, [], 'Kilograms') + + expect(structure.blocks[0]!.steps[0]).toMatchObject({ + loadMode: 'none', + setRows: [{ value: '5', loadValue: '' }] + }) + }) + + it('formats the history as generator context and omits an empty section', () => { + const references = buildStrengthIntensityReferences([ + { exerciseName: 'Back Squat', reps: 5, weight: 100, weightUnit: 'kg', rpe: 8 } + ]) + + expect(formatStrengthIntensityReferences(references)).toContain( + 'Back Squat: e1RM 112.5-116.7 kg; latest RPE 8 (estimated RIR 2)' + ) + expect(formatStrengthIntensityReferences([])).toBe('') + }) +}) diff --git a/trigger/generate-structured-workout.ts b/trigger/generate-structured-workout.ts index 869170e69..6cff5bb38 100644 --- a/trigger/generate-structured-workout.ts +++ b/trigger/generate-structured-workout.ts @@ -54,6 +54,12 @@ import { import { publishActivityEvent } from '../server/utils/activity-realtime' import { normalizeSwimStructure } from '../server/utils/swim-structure' import { normalizeStructuredStrengthWorkout } from '../server/utils/strength-exercise-library' +import { + applyStrengthIntensityTargets, + formatStrengthIntensityReferences, + loadStrengthIntensityReferences, + type StrengthIntensityReference +} from '../server/utils/strength-intensity' import { applyStrengthLibraryDefaultsToWorkout, validateStrengthStructuredWorkout @@ -840,6 +846,7 @@ export async function runGenerateStructuredWorkout( isAdmin: true, aiContext: true, language: true, + weightUnits: true, featureFlags: true } }, @@ -873,6 +880,7 @@ export async function runGenerateStructuredWorkout( isAdmin: true, aiContext: true, language: true, + weightUnits: true, featureFlags: true } } @@ -1037,6 +1045,20 @@ export async function runGenerateStructuredWorkout( ? 'Prefer single-value targets for steady aerobic/endurance/tempo blocks. Use ranges only when the workout explicitly asks for a range or ramp.' : 'Prefer metric ranges for steady aerobic/endurance/tempo blocks.' const isStrength = isStrengthWorkoutType(workout.type) + let strengthIntensityReferences: StrengthIntensityReference[] = [] + if (isStrength) { + try { + strengthIntensityReferences = await loadStrengthIntensityReferences(prisma, workout.userId) + logStage('loaded-strength-intensity-references', { + count: strengthIntensityReferences.length + }) + } catch (error: any) { + logStage('strength-intensity-references-failed', { + error: error?.message || String(error) + }) + } + } + const strengthIntensityContext = formatStrengthIntensityReferences(strengthIntensityReferences) const sportSpecificInstructions = buildSportSpecificInstructions({ workoutType: workout.type || '', targetFormatPolicy, @@ -1069,6 +1091,8 @@ ${recentWorkoutsSummary ? `RECENT WORKOUTS (brief):\n${recentWorkoutsSummary}\n` ${preserveExistingStructure ? `EXISTING STRUCTURE TO PRESERVE:\n${existingStructureSummary}\n` : ''} ${zoneDefinitions} +${strengthIntensityContext} + ${targetingBlock} For Zone 2 workouts, target the user's defined Z2 range for this sport before generic percentages.` @@ -1231,6 +1255,12 @@ OUTPUT JSON matching the schema.` } } + applyStrengthIntensityTargets( + structure, + strengthIntensityReferences, + workout.user.weightUnits + ) + const strengthValidation = validateStrengthStructuredWorkout( rawStrengthStructure, structure