Skip to content

Fitbit data expansion#207

Open
red-one1 wants to merge 4 commits intohdkiller:masterfrom
red-one1:feat/fitbit-phase1-wellness
Open

Fitbit data expansion#207
red-one1 wants to merge 4 commits intohdkiller:masterfrom
red-one1:feat/fitbit-phase1-wellness

Conversation

@red-one1
Copy link
Contributor

@red-one1 red-one1 commented Mar 2, 2026

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

  • Expanded Fitbit OAuth access to include sleep and heart-rate data (in addition to nutrition).
  • Added Fitbit wellness ingestion for:
    • sleep duration/quality and stage-derived sleep metrics
    • HRV daily summary
    • resting heart rate
  • Added optional intraday heart-rate enrichment:
    • reads minute-level heart-rate data when enabled
    • derives avgSleepingHr from overnight points
    • gracefully falls back to daily summaries when intraday data is unavailable
  • Added deterministic Fitbit recovery risk evaluation in coaching flows:
    • combines low HRV, poor sleep, and high ATL load
    • injects a clear caution signal into recommendation prompts
  • Updated Fitbit connection UI copy so users understand expanded permissions and value.
  • Added test coverage for:
    • Fitbit wellness normalization
    • intraday-derived sleeping HR behavior
    • Fitbit recovery alert evaluation

Why this was changed

  • Fitbit data already contained meaningful recovery inputs (sleep/HRV/HR), but only nutrition was being used.
  • Coaching recommendations benefit from a stable, deterministic safety signal in addition to LLM reasoning.
  • Intraday HR is useful but not always available; feature-flagged enrichment avoids breaking ingestion in constrained environments.

Benefits

  • Better recovery-aware coaching decisions using real Fitbit wellness signals.
  • More complete daily wellness records for Fitbit users.
  • Safer recommendation bias on high-risk recovery days.
  • Backward-compatible rollout through optional intraday enrichment.
  • Improved confidence from focused unit test coverage and runtime validation.

Validation

  • Targeted unit tests pass for Fitbit normalization and recovery alert logic.
  • TypeScript compile check passes.
  • Containerized worker run succeeds end-to-end:
    • Fitbit sync completes successfully
    • nutrition and wellness records are ingested
    • intraday enrichment populates avgSleepingHr when enabled.

Copilot AI review requested due to automatic review settings March 2, 2026 11:41
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +145 to +154
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)
})
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +145 to +151
const fitbitRecoveryAlert = evaluateFitbitRecoveryAlert({
source: todayMetric?.source,
lastSource: todayMetric?.lastSource,
hrv: todayMetric?.hrv,
sleepHours: todayMetric?.sleepHours,
sleepQuality: todayMetric?.sleepQuality,
sleepScore: todayMetric?.sleepScore,
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 122 to 161
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)

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
red-one1 and others added 2 commits March 3, 2026 10:23
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@red-one1 red-one1 force-pushed the feat/fitbit-phase1-wellness branch from 11ebccd to c11336d Compare March 2, 2026 23:25
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +177 to +195
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 })
}

Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
select: {
date: true,
hrv: true
},
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
},
},
where: { lastSource: 'fitbit' },

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +94
wellnessRepository.getForUser(userId, {
startDate: getStartOfDaysAgoUTC(timezone, 14),
endDate: todayEnd,
select: {
date: true,
hrv: true
},
orderBy: { date: 'desc' }
}),
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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')),

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +638 to +666
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
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +268 to +314
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 }
]
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sleep log here spans 2026-02-282026-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).

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants