From 565193704a4929534985a948023a7b8efbf73459 Mon Sep 17 00:00:00 2001 From: Simon Hill Date: Mon, 2 Mar 2026 22:37:05 +1100 Subject: [PATCH 1/4] Implement more wellness features from fitbit - sleep, HRV, HR. Wire fitbit into readiness automation --- .env.example | 1 + app/pages/connect-fitbit.vue | 15 +- .../api/integrations/fitbit/authorize.get.ts | 2 +- server/utils/fitbit.ts | 252 ++++++++++++++++++ server/utils/wellness.ts | 75 ++++++ tests/unit/server/utils/fitbit.test.ts | 99 ++++++- tests/unit/server/utils/wellness.test.ts | 36 +++ trigger/daily-coach.ts | 154 +++++++---- trigger/ingest-fitbit.ts | 186 ++++++++++--- trigger/recommend-today-activity.ts | 32 ++- 10 files changed, 744 insertions(+), 108 deletions(-) create mode 100644 tests/unit/server/utils/wellness.test.ts diff --git a/.env.example b/.env.example index fa1603e0..0165fdfc 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,7 @@ WITHINGS_CLIENT_SECRET="" FITBIT_CLIENT_ID="" FITBIT_CLIENT_SECRET="" FITBIT_SUBSCRIBER_VERIFICATION_CODE="your-verification-code" +FITBIT_ENABLE_INTRADAY_HEART_RATE="true" # Redis / DragonflyDB REDIS_URL=redis://:dragonfly@localhost:6379 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/server/api/integrations/fitbit/authorize.get.ts b/server/api/integrations/fitbit/authorize.get.ts index 3cbf8586..c2182fc2 100644 --- a/server/api/integrations/fitbit/authorize.get.ts +++ b/server/api/integrations/fitbit/authorize.get.ts @@ -46,7 +46,7 @@ export default defineEventHandler(async (event) => { path: '/' }) - const scope = 'nutrition' + const scope = 'nutrition sleep heartrate' const params = new URLSearchParams({ response_type: 'code', diff --git a/server/utils/fitbit.ts b/server/utils/fitbit.ts index 05ba317a..1d894938 100644 --- a/server/utils/fitbit.ts +++ b/server/utils/fitbit.ts @@ -89,6 +89,82 @@ 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 + } +} + function encodeBasicAuth(clientId: string, clientSecret: string) { const creds = `${clientId}:${clientSecret}` return Buffer.from(creds).toString('base64') @@ -288,6 +364,182 @@ 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 function normalizeFitbitWellness( + sleepLog: FitbitSleepLogResponse | null, + hrvSummary: FitbitHrvSummaryResponse | null, + heartRateSummary: FitbitHeartRateResponse | null, + heartRateIntraday: FitbitHeartRateIntradayResponse | null, + userId: string, + date: string +) { + const [year = 0, month = 1, day = 1] = date.split('-').map(Number) + const dateObj = new Date(Date.UTC(year, month - 1, day)) + + 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 ?? levelSummary.asleep?.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 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 intradayHeartRates = (heartRateIntraday?.['activities-heart-intraday']?.dataset || []) + .map((point) => { + const hr = + typeof point.value === 'number' && Number.isFinite(point.value) ? point.value : null + const hour = typeof point.time === 'string' ? Number(point.time.split(':')[0]) : NaN + return { hr, hour } + }) + .filter((point) => point.hr !== null && Number.isFinite(point.hour) && point.hour >= 0) + + const likelySleepingRates = intradayHeartRates + .filter((point) => point.hour <= 5) + .map((point) => point.hr as number) + + const avgSleepingHr = + likelySleepingRates.length > 0 + ? Math.round( + likelySleepingRates.reduce((sum, value) => sum + value, 0) / likelySleepingRates.length + ) + : null + + const hasWellnessData = + hrvRmssd != null || + restingHeartRate != null || + avgSleepingHr != null || + sleepSecs != null || + sleepQuality != null || + deepMinutes != null || + lightMinutes != null || + remMinutes != null || + wakeMinutes != null + + if (!hasWellnessData) { + return null + } + + return { + userId, + date: dateObj, + hrv: hrvRmssd, + hrvSdnn: null, + restingHr: restingHeartRate, + avgSleepingHr, + sleepSecs, + sleepHours, + sleepScore: null, + 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, + spO2: null, + respiration: null, + skinTemp: null, + rawJson: { + sleepLog, + hrvSummary, + heartRateSummary, + heartRateIntraday + }, + 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..f10ef3c3 100644 --- a/server/utils/wellness.ts +++ b/server/utils/wellness.ts @@ -61,3 +61,78 @@ export function getInjuryLabel(val: string | number | null | undefined): string } return map[score] || '' } + +export interface FitbitRecoveryAlertInput { + source?: string | null + 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 || input.source || ''}`.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.` + : `Fitbit recovery 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..26f86943 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,99 @@ 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, + 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: '00:15:00', value: 46 }, + { time: '01:10:00', value: 45 }, + { time: '05:55:00', value: 47 }, + { time: '12:00:00', value: 86 } + ] + } + }, + '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?.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?.source).toBe('fitbit') + }) + + it('returns null when no wellness metrics are available', () => { + const normalized = normalizeFitbitWellness( + { + sleep: [], + summary: {} + }, + { + hrv: [] + }, + { + 'activities-heart': [] + }, + { + 'activities-heart-intraday': { + dataset: [] + } + }, + 'user-1', + '2026-03-01' + ) + + 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..672b0fb8 --- /dev/null +++ b/tests/unit/server/utils/wellness.test.ts @@ -0,0 +1,36 @@ +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({ + source: '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({ + source: '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) + }) +}) diff --git a/trigger/daily-coach.ts b/trigger/daily-coach.ts index 642cd873..998f237a 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,102 @@ 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, + 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 fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ + source: todayMetric?.source, + lastSource: todayMetric?.lastSource, + hrv: todayMetric?.hrv, + sleepHours: todayMetric?.sleepHours, + sleepQuality: todayMetric?.sleepQuality, + sleepScore: todayMetric?.sleepScore, + atl: currentFitness?.atl, + recentHrvValues: recentWellness.map((metric) => metric.hrv) + }) + 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 +260,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 +282,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..2b83df37 100644 --- a/trigger/ingest-fitbit.ts +++ b/trigger/ingest-fitbit.ts @@ -3,13 +3,19 @@ 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 { wellnessRepository } from '../server/utils/repositories/wellnessRepository' import { getUserTimezone } from '../server/utils/date' import { + fetchFitbitHeartRateIntraday, + fetchFitbitHeartRateSummary, fetchFitbitFoodLog, fetchFitbitFoodGoals, + fetchFitbitHrvSummary, + fetchFitbitSleepLog, fetchFitbitWaterLog, mergeFitbitNutritionWithExisting, - normalizeFitbitNutrition + normalizeFitbitNutrition, + normalizeFitbitWellness } from '../server/utils/fitbit' import { getUserNutritionSettings } from '../server/utils/nutrition/settings' import type { IngestionResult } from './types' @@ -29,7 +35,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}`) @@ -83,7 +89,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 +102,10 @@ 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') + for (const date of dates) { try { const dateObj = new Date( @@ -109,69 +120,156 @@ export const ingestFitbitTask = task({ const isRecentDate = daysDiff <= 2 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) + ]) + + const hasExistingNutrition = + !!existingNutrition && + !!( + existingNutrition.calories || + existingNutrition.breakfast || + existingNutrition.lunch + ) + + const 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...`) + logger.log(`[${date}] Fetching Fitbit nutrition + wellness logs...`) 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) + let sleepLog: any = null + let hrvSummary: any = null + let heartRateSummary: any = null + let heartRateIntraday: any = null + + try { + sleepLog = await fetchFitbitSleepLog(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit sleep log`, { error }) + } + + try { + hrvSummary = await fetchFitbitHrvSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit HRV summary`, { error }) + } + + try { + heartRateSummary = await fetchFitbitHeartRateSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit heart-rate summary`, { error }) + } + + if (intradayHeartRateEnabled) { + try { + heartRateIntraday = await fetchFitbitHeartRateIntraday(integration, date) + } catch (error) { + logger.warn( + `[${date}] Failed to fetch Fitbit intraday heart-rate; using daily 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 = !(foodsCount === 0 && calories === 0 && water === 0) + + const wellness = normalizeFitbitWellness( + sleepLog, + hrvSummary, + heartRateSummary, + heartRateIntraday, + 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 +285,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 +303,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 fbc15ab6..163b4ec1 100644 --- a/trigger/recommend-today-activity.ts +++ b/trigger/recommend-today-activity.ts @@ -27,7 +27,8 @@ import { getSorenessLabel, getMotivationLabel, getHydrationLabel, - getInjuryLabel + getInjuryLabel, + evaluateFitbitRecoveryAlert } from '../server/utils/wellness' interface RecommendationAnalysis { @@ -235,6 +236,7 @@ export const recommendTodayActivityTask = task({ sportSettings, todayAvailability, weeklyAvailability, + recentWellness, mealTargetContext ] = await Promise.all([ // Today's planned workouts (Fetch ALL to handle multi-session days) @@ -369,6 +371,16 @@ 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, + 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()) @@ -642,6 +654,22 @@ ${analysis.recommendations ? 'Recommendations:\n' + analysis.recommendations.map ` } + const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ + source: enrichedTodayMetric?.source, + lastSource: enrichedTodayMetric?.lastSource, + hrv: enrichedTodayMetric?.hrv, + sleepHours: enrichedTodayMetric?.sleepHours, + sleepQuality: enrichedTodayMetric?.sleepQuality, + sleepScore: enrichedTodayMetric?.sleepScore, + atl: currentFitness?.atl, + recentHrvValues: recentWellness.map((metric) => metric.hrv) + }) + + const fitbitRecoveryAlertContext = ` +FITBIT RECOVERY ALERT CHECK: +- ${fitbitRecoveryAlert.summary} +` + // Build canonical metabolic meal-target context const mealTargetContextText = mealTargetContext ? ` @@ -759,6 +787,7 @@ ${enrichedTodayMetric.spO2 ? `- SpO2: ${enrichedTodayMetric.spO2}%` : ''} } ${wellnessAnalysisContext} +${fitbitRecoveryAlertContext} ${mealTargetContextText} ${checkinsSummary} @@ -783,6 +812,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} From c11336d332a7eb7146ec8545f1e3cd5c71660062 Mon Sep 17 00:00:00 2001 From: red_one Date: Tue, 3 Mar 2026 10:19:26 +1100 Subject: [PATCH 2/4] Update .env.example Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 0165fdfc..a50d27fc 100644 --- a/.env.example +++ b/.env.example @@ -54,7 +54,8 @@ WITHINGS_CLIENT_SECRET="" FITBIT_CLIENT_ID="" FITBIT_CLIENT_SECRET="" FITBIT_SUBSCRIBER_VERIFICATION_CODE="your-verification-code" -FITBIT_ENABLE_INTRADAY_HEART_RATE="true" +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 From 2118a5772e34763a20ead24576685682f754c3c5 Mon Sep 17 00:00:00 2001 From: Simon Hill Date: Tue, 3 Mar 2026 10:32:29 +1100 Subject: [PATCH 3/4] fix(fitbit): address review feedback on recovery and ingestion --- server/utils/fitbit.ts | 59 ++++++++++++++++--- server/utils/wellness.ts | 3 +- tests/unit/server/utils/fitbit.test.ts | 3 + tests/unit/server/utils/wellness.test.ts | 4 +- trigger/daily-coach.ts | 6 +- trigger/ingest-fitbit.ts | 75 ++++++++++++++---------- trigger/recommend-today-activity.ts | 7 ++- 7 files changed, 110 insertions(+), 47 deletions(-) diff --git a/server/utils/fitbit.ts b/server/utils/fitbit.ts index 1d894938..8d185d21 100644 --- a/server/utils/fitbit.ts +++ b/server/utils/fitbit.ts @@ -429,8 +429,7 @@ export function normalizeFitbitWellness( const summaryStageMinutes = sleepSummary.stages || {} const levelSummary = mainSleep?.levels?.summary || {} - const deepMinutes = - summaryStageMinutes.deep ?? levelSummary.deep?.minutes ?? levelSummary.asleep?.minutes ?? null + 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 = @@ -463,18 +462,62 @@ export function normalizeFitbitWellness( 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 hour = typeof point.time === 'string' ? Number(point.time.split(':')[0]) : NaN - return { hr, hour } + const minutes = toMinutesOfDay(point.time) + return { hr, minutes } }) - .filter((point) => point.hr !== null && Number.isFinite(point.hour) && point.hour >= 0) + .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) + } - const likelySleepingRates = intradayHeartRates - .filter((point) => point.hour <= 5) - .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 diff --git a/server/utils/wellness.ts b/server/utils/wellness.ts index f10ef3c3..bf2b4f2a 100644 --- a/server/utils/wellness.ts +++ b/server/utils/wellness.ts @@ -63,7 +63,6 @@ export function getInjuryLabel(val: string | number | null | undefined): string } export interface FitbitRecoveryAlertInput { - source?: string | null lastSource?: string | null hrv?: number | null sleepHours?: number | null @@ -90,7 +89,7 @@ function toFiniteNumber(value: number | null | undefined): number | null { export function evaluateFitbitRecoveryAlert( input: FitbitRecoveryAlertInput ): FitbitRecoveryAlertResult { - const source = `${input.lastSource || input.source || ''}`.toLowerCase() + const source = `${input.lastSource || ''}`.toLowerCase() const isFitbit = source === 'fitbit' const currentHrv = toFiniteNumber(input.hrv) diff --git a/tests/unit/server/utils/fitbit.test.ts b/tests/unit/server/utils/fitbit.test.ts index 26f86943..d36104e6 100644 --- a/tests/unit/server/utils/fitbit.test.ts +++ b/tests/unit/server/utils/fitbit.test.ts @@ -265,6 +265,8 @@ describe('fitbit wellness normalization', () => { sleep: [ { isMainSleep: true, + startTime: '2026-02-28T23:30:00.000', + endTime: '2026-03-01T06:45:00.000', minutesAsleep: 420, efficiency: 88, levels: { @@ -304,6 +306,7 @@ describe('fitbit wellness normalization', () => { { '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 }, diff --git a/tests/unit/server/utils/wellness.test.ts b/tests/unit/server/utils/wellness.test.ts index 672b0fb8..3281309c 100644 --- a/tests/unit/server/utils/wellness.test.ts +++ b/tests/unit/server/utils/wellness.test.ts @@ -4,7 +4,7 @@ import { evaluateFitbitRecoveryAlert } from '../../../../server/utils/wellness' describe('evaluateFitbitRecoveryAlert', () => { it('triggers alert when fitbit has low HRV, poor sleep, and high ATL', () => { const result = evaluateFitbitRecoveryAlert({ - source: 'fitbit', + lastSource: 'fitbit', hrv: 40, sleepHours: 6.1, sleepQuality: 68, @@ -22,7 +22,7 @@ describe('evaluateFitbitRecoveryAlert', () => { it('does not trigger alert for non-fitbit sources', () => { const result = evaluateFitbitRecoveryAlert({ - source: 'whoop', + lastSource: 'whoop', hrv: 25, sleepHours: 5, sleepQuality: 60, diff --git a/trigger/daily-coach.ts b/trigger/daily-coach.ts index 998f237a..aa150e0b 100644 --- a/trigger/daily-coach.ts +++ b/trigger/daily-coach.ts @@ -141,16 +141,18 @@ export const dailyCoachTask = task({ }) ]) const activeGoals = filterGoalsForContext(rawActiveGoals, timezone, todayDateOnly) + const priorHrvValues = recentWellness + .filter((metric) => metric.date.getTime() < todayDateOnly.getTime()) + .map((metric) => metric.hrv) const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ - source: todayMetric?.source, lastSource: todayMetric?.lastSource, hrv: todayMetric?.hrv, sleepHours: todayMetric?.sleepHours, sleepQuality: todayMetric?.sleepQuality, sleepScore: todayMetric?.sleepScore, atl: currentFitness?.atl, - recentHrvValues: recentWellness.map((metric) => metric.hrv) + recentHrvValues: priorHrvValues }) logger.log('Data fetched', { diff --git a/trigger/ingest-fitbit.ts b/trigger/ingest-fitbit.ts index 2b83df37..cc4ab30f 100644 --- a/trigger/ingest-fitbit.ts +++ b/trigger/ingest-fitbit.ts @@ -118,6 +118,8 @@ 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 [existingNutrition, existingWellness] = await Promise.all([ @@ -125,7 +127,7 @@ export const ingestFitbitTask = task({ wellnessRepository.getByDate(userId, dateObj) ]) - const hasExistingNutrition = + hasExistingNutrition = !!existingNutrition && !!( existingNutrition.calories || @@ -133,7 +135,7 @@ export const ingestFitbitTask = task({ existingNutrition.lunch ) - const hasExistingWellness = + hasExistingWellness = !!existingWellness && !!( existingWellness.hrv || @@ -152,46 +154,56 @@ export const ingestFitbitTask = task({ logger.log(`[${date}] Recent date - will update even if data exists`) } + const shouldFetchNutrition = isRecentDate || !hasExistingNutrition + const shouldFetchWellness = isRecentDate || !hasExistingWellness + logger.log(`[${date}] Fetching Fitbit nutrition + wellness logs...`) - 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) + let foodLog: any = null + let waterLog: any = 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: any = null let hrvSummary: any = null let heartRateSummary: any = null let heartRateIntraday: any = null - try { - sleepLog = await fetchFitbitSleepLog(integration, date) - } catch (error) { - logger.warn(`[${date}] Failed to fetch Fitbit sleep log`, { error }) - } - - try { - hrvSummary = await fetchFitbitHrvSummary(integration, date) - } catch (error) { - logger.warn(`[${date}] Failed to fetch Fitbit HRV summary`, { error }) - } + if (shouldFetchWellness) { + try { + sleepLog = await fetchFitbitSleepLog(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit sleep log`, { error }) + } - try { - heartRateSummary = await fetchFitbitHeartRateSummary(integration, date) - } catch (error) { - logger.warn(`[${date}] Failed to fetch Fitbit heart-rate summary`, { error }) - } + try { + hrvSummary = await fetchFitbitHrvSummary(integration, date) + } catch (error) { + logger.warn(`[${date}] Failed to fetch Fitbit HRV summary`, { error }) + } - if (intradayHeartRateEnabled) { try { - heartRateIntraday = await fetchFitbitHeartRateIntraday(integration, date) + heartRateSummary = await fetchFitbitHeartRateSummary(integration, date) } catch (error) { - logger.warn( - `[${date}] Failed to fetch Fitbit intraday heart-rate; using daily summary`, - { - error - } - ) + logger.warn(`[${date}] Failed to fetch Fitbit heart-rate summary`, { error }) + } + + if (intradayHeartRateEnabled) { + try { + heartRateIntraday = await fetchFitbitHeartRateIntraday(integration, date) + } catch (error) { + logger.warn( + `[${date}] Failed to fetch Fitbit intraday heart-rate; using daily summary`, + { + error + } + ) + } } } @@ -199,7 +211,8 @@ export const ingestFitbitTask = task({ const calories = foodLog?.summary?.calories || 0 const water = waterLog?.summary?.water || 0 - const hasNutritionData = !(foodsCount === 0 && calories === 0 && water === 0) + const hasNutritionData = + shouldFetchNutrition && !(foodsCount === 0 && calories === 0 && water === 0) const wellness = normalizeFitbitWellness( sleepLog, diff --git a/trigger/recommend-today-activity.ts b/trigger/recommend-today-activity.ts index 163b4ec1..5ce774f5 100644 --- a/trigger/recommend-today-activity.ts +++ b/trigger/recommend-today-activity.ts @@ -654,15 +654,18 @@ ${analysis.recommendations ? 'Recommendations:\n' + analysis.recommendations.map ` } + const priorHrvValues = recentWellness + .filter((metric) => metric.date.getTime() < today.getTime()) + .map((metric) => metric.hrv) + const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ - source: enrichedTodayMetric?.source, lastSource: enrichedTodayMetric?.lastSource, hrv: enrichedTodayMetric?.hrv, sleepHours: enrichedTodayMetric?.sleepHours, sleepQuality: enrichedTodayMetric?.sleepQuality, sleepScore: enrichedTodayMetric?.sleepScore, atl: currentFitness?.atl, - recentHrvValues: recentWellness.map((metric) => metric.hrv) + recentHrvValues: priorHrvValues }) const fitbitRecoveryAlertContext = ` From 0d59d24a3abad9a174104bd0fc852867a2cad728 Mon Sep 17 00:00:00 2001 From: Simon Hill Date: Tue, 3 Mar 2026 15:23:08 +1100 Subject: [PATCH 4/4] feat(fitbit): enhance wellness data integration with additional metrics and validations --- app/composables/useDataStatus.ts | 3 + app/pages/dashboard.vue | 5 +- .../api/integrations/fitbit/authorize.get.ts | 9 +- server/api/profile/dashboard.get.ts | 15 ++ server/utils/fitbit.ts | 199 +++++++++++++++++- server/utils/wellness.ts | 4 +- tests/unit/server/utils/fitbit.test.ts | 84 ++++++++ tests/unit/server/utils/wellness.test.ts | 1 + trigger/daily-coach.ts | 3 + trigger/ingest-fitbit.ts | 99 ++++++++- trigger/recommend-today-activity.ts | 3 + 11 files changed, 408 insertions(+), 17 deletions(-) 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/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 c2182fc2..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 sleep heartrate' + 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 1319e929..3f0bf324 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' export default defineEventHandler(async (event) => { const session = await getServerSession(event) @@ -47,12 +48,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, latestWeightWellness, 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 } }, @@ -102,6 +108,9 @@ export default defineEventHandler(async (event) => { prisma.dailyMetric.findFirst({ where: { userId: user.id, + date: { + lte: latestAllowedDate + }, OR: [ { restingHr: { not: null } }, { hrv: { not: null } }, @@ -133,6 +142,9 @@ export default defineEventHandler(async (event) => { prisma.wellness.findFirst({ where: { userId: user.id, + date: { + lte: latestAllowedDate + }, weight: { not: null } }, orderBy: { date: 'desc' }, @@ -141,6 +153,9 @@ export default defineEventHandler(async (event) => { 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 8d185d21..ed5c0c04 100644 --- a/server/utils/fitbit.ts +++ b/server/utils/fitbit.ts @@ -165,6 +165,55 @@ export interface FitbitHeartRateIntradayResponse { } } +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') @@ -410,17 +459,75 @@ export async function fetchFitbitHeartRateIntraday( } } +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 [year = 0, month = 1, day = 1] = date.split('-').map(Number) + 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) @@ -451,6 +558,54 @@ export function normalizeFitbitWellness( ? 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' @@ -526,16 +681,42 @@ export function normalizeFitbitWellness( ) : 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 + wakeMinutes != null || + weight != null || + bodyFat != null || + spO2 != null || + respiration != null if (!hasWellnessData) { return null @@ -550,7 +731,7 @@ export function normalizeFitbitWellness( avgSleepingHr, sleepSecs, sleepHours, - sleepScore: null, + sleepScore, sleepQuality, sleepDeepSecs: typeof deepMinutes === 'number' && Number.isFinite(deepMinutes) @@ -570,14 +751,20 @@ export function normalizeFitbitWellness( : null, readiness: null, recoveryScore: null, - spO2: null, - respiration: null, + weight, + bodyFat, + spO2, + respiration, skinTemp: null, rawJson: { sleepLog, hrvSummary, heartRateSummary, - heartRateIntraday + heartRateIntraday, + weightLog, + bodyFatLog, + spO2Summary, + breathingRateSummary }, source: 'fitbit' } diff --git a/server/utils/wellness.ts b/server/utils/wellness.ts index bf2b4f2a..ddef584e 100644 --- a/server/utils/wellness.ts +++ b/server/utils/wellness.ts @@ -123,7 +123,9 @@ export function evaluateFitbitRecoveryAlert( 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.` - : `Fitbit recovery flags — lowHRV:${lowHrv ? 'yes' : 'no'}, poorSleep:${poorSleep ? 'yes' : 'no'}, highATL:${highAtl ? 'yes' : 'no'}.` + : 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, diff --git a/tests/unit/server/utils/fitbit.test.ts b/tests/unit/server/utils/fitbit.test.ts index d36104e6..e4c3a795 100644 --- a/tests/unit/server/utils/fitbit.test.ts +++ b/tests/unit/server/utils/fitbit.test.ts @@ -314,6 +314,22 @@ describe('fitbit wellness normalization', () => { ] } }, + { + weight: [{ weight: 71.4 }] + }, + { + fat: [{ fat: 14.8 }] + }, + { + value: { avg: 96.7 } + }, + { + br: [ + { + value: { breathingRate: 13.2 } + } + ] + }, 'user-1', '2026-03-01' ) @@ -324,14 +340,46 @@ describe('fitbit wellness normalization', () => { 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( { @@ -349,10 +397,46 @@ describe('fitbit wellness normalization', () => { 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 index 3281309c..16ae95b7 100644 --- a/tests/unit/server/utils/wellness.test.ts +++ b/tests/unit/server/utils/wellness.test.ts @@ -32,5 +32,6 @@ describe('evaluateFitbitRecoveryAlert', () => { 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 aa150e0b..444b3545 100644 --- a/trigger/daily-coach.ts +++ b/trigger/daily-coach.ts @@ -86,6 +86,9 @@ export const dailyCoachTask = task({ wellnessRepository.getForUser(userId, { startDate: getStartOfDaysAgoUTC(timezone, 14), endDate: todayEnd, + where: { + lastSource: 'fitbit' + }, select: { date: true, hrv: true diff --git a/trigger/ingest-fitbit.ts b/trigger/ingest-fitbit.ts index cc4ab30f..c8fe853e 100644 --- a/trigger/ingest-fitbit.ts +++ b/trigger/ingest-fitbit.ts @@ -4,15 +4,19 @@ import { userIngestionQueue } from './queues' import { prisma } from '../server/utils/db' import { nutritionRepository } from '../server/utils/repositories/nutritionRepository' import { wellnessRepository } from '../server/utils/repositories/wellnessRepository' -import { getUserTimezone } from '../server/utils/date' +import { getEndOfDayUTC, getUserTimezone } from '../server/utils/date' import { + fetchFitbitBodyFatLog, + fetchFitbitBreathingRateSummary, fetchFitbitHeartRateIntraday, fetchFitbitHeartRateSummary, fetchFitbitFoodLog, fetchFitbitFoodGoals, fetchFitbitHrvSummary, fetchFitbitSleepLog, + fetchFitbitSpO2Summary, fetchFitbitWaterLog, + fetchFitbitWeightLog, mergeFitbitNutritionWithExisting, normalizeFitbitNutrition, normalizeFitbitWellness @@ -68,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') @@ -80,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() @@ -105,6 +140,7 @@ export const ingestFitbitTask = task({ const intradayHeartRateEnabled = process.env.FITBIT_ENABLE_INTRADAY_HEART_RATE === 'true' && `${integration.scope || ''}`.toLowerCase().includes('heartrate') + const wellnessCallDelayMs = 200 for (const date of dates) { try { @@ -159,8 +195,8 @@ export const ingestFitbitTask = task({ logger.log(`[${date}] Fetching Fitbit nutrition + wellness logs...`) - let foodLog: any = null - let waterLog: any = null + let foodLog: Awaited> | null = null + let waterLog: Awaited> | null = null if (shouldFetchNutrition) { foodLog = await fetchFitbitFoodLog(integration, date) @@ -169,29 +205,43 @@ export const ingestFitbitTask = task({ waterLog = await fetchFitbitWaterLog(integration, date) } - let sleepLog: any = null - let hrvSummary: any = null - let heartRateSummary: any = null - let heartRateIntraday: any = null + 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 { @@ -204,6 +254,35 @@ export const ingestFitbitTask = task({ } ) } + + 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 }) } } @@ -219,6 +298,10 @@ export const ingestFitbitTask = task({ hrvSummary, heartRateSummary, heartRateIntraday, + weightLog, + bodyFatLog, + spO2Summary, + breathingRateSummary, userId, date ) diff --git a/trigger/recommend-today-activity.ts b/trigger/recommend-today-activity.ts index 5ce774f5..b086628d 100644 --- a/trigger/recommend-today-activity.ts +++ b/trigger/recommend-today-activity.ts @@ -374,6 +374,9 @@ export const recommendTodayActivityTask = task({ wellnessRepository.getForUser(userId, { startDate: new Date(today.getTime() - 14 * 24 * 60 * 60 * 1000), endDate: today, + where: { + lastSource: 'fitbit' + }, select: { date: true, hrv: true