diff --git a/.env.example b/.env.example index fa1603e0..a50d27fc 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,8 @@ WITHINGS_CLIENT_SECRET="" FITBIT_CLIENT_ID="" FITBIT_CLIENT_SECRET="" FITBIT_SUBSCRIBER_VERIFICATION_CODE="your-verification-code" +FITBIT_ENABLE_INTRADAY_HEART_RATE="false" +# Set to "true" to enable intraday heart-rate fetches (may increase Fitbit API usage and rate limiting) # Redis / DragonflyDB REDIS_URL=redis://:dragonfly@localhost:6379 diff --git a/app/composables/useDataStatus.ts b/app/composables/useDataStatus.ts index 060668b2..8c3f78d4 100644 --- a/app/composables/useDataStatus.ts +++ b/app/composables/useDataStatus.ts @@ -32,6 +32,9 @@ export const useDataStatus = () => { if (wellnessDateStr === todayStr) return { isStale: false, label: 'Up to Date' } const daysAgo = getDaysAgo(latestWellnessDate) + if (daysAgo !== null && daysAgo < 0) { + return { isStale: true, label: 'Future date detected' } + } if (daysAgo === 1) return { isStale: true, label: 'Yesterday (Sync Needed)' } return { isStale: true, label: `${daysAgo} days old` } diff --git a/app/pages/connect-fitbit.vue b/app/pages/connect-fitbit.vue index 324f51e8..78e18a15 100644 --- a/app/pages/connect-fitbit.vue +++ b/app/pages/connect-fitbit.vue @@ -27,7 +27,7 @@

Connect Fitbit

- Connect your Fitbit account to sync nutrition history and food logs. + Connect your Fitbit account to sync nutrition, sleep, and heart-rate trends.

@@ -40,7 +40,8 @@
  • • Daily calories and macros
  • • Logged foods and meal entries
  • • Water intake summaries
  • -
  • • Historical nutrition trends
  • +
  • • Sleep duration and stage summaries
  • +
  • • HRV and resting heart-rate trends
  • @@ -69,6 +70,14 @@ Read nutrition and food logs +
    + + Read sleep and recovery metrics +
    +
    + + Read heart-rate and HRV summaries +
    @@ -100,7 +109,7 @@ meta: [ { name: 'description', - content: 'Connect your Fitbit account to sync nutrition history and food logs.' + content: 'Connect your Fitbit account to sync nutrition, sleep, and heart-rate trends.' } ] }) diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue index c01ccf2f..d3df772b 100644 --- a/app/pages/dashboard.vue +++ b/app/pages/dashboard.vue @@ -641,9 +641,12 @@ // Wellness modal handlers function openWellnessModal() { // Use today's date or the latest wellness date - wellnessModalDate.value = userStore.profile?.latestWellnessDate + const latestDate = userStore.profile?.latestWellnessDate ? new Date(userStore.profile.latestWellnessDate) : getUserLocalDate() + + const today = getUserLocalDate() + wellnessModalDate.value = latestDate > today ? today : latestDate showWellnessModal.value = true } diff --git a/server/api/integrations/fitbit/authorize.get.ts b/server/api/integrations/fitbit/authorize.get.ts index 3cbf8586..14a8f59c 100644 --- a/server/api/integrations/fitbit/authorize.get.ts +++ b/server/api/integrations/fitbit/authorize.get.ts @@ -46,7 +46,14 @@ export default defineEventHandler(async (event) => { path: '/' }) - const scope = 'nutrition' + const scope = [ + 'nutrition', + 'sleep', + 'heartrate', + 'weight', + 'oxygen_saturation', + 'respiratory_rate' + ].join(' ') const params = new URLSearchParams({ response_type: 'code', diff --git a/server/api/profile/dashboard.get.ts b/server/api/profile/dashboard.get.ts index b76db45d..3a961f10 100644 --- a/server/api/profile/dashboard.get.ts +++ b/server/api/profile/dashboard.get.ts @@ -4,6 +4,7 @@ import { sportSettingsRepository } from '../../utils/repositories/sportSettingsR import { wellnessRepository } from '../../utils/repositories/wellnessRepository' import { nutritionRepository } from '../../utils/repositories/nutritionRepository' import { workoutRepository } from '../../utils/repositories/workoutRepository' +import { getEndOfDayUTC, getUserTimezone } from '../../utils/date' import { bodyMetricResolver } from '../../utils/services/bodyMetricResolver' export default defineEventHandler(async (event) => { @@ -49,12 +50,17 @@ export default defineEventHandler(async (event) => { // Get Sport Settings via Repository (ensures Default exists) const sportSettings = await sportSettingsRepository.getByUserId(user.id) const defaultProfile = sportSettings.find((s: any) => s.isDefault) + const timezone = await getUserTimezone(user.id) + const latestAllowedDate = getEndOfDayUTC(timezone, new Date()) const [wellness, dailyMetric, latestBodyFatWellness] = await Promise.all([ // Query most recent wellness record with any meaningful values (not only resting HR) prisma.wellness.findFirst({ where: { userId: user.id, + date: { + lte: latestAllowedDate + }, OR: [ { restingHr: { not: null } }, { hrv: { not: null } }, @@ -110,6 +116,9 @@ export default defineEventHandler(async (event) => { prisma.dailyMetric.findFirst({ where: { userId: user.id, + date: { + lte: latestAllowedDate + }, OR: [ { restingHr: { not: null } }, { hrv: { not: null } }, @@ -137,9 +146,24 @@ export default defineEventHandler(async (event) => { source: true } }), + + prisma.wellness.findFirst({ + where: { + userId: user.id, + date: { + lte: latestAllowedDate + }, + weight: { not: null } + }, + orderBy: { date: 'desc' }, + select: { weight: true } + }), prisma.wellness.findFirst({ where: { userId: user.id, + date: { + lte: latestAllowedDate + }, bodyFat: { not: null } }, orderBy: { date: 'desc' }, diff --git a/server/utils/fitbit.ts b/server/utils/fitbit.ts index 05ba317a..ed5c0c04 100644 --- a/server/utils/fitbit.ts +++ b/server/utils/fitbit.ts @@ -89,6 +89,131 @@ export interface FitbitWaterGoalsResponse { } } +export interface FitbitSleepLogResponse { + sleep?: Array<{ + dateOfSleep?: string + duration?: number + efficiency?: number + endTime?: string + isMainSleep?: boolean + logId?: number + minutesAfterWakeup?: number + minutesAsleep?: number + minutesAwake?: number + minutesToFallAsleep?: number + startTime?: string + timeInBed?: number + type?: 'classic' | 'stages' + levels?: { + summary?: { + deep?: { minutes?: number } + light?: { minutes?: number } + rem?: { minutes?: number } + wake?: { minutes?: number } + asleep?: { minutes?: number } + restless?: { minutes?: number } + awake?: { minutes?: number } + } + } + }> + summary?: { + stages?: { + deep?: number + light?: number + rem?: number + wake?: number + } + totalMinutesAsleep?: number + totalSleepRecords?: number + totalTimeInBed?: number + } +} + +export interface FitbitHrvSummaryResponse { + hrv?: Array<{ + dateTime?: string + value?: { + dailyRmssd?: number + deepRmssd?: number + } + }> +} + +export interface FitbitHeartRateResponse { + 'activities-heart'?: Array<{ + dateTime?: string + value?: { + restingHeartRate?: number + } + }> +} + +export interface FitbitHeartRateIntradayResponse { + 'activities-heart'?: Array<{ + dateTime?: string + value?: { + restingHeartRate?: number + } + }> + 'activities-heart-intraday'?: { + dataset?: Array<{ + time?: string + value?: number + }> + datasetInterval?: number + datasetType?: string + } +} + +export interface FitbitWeightLogResponse { + weight?: Array<{ + date?: string + bmi?: number + fat?: number + logId?: number + source?: string + time?: string + weight?: number + }> +} + +export interface FitbitBodyFatLogResponse { + fat?: Array<{ + date?: string + fat?: number + logId?: number + source?: string + time?: string + }> +} + +export interface FitbitSpO2SummaryResponse { + value?: { + avg?: number + spo2?: number + average?: number + min?: number + max?: number + } + minutesBelow90?: { + minute?: number + value?: number + } + dateTime?: string +} + +export interface FitbitBreathingRateSummaryResponse { + br?: Array<{ + dateTime?: string + value?: { + breathingRate?: number + avg?: number + br?: number + rate?: number + } + }> +} + function encodeBasicAuth(clientId: string, clientSecret: string) { const creds = `${clientId}:${clientSecret}` return Buffer.from(creds).toString('base64') @@ -288,6 +413,363 @@ export async function fetchFitbitWaterGoals( ) } +export async function fetchFitbitSleepLog( + integration: Integration, + date: string +): Promise { + return await fitbitGet(integration, `/1.2/user/-/sleep/date/${date}.json`) +} + +export async function fetchFitbitHrvSummary( + integration: Integration, + date: string +): Promise { + return await fitbitGet(integration, `/1/user/-/hrv/date/${date}.json`) +} + +export async function fetchFitbitHeartRateSummary( + integration: Integration, + date: string +): Promise { + return await fitbitGet( + integration, + `/1/user/-/activities/heart/date/${date}/1d.json` + ) +} + +export async function fetchFitbitHeartRateIntraday( + integration: Integration, + date: string +): Promise { + const scope = (integration.scope || '').toLowerCase() + if (!scope.includes('heartrate')) { + return null + } + + try { + return await fitbitGet( + integration, + `/1/user/-/activities/heart/date/${date}/1d/1min.json` + ) + } catch (error) { + if (error instanceof Error && /403|404/.test(error.message)) { + return null + } + throw error + } +} + +export async function fetchFitbitWeightLog( + integration: Integration, + date: string +): Promise { + return await fitbitGet( + integration, + `/1/user/-/body/log/weight/date/${date}.json` + ) +} + +export async function fetchFitbitBodyFatLog( + integration: Integration, + date: string +): Promise { + return await fitbitGet( + integration, + `/1/user/-/body/log/fat/date/${date}.json` + ) +} + +export async function fetchFitbitSpO2Summary( + integration: Integration, + date: string +): Promise { + return await fitbitGet(integration, `/1/user/-/spo2/date/${date}.json`) +} + +export async function fetchFitbitBreathingRateSummary( + integration: Integration, + date: string +): Promise { + return await fitbitGet( + integration, + `/1/user/-/br/date/${date}.json` + ) +} + +export function normalizeFitbitWellness( + sleepLog: FitbitSleepLogResponse | null, + hrvSummary: FitbitHrvSummaryResponse | null, + heartRateSummary: FitbitHeartRateResponse | null, + heartRateIntraday: FitbitHeartRateIntradayResponse | null, + weightLog: FitbitWeightLogResponse | null, + bodyFatLog: FitbitBodyFatLogResponse | null, + spO2Summary: FitbitSpO2SummaryResponse | null, + breathingRateSummary: FitbitBreathingRateSummaryResponse | null, + userId: string, + date: string +) { + const [yearStr, monthStr, dayStr] = date.split('-') + const year = Number(yearStr) + const month = Number(monthStr) + const day = Number(dayStr) + + if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) { + return null + } + + const dateObj = new Date(Date.UTC(year, month - 1, day)) + + if ( + Number.isNaN(dateObj.getTime()) || + dateObj.getUTCFullYear() !== year || + dateObj.getUTCMonth() !== month - 1 || + dateObj.getUTCDate() !== day + ) { + return null + } + + const mainSleep = + sleepLog?.sleep?.find((entry) => entry?.isMainSleep) || + (Array.isArray(sleepLog?.sleep) ? sleepLog?.sleep[0] : null) + const sleepSummary = sleepLog?.summary || {} + + const summaryStageMinutes = sleepSummary.stages || {} + const levelSummary = mainSleep?.levels?.summary || {} + + const deepMinutes = summaryStageMinutes.deep ?? levelSummary.deep?.minutes ?? null + const lightMinutes = summaryStageMinutes.light ?? levelSummary.light?.minutes ?? null + const remMinutes = summaryStageMinutes.rem ?? levelSummary.rem?.minutes ?? null + const wakeMinutes = + summaryStageMinutes.wake ?? + levelSummary.wake?.minutes ?? + levelSummary.awake?.minutes ?? + mainSleep?.minutesAwake ?? + null + + const totalMinutesAsleep = mainSleep?.minutesAsleep ?? sleepSummary.totalMinutesAsleep ?? null + const sleepSecs = + typeof totalMinutesAsleep === 'number' && Number.isFinite(totalMinutesAsleep) + ? Math.round(totalMinutesAsleep * 60) + : null + const sleepHours = sleepSecs != null ? Math.round((sleepSecs / 3600) * 10) / 10 : null + + const sleepQuality = + typeof mainSleep?.efficiency === 'number' && Number.isFinite(mainSleep.efficiency) + ? Math.round(mainSleep.efficiency) + : null + + const asNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string') { + const parsed = Number(value) + if (Number.isFinite(parsed)) { + return parsed + } + } + + return null + } + + const firstNumber = (...values: unknown[]): number | null => { + for (const value of values) { + const parsed = asNumber(value) + if (parsed !== null) { + return parsed + } + } + return null + } + + const getNestedValue = (obj: unknown, path: (string | number)[]): unknown => { + let current: unknown = obj + for (const key of path) { + if (current && typeof current === 'object' && key in (current as Record)) { + current = (current as Record)[key as string] + } else { + return undefined + } + } + return current + } + + const sleepScoreRaw = firstNumber( + getNestedValue(mainSleep, ['score', 'overall']), + getNestedValue(mainSleep, ['score']), + getNestedValue(mainSleep, ['sleepScore']), + getNestedValue(mainSleep, ['levels', 'summary', 'sleepScore']), + getNestedValue(sleepLog, ['summary', 'sleepScore']), + getNestedValue(sleepLog, ['summary', 'sleep_score']) + ) + + const sleepScore = sleepScoreRaw != null ? Math.round(sleepScoreRaw) : null + + const hrvValue = hrvSummary?.hrv?.[0]?.value || null + const hrvRmssd = + typeof hrvValue?.dailyRmssd === 'number' + ? hrvValue.dailyRmssd + : typeof hrvValue?.deepRmssd === 'number' + ? hrvValue.deepRmssd + : null + + const restingHeartRate = + heartRateSummary?.['activities-heart']?.[0]?.value?.restingHeartRate ?? null + + const toMinutesOfDay = (timeValue?: string): number | null => { + if (!timeValue || typeof timeValue !== 'string') { + return null + } + + const isoMatch = timeValue.match(/T(\d{2}):(\d{2})/) + if (isoMatch?.[1] && isoMatch?.[2]) { + return Number(isoMatch[1]) * 60 + Number(isoMatch[2]) + } + + const plainMatch = timeValue.match(/^(\d{2}):(\d{2})/) + if (plainMatch?.[1] && plainMatch?.[2]) { + return Number(plainMatch[1]) * 60 + Number(plainMatch[2]) + } + + return null + } + + const sleepWindowStartMinutes = toMinutesOfDay(mainSleep?.startTime) + const sleepWindowEndMinutes = toMinutesOfDay(mainSleep?.endTime) + + const intradayHeartRates = (heartRateIntraday?.['activities-heart-intraday']?.dataset || []) + .map((point) => { + const hr = + typeof point.value === 'number' && Number.isFinite(point.value) ? point.value : null + const minutes = toMinutesOfDay(point.time) + return { hr, minutes } + }) + .filter( + (point) => + point.hr !== null && point.minutes !== null && point.minutes >= 0 && point.minutes < 1440 + ) + + let likelySleepingRates: number[] = [] + if (sleepWindowStartMinutes !== null && sleepWindowEndMinutes !== null) { + const bufferMinutes = 30 + const windowStart = (sleepWindowStartMinutes - bufferMinutes + 1440) % 1440 + const windowEnd = (sleepWindowEndMinutes + bufferMinutes) % 1440 + const wrapsMidnight = windowStart > windowEnd + + likelySleepingRates = intradayHeartRates + .filter((point) => { + const minute = point.minutes as number + if (wrapsMidnight) { + return minute >= windowStart || minute <= windowEnd + } + return minute >= windowStart && minute <= windowEnd + }) + .map((point) => point.hr as number) + } + + if (likelySleepingRates.length === 0) { + likelySleepingRates = intradayHeartRates + .filter((point) => (point.minutes as number) <= 5 * 60 + 59) + .map((point) => point.hr as number) + } + + const avgSleepingHr = + likelySleepingRates.length > 0 + ? Math.round( + likelySleepingRates.reduce((sum, value) => sum + value, 0) / likelySleepingRates.length + ) + : null + + const weight = firstNumber(weightLog?.weight?.[0]?.weight) + const bodyFat = firstNumber(bodyFatLog?.fat?.[0]?.fat, weightLog?.weight?.[0]?.fat) + + const spO2 = firstNumber( + spO2Summary?.value?.avg, + spO2Summary?.value?.spo2, + spO2Summary?.value?.average, + (spO2Summary as any)?.spo2?.[0]?.value?.avg, + (spO2Summary as any)?.spo2?.[0]?.value?.spo2, + (spO2Summary as any)?.spo2?.[0]?.value + ) + + const respiration = firstNumber( + breathingRateSummary?.br?.[0]?.value?.breathingRate, + breathingRateSummary?.br?.[0]?.value?.avg, + breathingRateSummary?.br?.[0]?.value?.br, + breathingRateSummary?.br?.[0]?.value?.rate, + (breathingRateSummary as any)?.value?.breathingRate, + (breathingRateSummary as any)?.value?.avg + ) + + const hasWellnessData = + hrvRmssd != null || + restingHeartRate != null || + avgSleepingHr != null || + sleepSecs != null || + sleepScore != null || + sleepQuality != null || + deepMinutes != null || + lightMinutes != null || + remMinutes != null || + wakeMinutes != null || + weight != null || + bodyFat != null || + spO2 != null || + respiration != null + + if (!hasWellnessData) { + return null + } + + return { + userId, + date: dateObj, + hrv: hrvRmssd, + hrvSdnn: null, + restingHr: restingHeartRate, + avgSleepingHr, + sleepSecs, + sleepHours, + sleepScore, + sleepQuality, + sleepDeepSecs: + typeof deepMinutes === 'number' && Number.isFinite(deepMinutes) + ? Math.round(deepMinutes * 60) + : null, + sleepRemSecs: + typeof remMinutes === 'number' && Number.isFinite(remMinutes) + ? Math.round(remMinutes * 60) + : null, + sleepLightSecs: + typeof lightMinutes === 'number' && Number.isFinite(lightMinutes) + ? Math.round(lightMinutes * 60) + : null, + sleepAwakeSecs: + typeof wakeMinutes === 'number' && Number.isFinite(wakeMinutes) + ? Math.round(wakeMinutes * 60) + : null, + readiness: null, + recoveryScore: null, + weight, + bodyFat, + spO2, + respiration, + skinTemp: null, + rawJson: { + sleepLog, + hrvSummary, + heartRateSummary, + heartRateIntraday, + weightLog, + bodyFatLog, + spO2Summary, + breathingRateSummary + }, + source: 'fitbit' + } +} + const MEAL_TYPE_MAP: Record = { 1: 'breakfast', 2: 'snacks', diff --git a/server/utils/wellness.ts b/server/utils/wellness.ts index 283d58a3..ddef584e 100644 --- a/server/utils/wellness.ts +++ b/server/utils/wellness.ts @@ -61,3 +61,79 @@ export function getInjuryLabel(val: string | number | null | undefined): string } return map[score] || '' } + +export interface FitbitRecoveryAlertInput { + lastSource?: string | null + hrv?: number | null + sleepHours?: number | null + sleepQuality?: number | null + sleepScore?: number | null + atl?: number | null + recentHrvValues?: Array +} + +export interface FitbitRecoveryAlertResult { + isFitbit: boolean + lowHrv: boolean + poorSleep: boolean + highAtl: boolean + triggered: boolean + baselineHrv: number | null + summary: string +} + +function toFiniteNumber(value: number | null | undefined): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null +} + +export function evaluateFitbitRecoveryAlert( + input: FitbitRecoveryAlertInput +): FitbitRecoveryAlertResult { + const source = `${input.lastSource || ''}`.toLowerCase() + const isFitbit = source === 'fitbit' + + const currentHrv = toFiniteNumber(input.hrv) + const sleepHours = toFiniteNumber(input.sleepHours) + const sleepQuality = toFiniteNumber(input.sleepQuality) + const sleepScore = toFiniteNumber(input.sleepScore) + const atl = toFiniteNumber(input.atl) + + const recentHrvValues = (input.recentHrvValues || []) + .map((value) => toFiniteNumber(value)) + .filter((value): value is number => value !== null) + + const baselineHrv = + recentHrvValues.length >= 5 + ? recentHrvValues.reduce((sum, value) => sum + value, 0) / recentHrvValues.length + : null + + const lowHrv = + currentHrv !== null && + ((baselineHrv !== null && currentHrv < baselineHrv * 0.85) || + (baselineHrv === null && currentHrv < 35)) + + const poorSleep = + (sleepHours !== null && sleepHours < 6.5) || + (sleepScore !== null && sleepScore < 70) || + (sleepQuality !== null && sleepQuality < 75) + + const highAtl = atl !== null && atl >= 95 + const triggered = isFitbit && lowHrv && poorSleep && highAtl + + const baselineText = baselineHrv !== null ? `${baselineHrv.toFixed(1)}ms` : 'unknown' + const summary = triggered + ? `FITBIT RECOVERY ALERT: low HRV + poor sleep + high ATL detected (HRV ${currentHrv ?? 'n/a'}ms vs baseline ${baselineText}, ATL ${atl ?? 'n/a'}). Bias recommendation to rest/reduce intensity today.` + : isFitbit + ? `Fitbit recovery flags — lowHRV:${lowHrv ? 'yes' : 'no'}, poorSleep:${poorSleep ? 'yes' : 'no'}, highATL:${highAtl ? 'yes' : 'no'}. No Fitbit recovery alert triggered.` + : `Not a Fitbit-sourced wellness day; Fitbit recovery alert not evaluated. Flags — lowHRV:${lowHrv ? 'yes' : 'no'}, poorSleep:${poorSleep ? 'yes' : 'no'}, highATL:${highAtl ? 'yes' : 'no'}.` + + return { + isFitbit, + lowHrv, + poorSleep, + highAtl, + triggered, + baselineHrv, + summary + } +} diff --git a/tests/unit/server/utils/fitbit.test.ts b/tests/unit/server/utils/fitbit.test.ts index ef4622fb..e4c3a795 100644 --- a/tests/unit/server/utils/fitbit.test.ts +++ b/tests/unit/server/utils/fitbit.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { mergeFitbitNutritionWithExisting, - normalizeFitbitNutrition + normalizeFitbitNutrition, + normalizeFitbitWellness } from '../../../../server/utils/fitbit' describe('fitbit nutrition normalization and merge', () => { @@ -256,3 +257,186 @@ describe('fitbit nutrition normalization and merge', () => { expect(fitbitItem?.fitbitTimeDerived).toBe(false) }) }) + +describe('fitbit wellness normalization', () => { + it('normalizes sleep, HRV, and resting heart rate into wellness fields', () => { + const normalized = normalizeFitbitWellness( + { + sleep: [ + { + isMainSleep: true, + startTime: '2026-02-28T23:30:00.000', + endTime: '2026-03-01T06:45:00.000', + minutesAsleep: 420, + efficiency: 88, + levels: { + summary: { + deep: { minutes: 90 }, + light: { minutes: 230 }, + rem: { minutes: 100 }, + wake: { minutes: 25 } + } + } + } + ], + summary: { + totalMinutesAsleep: 420 + } + }, + { + hrv: [ + { + dateTime: '2026-03-01', + value: { + dailyRmssd: 54.2 + } + } + ] + }, + { + 'activities-heart': [ + { + dateTime: '2026-03-01', + value: { + restingHeartRate: 47 + } + } + ] + }, + { + 'activities-heart-intraday': { + dataset: [ + { time: '23:30:00', value: 44 }, + { time: '00:15:00', value: 46 }, + { time: '01:10:00', value: 45 }, + { time: '05:55:00', value: 47 }, + { time: '12:00:00', value: 86 } + ] + } + }, + { + weight: [{ weight: 71.4 }] + }, + { + fat: [{ fat: 14.8 }] + }, + { + value: { avg: 96.7 } + }, + { + br: [ + { + value: { breathingRate: 13.2 } + } + ] + }, + 'user-1', + '2026-03-01' + ) + + expect(normalized).not.toBeNull() + expect(normalized?.hrv).toBe(54.2) + expect(normalized?.restingHr).toBe(47) + expect(normalized?.avgSleepingHr).toBe(46) + expect(normalized?.sleepSecs).toBe(25200) + expect(normalized?.sleepHours).toBe(7) + expect(normalized?.sleepScore).toBeNull() + expect(normalized?.sleepQuality).toBe(88) + expect(normalized?.sleepDeepSecs).toBe(5400) + expect(normalized?.sleepLightSecs).toBe(13800) + expect(normalized?.sleepRemSecs).toBe(6000) + expect(normalized?.sleepAwakeSecs).toBe(1500) + expect(normalized?.weight).toBe(71.4) + expect(normalized?.bodyFat).toBe(14.8) + expect(normalized?.spO2).toBe(96.7) + expect(normalized?.respiration).toBe(13.2) + expect(normalized?.source).toBe('fitbit') + }) + + it('maps sleep score when Fitbit payload includes it', () => { + const normalized = normalizeFitbitWellness( + { + sleep: [ + { + isMainSleep: true, + minutesAsleep: 420, + score: { + overall: 82 + } + } + ] + }, + { hrv: [] }, + { 'activities-heart': [] }, + { 'activities-heart-intraday': { dataset: [] } }, + { weight: [] }, + { fat: [] }, + { value: {} }, + { br: [] }, + 'user-1', + '2026-03-01' + ) + + expect(normalized?.sleepScore).toBe(82) + }) + + it('returns null when no wellness metrics are available', () => { + const normalized = normalizeFitbitWellness( + { + sleep: [], + summary: {} + }, + { + hrv: [] + }, + { + 'activities-heart': [] + }, + { + 'activities-heart-intraday': { + dataset: [] + } + }, + { + weight: [] + }, + { + fat: [] + }, + { + value: {} + }, + { + br: [] + }, + 'user-1', + '2026-03-01' + ) + + expect(normalized).toBeNull() + }) + + it('returns null for invalid date input', () => { + const normalized = normalizeFitbitWellness( + { + sleep: [ + { + isMainSleep: true, + minutesAsleep: 420 + } + ] + }, + { hrv: [] }, + { 'activities-heart': [] }, + { 'activities-heart-intraday': { dataset: [] } }, + { weight: [] }, + { fat: [] }, + { value: {} }, + { br: [] }, + 'user-1', + '2026-13-40' + ) + + expect(normalized).toBeNull() + }) +}) diff --git a/tests/unit/server/utils/wellness.test.ts b/tests/unit/server/utils/wellness.test.ts new file mode 100644 index 00000000..16ae95b7 --- /dev/null +++ b/tests/unit/server/utils/wellness.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { evaluateFitbitRecoveryAlert } from '../../../../server/utils/wellness' + +describe('evaluateFitbitRecoveryAlert', () => { + it('triggers alert when fitbit has low HRV, poor sleep, and high ATL', () => { + const result = evaluateFitbitRecoveryAlert({ + lastSource: 'fitbit', + hrv: 40, + sleepHours: 6.1, + sleepQuality: 68, + atl: 102, + recentHrvValues: [52, 50, 49, 51, 53, 50] + }) + + expect(result.isFitbit).toBe(true) + expect(result.lowHrv).toBe(true) + expect(result.poorSleep).toBe(true) + expect(result.highAtl).toBe(true) + expect(result.triggered).toBe(true) + expect(result.summary).toContain('FITBIT RECOVERY ALERT') + }) + + it('does not trigger alert for non-fitbit sources', () => { + const result = evaluateFitbitRecoveryAlert({ + lastSource: 'whoop', + hrv: 25, + sleepHours: 5, + sleepQuality: 60, + atl: 110, + recentHrvValues: [50, 50, 50, 50, 50] + }) + + expect(result.isFitbit).toBe(false) + expect(result.triggered).toBe(false) + expect(result.summary).toContain('not evaluated') + }) +}) diff --git a/trigger/daily-coach.ts b/trigger/daily-coach.ts index 642cd873..444b3545 100644 --- a/trigger/daily-coach.ts +++ b/trigger/daily-coach.ts @@ -19,6 +19,8 @@ import { import { getUserAiSettings } from '../server/utils/ai-user-settings' import { filterGoalsForContext } from '../server/utils/goal-context' import { isWithinPreferredEmailTime } from '../server/utils/email-schedule' +import { getCurrentFitnessSummary } from '../server/utils/training-stress' +import { evaluateFitbitRecoveryAlert } from '../server/utils/wellness' const suggestionSchema = { type: 'object', @@ -61,72 +63,107 @@ export const dailyCoachTask = task({ const todayEnd = getEndOfDayUTC(timezone, todayStart) // Fetch data including email preferences - const [yesterdayWorkout, todayMetric, user, athleteProfile, rawActiveGoals, emailPrefs] = - await Promise.all([ - workoutRepository - .getForUser(userId, { - startDate: yesterdayStart, - endDate: yesterdayEnd, - limit: 1, - orderBy: { date: 'desc' }, - includeDuplicates: false - }) - .then((workouts) => workouts[0]), - wellnessRepository.getByDate(userId, todayDateOnly), - prisma.user.findUnique({ - where: { id: userId }, - select: { - ftp: true, - weight: true, - weightUnits: true, - height: true, - heightUnits: true, - maxHr: true, - language: true, - aiAutoAnalyzeReadiness: true - } - }), - - // Latest athlete profile - prisma.report.findFirst({ - where: { - userId, - type: 'ATHLETE_PROFILE', - status: 'COMPLETED' - }, - orderBy: { createdAt: 'desc' }, - select: { analysisJson: true, createdAt: true } - }), - - // Active goals - prisma.goal.findMany({ - where: { - userId, - status: 'ACTIVE' - }, - orderBy: { priority: 'desc' }, - select: { - title: true, - type: true, - description: true, - targetDate: true, - eventDate: true, - priority: true - } - }), - - // Email Preferences - prisma.emailPreference.findUnique({ - where: { userId_channel: { userId, channel: 'EMAIL' } } + const [ + yesterdayWorkout, + todayMetric, + recentWellness, + currentFitness, + user, + athleteProfile, + rawActiveGoals, + emailPrefs + ] = await Promise.all([ + workoutRepository + .getForUser(userId, { + startDate: yesterdayStart, + endDate: yesterdayEnd, + limit: 1, + orderBy: { date: 'desc' }, + includeDuplicates: false }) - ]) + .then((workouts) => workouts[0]), + wellnessRepository.getByDate(userId, todayDateOnly), + wellnessRepository.getForUser(userId, { + startDate: getStartOfDaysAgoUTC(timezone, 14), + endDate: todayEnd, + where: { + lastSource: 'fitbit' + }, + select: { + date: true, + hrv: true + }, + orderBy: { date: 'desc' } + }), + getCurrentFitnessSummary(userId), + prisma.user.findUnique({ + where: { id: userId }, + select: { + ftp: true, + weight: true, + weightUnits: true, + height: true, + heightUnits: true, + maxHr: true, + language: true, + aiAutoAnalyzeReadiness: true + } + }), + + // Latest athlete profile + prisma.report.findFirst({ + where: { + userId, + type: 'ATHLETE_PROFILE', + status: 'COMPLETED' + }, + orderBy: { createdAt: 'desc' }, + select: { analysisJson: true, createdAt: true } + }), + + // Active goals + prisma.goal.findMany({ + where: { + userId, + status: 'ACTIVE' + }, + orderBy: { priority: 'desc' }, + select: { + title: true, + type: true, + description: true, + targetDate: true, + eventDate: true, + priority: true + } + }), + + // Email Preferences + prisma.emailPreference.findUnique({ + where: { userId_channel: { userId, channel: 'EMAIL' } } + }) + ]) const activeGoals = filterGoalsForContext(rawActiveGoals, timezone, todayDateOnly) + const priorHrvValues = recentWellness + .filter((metric) => metric.date.getTime() < todayDateOnly.getTime()) + .map((metric) => metric.hrv) + + const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ + lastSource: todayMetric?.lastSource, + hrv: todayMetric?.hrv, + sleepHours: todayMetric?.sleepHours, + sleepQuality: todayMetric?.sleepQuality, + sleepScore: todayMetric?.sleepScore, + atl: currentFitness?.atl, + recentHrvValues: priorHrvValues + }) logger.log('Data fetched', { hasYesterdayWorkout: !!yesterdayWorkout, hasTodayMetric: !!todayMetric, hasAthleteProfile: !!athleteProfile, - activeGoals: activeGoals.length + activeGoals: activeGoals.length, + fitbitRecoveryAlert }) // Logic Check: If AUTOMATIC, ensure aiAutoAnalyzeReadiness is enabled @@ -228,6 +265,9 @@ ${todayMetric.spO2 ? `- SpO2: ${todayMetric.spO2}%` : ''}` : 'No recovery data available' } +FITBIT RECOVERY ALERT CHECK: +- ${fitbitRecoveryAlert.summary} + DECISION LOGIC: Use Training Stress Balance (TSB/Form) as primary indicator: - TSB > 25: Detraining risk - need more training stimulus @@ -247,6 +287,7 @@ Also consider: - Multiple high-load days increase fatigue risk - Low HRV combined with high HR indicates stress - Poor sleep (<7h) reduces training capacity +- If Fitbit recovery alert is triggered, prefer 'rest' or 'reduce_intensity' unless user explicitly overrides with strong justification ${activeGoals.length > 0 ? `- Consider how today's recommendation impacts progress toward active goals` : ''} CRITICAL: Base your recommendation on the comprehensive training load data above, especially TSB (Form), not just today's recovery metrics. If Recovery Score is "Unknown", rely on TSB, HRV trend, and Sleep. diff --git a/trigger/ingest-fitbit.ts b/trigger/ingest-fitbit.ts index 4007b9bd..c8fe853e 100644 --- a/trigger/ingest-fitbit.ts +++ b/trigger/ingest-fitbit.ts @@ -3,13 +3,23 @@ import { logger, task } from '@trigger.dev/sdk/v3' import { userIngestionQueue } from './queues' import { prisma } from '../server/utils/db' import { nutritionRepository } from '../server/utils/repositories/nutritionRepository' -import { getUserTimezone } from '../server/utils/date' +import { wellnessRepository } from '../server/utils/repositories/wellnessRepository' +import { getEndOfDayUTC, getUserTimezone } from '../server/utils/date' import { + fetchFitbitBodyFatLog, + fetchFitbitBreathingRateSummary, + fetchFitbitHeartRateIntraday, + fetchFitbitHeartRateSummary, fetchFitbitFoodLog, fetchFitbitFoodGoals, + fetchFitbitHrvSummary, + fetchFitbitSleepLog, + fetchFitbitSpO2Summary, fetchFitbitWaterLog, + fetchFitbitWeightLog, mergeFitbitNutritionWithExisting, - normalizeFitbitNutrition + normalizeFitbitNutrition, + normalizeFitbitWellness } from '../server/utils/fitbit' import { getUserNutritionSettings } from '../server/utils/nutrition/settings' import type { IngestionResult } from './types' @@ -29,7 +39,7 @@ export const ingestFitbitTask = task({ const { userId, startDate, endDate } = payload logger.log('='.repeat(60)) - logger.log('🥗 FITBIT NUTRITION SYNC STARTING') + logger.log('🥗💤 FITBIT NUTRITION + WELLNESS SYNC STARTING') logger.log('='.repeat(60)) logger.log(`User ID: ${userId}`) logger.log(`Date Range: ${startDate} to ${endDate}`) @@ -62,11 +72,14 @@ export const ingestFitbitTask = task({ const start = new Date(startDate) const end = new Date(endDate) + const now = new Date() + const historicalEndLocal = getEndOfDayUTC(timezone, now) + const historicalEnd = end > historicalEndLocal ? historicalEndLocal : end const dates: string[] = [] const currentDate = new Date(start) - while (currentDate <= end) { + while (currentDate <= historicalEnd) { const year = currentDate.getUTCFullYear() const month = String(currentDate.getUTCMonth() + 1).padStart(2, '0') const day = String(currentDate.getUTCDate()).padStart(2, '0') @@ -74,6 +87,34 @@ export const ingestFitbitTask = task({ currentDate.setUTCDate(currentDate.getUTCDate() + 1) } + if (dates.length === 0) { + logger.warn('No Fitbit dates to process after capping to today in user timezone', { + startDate, + endDate, + cappedEndDate: historicalEnd.toISOString() + }) + + await prisma.integration.update({ + where: { id: integration.id }, + data: { + syncStatus: 'SUCCESS', + lastSyncAt: new Date(), + errorMessage: null + } + }) + + return { + success: true, + counts: { + nutrition: 0, + wellness: 0 + }, + userId, + startDate, + endDate + } + } + // Process most recent days first dates.reverse() @@ -83,7 +124,8 @@ export const ingestFitbitTask = task({ logger.log(` Last: ${dates[dates.length - 1]}`) logger.log('-'.repeat(60)) - let upsertedCount = 0 + let nutritionUpsertedCount = 0 + let wellnessUpsertedCount = 0 let skippedCount = 0 let errorCount = 0 @@ -95,6 +137,11 @@ export const ingestFitbitTask = task({ logger.warn('Failed to fetch Fitbit food goals', { error }) } + const intradayHeartRateEnabled = + process.env.FITBIT_ENABLE_INTRADAY_HEART_RATE === 'true' && + `${integration.scope || ''}`.toLowerCase().includes('heartrate') + const wellnessCallDelayMs = 200 + for (const date of dates) { try { const dateObj = new Date( @@ -107,71 +154,218 @@ export const ingestFitbitTask = task({ const today = new Date() const daysDiff = Math.floor((today.getTime() - dateObj.getTime()) / (1000 * 60 * 60 * 24)) const isRecentDate = daysDiff <= 2 + let hasExistingNutrition = false + let hasExistingWellness = false if (!isRecentDate) { - const existing = await nutritionRepository.getByDate(userId, dateObj) - if (existing && (existing.calories || existing.breakfast || existing.lunch)) { + const [existingNutrition, existingWellness] = await Promise.all([ + nutritionRepository.getByDate(userId, dateObj), + wellnessRepository.getByDate(userId, dateObj) + ]) + + hasExistingNutrition = + !!existingNutrition && + !!( + existingNutrition.calories || + existingNutrition.breakfast || + existingNutrition.lunch + ) + + hasExistingWellness = + !!existingWellness && + !!( + existingWellness.hrv || + existingWellness.restingHr || + existingWellness.sleepHours || + existingWellness.sleepSecs || + existingWellness.sleepQuality + ) + + if (hasExistingNutrition && hasExistingWellness) { skippedCount++ - logger.log(`[${date}] ✓ Existing nutrition data - skipping (older date)`) + logger.log(`[${date}] ✓ Existing nutrition + wellness data - skipping (older date)`) continue } } else { logger.log(`[${date}] Recent date - will update even if data exists`) } - logger.log(`[${date}] Fetching Fitbit nutrition logs...`) + const shouldFetchNutrition = isRecentDate || !hasExistingNutrition + const shouldFetchWellness = isRecentDate || !hasExistingWellness - const foodLog = await fetchFitbitFoodLog(integration, date) - // Small delay to avoid immediate back-to-back rate limit hits - await new Promise((resolve) => setTimeout(resolve, 300)) - const waterLog = await fetchFitbitWaterLog(integration, date) + logger.log(`[${date}] Fetching Fitbit nutrition + wellness logs...`) + + let foodLog: Awaited> | null = null + let waterLog: Awaited> | null = null + + if (shouldFetchNutrition) { + foodLog = await fetchFitbitFoodLog(integration, date) + // Small delay to avoid immediate back-to-back rate limit hits + await new Promise((resolve) => setTimeout(resolve, 300)) + waterLog = await fetchFitbitWaterLog(integration, date) + } + + let sleepLog: Awaited> | null = null + let hrvSummary: Awaited> | null = null + let heartRateSummary: Awaited> | null = + null + let heartRateIntraday: Awaited> | null = + null + let weightLog: Awaited> | null = null + let bodyFatLog: Awaited> | null = null + let spO2Summary: Awaited> | null = null + let breathingRateSummary: Awaited< + ReturnType + > | null = null + + if (shouldFetchWellness) { + const pauseBetweenWellnessCalls = async () => + await new Promise((resolve) => setTimeout(resolve, wellnessCallDelayMs)) + + try { + sleepLog = await fetchFitbitSleepLog(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit sleep log`, { error }) + } + await pauseBetweenWellnessCalls() + + try { + hrvSummary = await fetchFitbitHrvSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit HRV summary`, { error }) + } + await pauseBetweenWellnessCalls() + + try { + heartRateSummary = await fetchFitbitHeartRateSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit heart-rate summary`, { error }) + } + await pauseBetweenWellnessCalls() + + if (intradayHeartRateEnabled) { + try { + heartRateIntraday = await fetchFitbitHeartRateIntraday(integration, date) + } catch (error) { + logger.warn( + `[${date}] Failed to fetch Fitbit intraday heart-rate; using daily summary`, + { + error + } + ) + } + + await pauseBetweenWellnessCalls() + } + + try { + weightLog = await fetchFitbitWeightLog(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit weight log`, { error }) + } + await pauseBetweenWellnessCalls() + + try { + bodyFatLog = await fetchFitbitBodyFatLog(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit body-fat log`, { error }) + } + await pauseBetweenWellnessCalls() + + try { + spO2Summary = await fetchFitbitSpO2Summary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit SpO2 summary`, { error }) + } + await pauseBetweenWellnessCalls() + + try { + breathingRateSummary = await fetchFitbitBreathingRateSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit respiration summary`, { error }) + } + } const foodsCount = foodLog?.foods?.length || 0 const calories = foodLog?.summary?.calories || 0 const water = waterLog?.summary?.water || 0 - if (foodsCount === 0 && calories === 0 && water === 0) { + const hasNutritionData = + shouldFetchNutrition && !(foodsCount === 0 && calories === 0 && water === 0) + + const wellness = normalizeFitbitWellness( + sleepLog, + hrvSummary, + heartRateSummary, + heartRateIntraday, + weightLog, + bodyFatLog, + spO2Summary, + breathingRateSummary, + userId, + date + ) + + const hasWellnessData = !!wellness + + if (!hasNutritionData && !hasWellnessData) { skippedCount++ - logger.log(`[${date}] ⊘ Skipping - no nutrition data logged`) + logger.log(`[${date}] ⊘ Skipping - no nutrition or wellness data logged`) continue } - const nutrition = normalizeFitbitNutrition(foodLog, waterLog, foodGoals, userId, date, { - mealPattern: nutritionSettings.mealPattern, - timezone - }) - - const existingDay = await nutritionRepository.getByDate(userId, nutrition.date) - const mergedNutrition = mergeFitbitNutritionWithExisting(nutrition, existingDay) + if (hasNutritionData) { + const nutrition = normalizeFitbitNutrition(foodLog, waterLog, foodGoals, userId, date, { + mealPattern: nutritionSettings.mealPattern, + timezone + }) + + const existingDay = await nutritionRepository.getByDate(userId, nutrition.date) + const mergedNutrition = mergeFitbitNutritionWithExisting(nutrition, existingDay) + + const result = await nutritionRepository.upsert( + userId, + mergedNutrition.date, + mergedNutrition as any, + { + ...mergedNutrition, + aiAnalysis: null, + aiAnalysisJson: null, + aiAnalysisStatus: 'NOT_STARTED', + aiAnalyzedAt: null, + overallScore: null, + macroBalanceScore: null, + qualityScore: null, + adherenceScore: null, + hydrationScore: null, + nutritionalBalanceExplanation: null, + calorieAdherenceExplanation: null, + macroDistributionExplanation: null, + hydrationStatusExplanation: null, + timingOptimizationExplanation: null + } as any + ) - const result = await nutritionRepository.upsert( - userId, - mergedNutrition.date, - mergedNutrition as any, - { - ...mergedNutrition, - aiAnalysis: null, - aiAnalysisJson: null, - aiAnalysisStatus: 'NOT_STARTED', - aiAnalyzedAt: null, - overallScore: null, - macroBalanceScore: null, - qualityScore: null, - adherenceScore: null, - hydrationScore: null, - nutritionalBalanceExplanation: null, - calorieAdherenceExplanation: null, - macroDistributionExplanation: null, - hydrationStatusExplanation: null, - timingOptimizationExplanation: null - } as any - ) + if (result) { + nutritionUpsertedCount++ + } + } - if (result) { - upsertedCount++ - logger.log(`[${date}] ✓ Synced successfully`) + if (wellness) { + await wellnessRepository.upsert( + userId, + wellness.date, + wellness as any, + wellness as any, + 'fitbit' + ) + wellnessUpsertedCount++ } + logger.log( + `[${date}] ✓ Synced successfully (nutrition: ${hasNutritionData ? 'yes' : 'no'}, wellness: ${hasWellnessData ? 'yes' : 'no'})` + ) + // Throttle between days to reduce rate limit pressure await new Promise((resolve) => setTimeout(resolve, 500)) } catch (error) { @@ -187,7 +381,8 @@ export const ingestFitbitTask = task({ logger.log('='.repeat(60)) logger.log('✅ FITBIT SYNC COMPLETED') - logger.log(` Upserted: ${upsertedCount}`) + logger.log(` Nutrition upserted: ${nutritionUpsertedCount}`) + logger.log(` Wellness upserted: ${wellnessUpsertedCount}`) logger.log(` Skipped: ${skippedCount}`) logger.log(` Errors: ${errorCount}`) logger.log('='.repeat(60)) @@ -204,7 +399,8 @@ export const ingestFitbitTask = task({ return { success: true, counts: { - nutrition: upsertedCount + nutrition: nutritionUpsertedCount, + wellness: wellnessUpsertedCount }, userId, startDate, diff --git a/trigger/recommend-today-activity.ts b/trigger/recommend-today-activity.ts index fab40ecd..c8eea1ea 100644 --- a/trigger/recommend-today-activity.ts +++ b/trigger/recommend-today-activity.ts @@ -28,7 +28,8 @@ import { getSorenessLabel, getMotivationLabel, getHydrationLabel, - getInjuryLabel + getInjuryLabel, + evaluateFitbitRecoveryAlert } from '../server/utils/wellness' interface RecommendationAnalysis { @@ -242,6 +243,7 @@ export const recommendTodayActivityTask = task({ sportSettings, todayAvailability, weeklyAvailability, + recentWellness, mealTargetContext ] = await Promise.all([ // Today's planned workouts (Fetch ALL to handle multi-session days) @@ -376,6 +378,19 @@ export const recommendTodayActivityTask = task({ // Full weekly training availability (for forward-looking guidance) availabilityRepository.getFullSchedule(userId), + wellnessRepository.getForUser(userId, { + startDate: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), + endDate: today, + where: { + lastSource: 'fitbit' + }, + select: { + date: true, + hrv: true + }, + orderBy: { date: 'desc' } + }), + // Canonical metabolic meal target context (same engine as nutrition charts) nutritionEnabled ? metabolicService.getMealTargetContext(userId, today, new Date()) @@ -649,6 +664,25 @@ ${analysis.recommendations ? 'Recommendations:\n' + analysis.recommendations.map ` } + const priorHrvValues = recentWellness + .filter((metric) => metric.date.getTime() < today.getTime()) + .map((metric) => metric.hrv) + + const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ + lastSource: enrichedTodayMetric?.lastSource, + hrv: enrichedTodayMetric?.hrv, + sleepHours: enrichedTodayMetric?.sleepHours, + sleepQuality: enrichedTodayMetric?.sleepQuality, + sleepScore: enrichedTodayMetric?.sleepScore, + atl: currentFitness?.atl, + recentHrvValues: priorHrvValues + }) + + const fitbitRecoveryAlertContext = ` +FITBIT RECOVERY ALERT CHECK: +- ${fitbitRecoveryAlert.summary} +` + // Build canonical metabolic meal-target context const mealTargetContextText = mealTargetContext ? ` @@ -766,6 +800,7 @@ ${enrichedTodayMetric.spO2 ? `- SpO2: ${enrichedTodayMetric.spO2}%` : ''} } ${wellnessAnalysisContext} +${fitbitRecoveryAlertContext} ${mealTargetContextText} ${checkinsSummary} @@ -790,6 +825,7 @@ CRITICAL INSTRUCTIONS: 3. IGNORE any conflicting TSB/CTL values found in the "ATHLETE PROFILE" section if they differ from the Source of Truth, as they may be stale summaries. 4. Refer to the "PROJECTED FITNESS TRENDS" for future state, but base your primary decision on the current TSB and recovery metrics. 5. RESPECT TRAINING AVAILABILITY: do not recommend sessions outside declared availability windows unless user feedback explicitly asks to override. +6. If Fitbit recovery alert is triggered, bias strongly toward 'rest' or 'reduce_intensity' unless user feedback explicitly requests otherwise. ${zoneDefinitions}