Conversation
There was a problem hiding this comment.
Pull request overview
Expands the Fitbit integration from nutrition-only syncing into a broader wellness + recovery signal used both for ingestion (sleep/HRV/HR) and for deterministic “recovery risk” biasing inside coaching prompts.
Changes:
- Expanded Fitbit OAuth scope and updated connection UI copy to reflect new permissions and value.
- Added Fitbit wellness ingestion + normalization (sleep, HRV, resting HR) with optional intraday HR enrichment.
- Introduced a deterministic Fitbit recovery alert check in coaching flows, with new unit tests.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| trigger/recommend-today-activity.ts | Adds recent HRV fetch and injects Fitbit recovery alert context into the recommendation prompt. |
| trigger/ingest-fitbit.ts | Extends ingestion to pull Fitbit sleep/HRV/HR (plus optional intraday HR) and upsert into wellness records. |
| trigger/daily-coach.ts | Adds Fitbit recovery alert evaluation and prompt guidance for daily coaching decisions. |
| server/utils/wellness.ts | Introduces evaluateFitbitRecoveryAlert utility and related types. |
| server/utils/fitbit.ts | Adds Fitbit API fetch helpers for sleep/HRV/HR and normalization into Wellness-compatible fields. |
| server/api/integrations/fitbit/authorize.get.ts | Expands OAuth scopes requested during Fitbit authorization. |
| app/pages/connect-fitbit.vue | Updates Fitbit connection page copy to mention sleep/HRV/heart-rate permissions and benefits. |
| tests/unit/server/utils/wellness.test.ts | Adds unit tests for Fitbit recovery alert evaluation behavior. |
| tests/unit/server/utils/fitbit.test.ts | Adds unit tests for Fitbit wellness normalization and intraday-derived sleeping HR. |
| .env.example | Adds an env flag controlling intraday heart-rate enrichment. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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) | ||
| }) |
There was a problem hiding this comment.
recentHrvValues includes today's HRV (range ends at todayEnd). Including the current day's value in the baseline average can mask an acute HRV drop and prevent the recovery alert from triggering. Filter out the todayDateOnly entry (or otherwise ensure only prior days are used) before computing the baseline inputs.
| const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({ | ||
| source: todayMetric?.source, | ||
| lastSource: todayMetric?.lastSource, | ||
| hrv: todayMetric?.hrv, | ||
| sleepHours: todayMetric?.sleepHours, | ||
| sleepQuality: todayMetric?.sleepQuality, | ||
| sleepScore: todayMetric?.sleepScore, |
There was a problem hiding this comment.
todayMetric?.source is used, but Wellness records do not have a source field (only lastSource). This will always be undefined and can undermine the Fitbit-source detection. Prefer passing just lastSource (or rename the evaluateFitbitRecoveryAlert input to match lastSource to avoid confusion).
| 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) | ||
|
|
There was a problem hiding this comment.
For older dates, nutrition + wellness fetches always start by calling the nutrition endpoints (fetchFitbitFoodLog / fetchFitbitWaterLog) even when nutrition already exists and only wellness is missing. This adds avoidable Fitbit API calls and increases rate-limit pressure. Consider conditionally skipping nutrition fetches when hasExistingNutrition is true (and skipping wellness fetches when hasExistingWellness is true), while still upserting whichever side is missing.
…itbit into readiness automation
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
11ebccd to
c11336d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (shouldFetchWellness) { | ||
| 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 }) | ||
| } | ||
|
|
||
| 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) | ||
| try { | ||
| heartRateSummary = await fetchFitbitHeartRateSummary(integration, date) | ||
| } catch (error) { | ||
| logger.warn(`[${date}] Failed to fetch Fitbit heart-rate summary`, { error }) | ||
| } | ||
|
|
There was a problem hiding this comment.
The wellness sync path makes multiple back-to-back Fitbit API calls per day (sleep, HRV, HR summary, optional intraday) without any spacing, while nutrition calls intentionally insert a delay to reduce rate-limit pressure. Consider adding a small delay between these wellness calls (or centralizing throttling) to reduce the chance of 429s during multi-day backfills.
| select: { | ||
| date: true, | ||
| hrv: true | ||
| }, |
There was a problem hiding this comment.
recentWellness is fetched without filtering by lastSource, so the HRV baseline passed into evaluateFitbitRecoveryAlert can mix HRV values from other providers (Whoop/Oura/Intervals), which may be on different scales and can cause false positives/negatives for the Fitbit alert. Consider filtering the query to where: { lastSource: 'fitbit' } (and/or selecting lastSource and filtering in code) so the baseline is computed from Fitbit-only values.
| }, | |
| }, | |
| where: { lastSource: 'fitbit' }, |
| wellnessRepository.getForUser(userId, { | ||
| startDate: getStartOfDaysAgoUTC(timezone, 14), | ||
| endDate: todayEnd, | ||
| select: { | ||
| date: true, | ||
| hrv: true | ||
| }, | ||
| orderBy: { date: 'desc' } | ||
| }), |
There was a problem hiding this comment.
The HRV history used for evaluateFitbitRecoveryAlert (recentWellness) is not scoped to Fitbit (lastSource), so the computed baseline can include HRV from other integrations and skew the alert thresholds. Fetch Fitbit-only HRV values (e.g., where: { lastSource: 'fitbit' }) or include lastSource in the select and filter before building priorHrvValues.
| wellnessRepository.getForUser(userId, { | |
| startDate: getStartOfDaysAgoUTC(timezone, 14), | |
| endDate: todayEnd, | |
| select: { | |
| date: true, | |
| hrv: true | |
| }, | |
| orderBy: { date: 'desc' } | |
| }), | |
| wellnessRepository | |
| .getForUser(userId, { | |
| startDate: getStartOfDaysAgoUTC(timezone, 14), | |
| endDate: todayEnd, | |
| select: { | |
| date: true, | |
| hrv: true, | |
| lastSource: true | |
| }, | |
| orderBy: { date: 'desc' } | |
| }) | |
| .then((wellness) => wellness.filter((w) => w.lastSource === 'fitbit')), |
…cs and validations
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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 |
There was a problem hiding this comment.
avgSleepingHr sleep-window matching uses only minutes-of-day and treats a window that crosses midnight as (minute >= windowStart || minute <= windowEnd). Because the intraday dataset is for a single calendar date, this can incorrectly include late-night points from the same day that are not part of the sleep session when startTime/endTime span different dates (or exclude the correct half of the window). Consider parsing the dates in mainSleep.startTime / endTime and clamping the window to the current date being normalized (or fetching the adjacent day’s intraday dataset when the sleep spans midnight) so you only average points that actually occurred during that sleep.
| 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 } | ||
| ] |
There was a problem hiding this comment.
The sleep log here spans 2026-02-28 → 2026-03-01, but the intraday HR dataset times (e.g. 23:30:00) implicitly belong to the requested intraday date (2026-03-01). Because intraday points don’t carry a date, combining a cross-date sleep window with same-day intraday times can make the midnight-wrap averaging logic look correct even if it would misbehave with real payload alignment. Consider adjusting the test fixtures so the sleep window and intraday dataset clearly refer to the same calendar day(s) (or add an explicit test case for cross-day behavior).
Summary
This PR expands Fitbit from a nutrition-only integration into a broader daily readiness signal that powers both data ingestion and coaching decisions.
What changed
avgSleepingHrfrom overnight pointsWhy this was changed
Benefits
Validation
avgSleepingHrwhen enabled.