Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/production-incident-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -8827,6 +8827,43 @@ new incremental tables are populated.
update; add freshness monitoring comparing latest sleep/heart-rate inputs to
latest active RHR output date.

### Healthspan activity and steps undercount

- Date: 2026-05-26.
- Symptoms: The Healthspan Score card showed `Aerobic Activity` as
`0 min/week` and `Daily Steps` around `1117 steps/day` despite the user
reporting regular activity and more walking than that.
- User Impact: The Healthspan score penalized activity and steps using inputs
that did not match the user's actual recent behavior.
- Evidence: Production `fitness.v_daily_metrics` averaged `1115` steps and
`16` exercise minutes over the 35-day Healthspan window. Recent
`fitness.daily_metrics` rows from Apple Health `HealthKit` showed many
overwritten low partial-day step totals, while the Healthspan aerobic query
used only HR/power-linked `analytics.activity_summary` activity data and
ignored device-reported `exercise_minutes`.
- Root Cause: Incremental mobile HealthKit sync used `now - 24h` as the start
time for daily cumulative statistics, then upserted those partial-day
statistics over whole-day `fitness.daily_metrics` rows. Separately,
Healthspan treated missing HR/power-linked aerobic activity as zero instead
of using the device-reported full-day exercise minutes already stored in
`fitness.v_daily_metrics`.
- Fix/Mitigation: Updated mobile HealthKit sync to start incremental sync
windows at the local calendar-day boundary, preventing future partial-day
overwrites. Updated Healthspan to use weekly device-reported exercise minutes
as the aerobic activity floor when HR-zone activity minutes are lower or
missing.
- Validation: Added regression coverage for day-boundary HealthKit sync and
Healthspan exercise-minute fallback. Focused Healthspan unit tests, mobile
Vitest project, Biome checks, and TypeScript checks passed locally.
- Remaining Risk: Existing corrupted historical Apple Health daily metric rows
remain in production until the iOS app runs a corrected manual/full HealthKit
sync from the user's device; the server cannot reconstruct those all-day
HealthKit totals without the device.
- Follow-Up Work: After deploying the fix, run a full Apple Health sync from
the iOS app to repair historical daily step and exercise-minute rows. Consider
adding a server-side diagnostic for suspicious step drops after partial
HealthKit sync windows.

### Resting heart rate sleep-sample join null handling

- Date: 2026-05-26.
Expand Down
23 changes: 23 additions & 0 deletions packages/mobile/lib/health-kit-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,29 @@ describe("syncHealthKitToServer", () => {
expect(startDate.getFullYear()).toBeLessThanOrEqual(1970);
});

it("starts incremental daily statistics at the local day boundary", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-26T15:30:00-07:00"));
const client = createMockClient();
const healthKit = createMockHealthKit();
const expectedStartDate = new Date();
expectedStartDate.setDate(expectedStartDate.getDate() - 1);
expectedStartDate.setHours(0, 0, 0, 0);

try {
await syncHealthKitToServer({
trpcClient: client,
healthKit,
syncRangeDays: 1,
});
} finally {
vi.useRealTimers();
}

const firstCall = healthKit.queryDailyStatistics.mock.calls[0];
expect(firstCall[1]).toBe(expectedStartDate.toISOString());
});

it("batches large sample sets into groups of 500", async () => {
const client = createMockClient();
const healthKit = createMockHealthKit();
Expand Down
15 changes: 10 additions & 5 deletions packages/mobile/lib/health-kit-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,15 @@ const ALL_QUANTITY_TYPES = [...ADDITIVE_QUANTITY_TYPES, ...NON_ADDITIVE_QUANTITY

const BATCH_SIZE = 500;

function daysAgo(days: number): string {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString();
function syncWindowStart(syncRangeDays: number | null): string {
if (syncRangeDays === null) {
return new Date(0).toISOString();
}

const startDate = new Date();
startDate.setDate(startDate.getDate() - syncRangeDays);
startDate.setHours(0, 0, 0, 0);
return startDate.toISOString();
}

function normalizeWorkout(workout: WorkoutSample): WorkoutSample {
Expand Down Expand Up @@ -114,7 +119,7 @@ export interface SyncResult {
export async function syncHealthKitToServer(options: SyncOptions): Promise<SyncResult> {
const { trpcClient, healthKit, syncRangeDays, onProgress } = options;

const startDate = syncRangeDays === null ? new Date(0).toISOString() : daysAgo(syncRangeDays);
const startDate = syncWindowStart(syncRangeDays);
const endDate = new Date().toISOString();

const allSamples: HealthKitSample[] = [];
Expand Down
16 changes: 15 additions & 1 deletion packages/server/src/routers/healthspan-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,12 @@ function makeFetchContext(
overrides: Partial<Parameters<typeof fetchHealthspanRawData>[0]>,
): Parameters<typeof fetchHealthspanRawData>[0] {
return {
db: { execute: vi.fn().mockResolvedValue([makeRawHealthspanRow()]) },
db: {
execute: vi
.fn()
.mockResolvedValueOnce([{ weekly_exercise_min: null }])
.mockResolvedValueOnce([makeRawHealthspanRow()]),
},
userId: "user-1",
timezone: "UTC",
accessWindow: fullAccessWindow,
Expand All @@ -65,6 +70,15 @@ describe("fetchHealthspanRawData", () => {
([, queryText]) => typeof queryText === "string" && queryText.includes("activity_metadata"),
)?.[1];
expect(zoneQuery).toEqual(expect.any(String));
expect(query).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("activity_metadata"),
expect.objectContaining({
windowStart: "2026-03-01 00:00:00",
windowEndExclusive: "2026-03-16 00:00:00",
}),
);
expect(zoneQuery).toContain("AND asum.started_at < toDateTime({windowEndExclusive:String})");
expect(zoneQuery).toContain(`INNER JOIN analytics.deduped_sensor AS ds
ON ds.user_id = am.user_id
AND ds.recorded_at >= am.started_at
Expand Down
56 changes: 52 additions & 4 deletions packages/server/src/routers/healthspan-query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { averageVo2MaxEstimates } from "@dofek/training/derived-cardio";
import { ZONE_BOUNDARIES_FTP } from "@dofek/zones/zones";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { dateWindowStart, timestampWindowStart } from "../lib/date-window.ts";
import { dateAccessPredicate } from "../billing/entitlement.ts";
import { dateWindowEnd, dateWindowStart, timestampWindowStart } from "../lib/date-window.ts";
import { dateStringSchema, executeWithSchema } from "../lib/typed-sql.ts";
import { fetchBodyCompRows } from "../repositories/body-clickhouse.ts";
import { fetchSleepNights } from "../repositories/clickhouse-sleep-repository.ts";
Expand Down Expand Up @@ -36,6 +37,10 @@ const rawRowSchema = z.object({

export type HealthspanRawRow = z.infer<typeof rawRowSchema>;

const weeklyExerciseMinutesRowSchema = z.object({
weekly_exercise_min: z.coerce.number().nullable(),
});

type WeeklyHistoryRow = z.infer<typeof historyRowSchema>;
type HealthspanRawDataContext = Pick<
AuthenticatedContext,
Expand Down Expand Up @@ -72,6 +77,12 @@ function bedtimeMinutes(timestamp: string, timezone: string): number | null {
return minutes < 720 ? minutes + 1440 : minutes;
}

function nextDateString(dateString: string): string {
const date = new Date(`${dateString}T00:00:00Z`);
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString().slice(0, 10);
}

/**
* Compute total aerobic and high-intensity minutes from analytics.deduped_sensor.
* Uses user_profile plus bounded helper-provided resting heart-rate rows.
Expand All @@ -91,6 +102,7 @@ async function fetchHrZoneTime(
.toISOString()
.replace("T", " ")
.replace(/\.\d{3}Z$/, "");
const windowEndExclusive = `${nextDateString(endDate)} 00:00:00`;

const rows = await sensorStore.query(
z.object({
Expand Down Expand Up @@ -122,6 +134,7 @@ async function fetchHrZoneTime(
ON up.id = asum.user_id
WHERE asum.user_id = {userId:UUID}
AND asum.started_at > toDateTime({windowStart:String})
AND asum.started_at < toDateTime({windowEndExclusive:String})
AND asum.ended_at IS NOT NULL
AND (up.max_hr IS NOT NULL OR up.ftp IS NOT NULL)
),
Expand Down Expand Up @@ -169,6 +182,7 @@ async function fetchHrZoneTime(
userId: ctx.userId,
timezone: ctx.timezone,
windowStart: windowStartTimestamp,
windowEndExclusive,
powerThreshold: ZONE_BOUNDARIES_FTP[2],
restingHeartRateDates: restingHeartRateRows.map((row) => row.date),
restingHeartRates: restingHeartRateRows.map((row) => row.resting_hr),
Expand All @@ -178,6 +192,26 @@ async function fetchHrZoneTime(
return rows[0] ?? { aerobic_minutes: 0, high_intensity_minutes: 0 };
}

async function fetchWeeklyExerciseMinutes(
ctx: HealthspanRawDataContext,
endDate: string,
totalDays: number,
): Promise<number | null> {
const rows = await executeWithSchema(
ctx.db,
weeklyExerciseMinutesRowSchema,
sql`SELECT
(SUM(exercise_minutes)::real / GREATEST(${totalDays}::real / 7, 1)) AS weekly_exercise_min
FROM fitness.v_daily_metrics
WHERE user_id = ${ctx.userId}
AND date > ${dateWindowStart(endDate, totalDays)}
AND date <= ${dateWindowEnd(endDate)}
${dateAccessPredicate(ctx.accessWindow, sql`date`)}`,
);

return rows[0]?.weekly_exercise_min ?? null;
}
Comment thread
Asherlc marked this conversation as resolved.

/**
* Fetch the raw aggregates and weekly history needed to compute a Healthspan score.
*
Expand All @@ -202,8 +236,12 @@ export async function fetchHealthspanRawData(
: [];
const restingHeartRateCte = restingHeartRateValuesCte(restingHeartRateRows);
const hrZoneTime = await fetchHrZoneTime(ctx, endDate, totalDays, restingHeartRateRows);
const weeklyExerciseMin = await fetchWeeklyExerciseMinutes(ctx, endDate, totalDays);
const weeklyDivisor = Math.max(totalDays / 7, 1);
const weeklyAerobicMin = hrZoneTime.aerobic_minutes / weeklyDivisor;
const weeklyAerobicMin = Math.max(
hrZoneTime.aerobic_minutes / weeklyDivisor,
weeklyExerciseMin ?? 0,
);
const weeklyHighIntensityMin = hrZoneTime.high_intensity_minutes / weeklyDivisor;
const bodyMeasurements = ctx.sensorStore
? await fetchBodyCompRows(ctx.sensorStore, ctx.userId, endDate, totalDays)
Expand Down Expand Up @@ -238,11 +276,15 @@ export async function fetchHealthspanRawData(
SELECT
(SELECT AVG(resting_hr)
FROM resting_heart_rate
WHERE date > ${dateWindowStart(endDate, totalDays)}) AS avg_resting_hr,
WHERE date > ${dateWindowStart(endDate, totalDays)}
AND date <= ${dateWindowEnd(endDate)}
${dateAccessPredicate(ctx.accessWindow, sql`date`)}) AS avg_resting_hr,
(SELECT AVG(steps)
FROM fitness.v_daily_metrics
WHERE user_id = ${ctx.userId}
AND date > ${dateWindowStart(endDate, totalDays)}) AS avg_steps,
AND date > ${dateWindowStart(endDate, totalDays)}
AND date <= ${dateWindowEnd(endDate)}
${dateAccessPredicate(ctx.accessWindow, sql`date`)}) AS avg_steps,
NULL::real AS latest_vo2max
),
strength_freq AS (
Expand All @@ -251,13 +293,17 @@ export async function fetchHealthspanRawData(
WHERE user_id = ${ctx.userId}
AND activity_type = 'strength'
AND started_at > ${timestampWindowStart(endDate, totalDays)}
AND started_at < (${dateWindowEnd(endDate)} + INTERVAL '1 day')::timestamp
${dateAccessPredicate(ctx.accessWindow, sql`started_at::date`)}
),
weekly_rhr AS (
SELECT
date_trunc('week', date)::date AS week_start,
AVG(resting_hr) AS avg_rhr
FROM resting_heart_rate
WHERE date > ${dateWindowStart(endDate, totalDays)}
AND date <= ${dateWindowEnd(endDate)}
${dateAccessPredicate(ctx.accessWindow, sql`date`)}
GROUP BY date_trunc('week', date)
),
weekly_steps AS (
Expand All @@ -267,6 +313,8 @@ export async function fetchHealthspanRawData(
FROM fitness.v_daily_metrics
WHERE user_id = ${ctx.userId}
AND date > ${dateWindowStart(endDate, totalDays)}
AND date <= ${dateWindowEnd(endDate)}
${dateAccessPredicate(ctx.accessWindow, sql`date`)}
GROUP BY date_trunc('week', date)
),
weekly_dates AS (
Expand Down
Loading
Loading