diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index fd6b34138..f029d6a7f 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -287,6 +287,40 @@ describe("GET /api/integrations/healthkit — sync health", () => { expect((await read()).syncHealth.verdict).toBe("pending_first_sync"); }); + // The column only moves on a live batch. Rows that reached the account any + // other way (the export.zip import, a batch before the column existed) left + // it null, and the card said "waiting for first data" beside a four-digit + // record count. Arrival is a data question: any Apple Health row settles it. + it("is not pending when the per-type ledger records an arrival", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(prisma.healthKitTypeSync.findMany).mockResolvedValue([ + { + type: "PULSE", + lastReceivedAt: ago(2 * DAY), + lastTrigger: null, + lastNewSampleAt: null, + }, + ] as never); + expect((await read()).syncHealth.verdict).toBe("fresh"); + }); + + it("is not pending when Apple Health rows exist without any sync stamp", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(listLiveMeasurementTypes).mockResolvedValue(["PULSE"]); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { type: "PULSE", last_seen: ago(DAY) }, + ] as never); + const body = await read(); + expect(body.syncHealth.verdict).toBe("fresh"); + expect(body.metricFreshness.map((e) => e.stale)).toEqual([false]); + }); + it("flags one quiet metric inside an otherwise healthy connection", async () => { vi.mocked(prisma.user.findUnique).mockResolvedValue({ healthKitConfigJson: null, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index e104168ca..886e489ea 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit"; import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress"; import { getHealthKitTypeArrivals, + newestAppleHealthDataAt, withHealthKitArrivals, } from "@/lib/integrations/healthkit-type-sync"; import { getSourceFreshness } from "@/lib/integrations/metric-freshness"; @@ -162,21 +163,9 @@ export const GET = apiHandler(async () => { // three-week-dead pipe rendered the same green as one that delivered an hour // ago. Fail-soft on the per-metric read — an honesty signal is never worth // 500-ing the config the iOS client needs. - // No `connected` hint: Apple Health has nothing to connect or disconnect, so - // "never delivered" resolves to `pending_first_sync`, not `disconnected`. - const syncHealth = resolveSyncVerdict({ - configured: true, - lastDataAt: lastSyncedAt, - legacyLastSyncedAt: lastSyncedAt, - cadence: PUSH_CADENCE, - }); // #1173 — when each type last ARRIVED and under which trigger, beside when // its newest sample was taken. Fail-soft to null, which leaves the entries // without arrival fields rather than reporting every type as never received. - const [samples, arrivals] = await Promise.all([ - getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), - getHealthKitTypeArrivals(user.id).catch(() => null), - ]); // Issue #778 — a first-run backfill used to be invisible from the web: the // card said "last sync 2 minutes ago" while giving no sense of how much had // arrived or how far back it reached. These two figures (row count + oldest @@ -184,9 +173,23 @@ export const GET = apiHandler(async () => { // progress; the phone's queue and throttle state stay on the phone and are // not guessed at. Fail-soft for the same reason the freshness read is — // a progress line is never worth 500-ing the config the iOS client needs. - const syncProgress = await getAppleHealthSyncProgress(user.id).catch( - () => null, - ); + const [samples, arrivals, syncProgress] = await Promise.all([ + getSourceFreshness(user.id, "APPLE_HEALTH").catch(() => []), + getHealthKitTypeArrivals(user.id).catch(() => null), + getAppleHealthSyncProgress(user.id).catch(() => null), + ]); + // No `connected` hint: Apple Health has nothing to connect or disconnect, so + // "never delivered" resolves to `pending_first_sync`, not `disconnected`. + // "Delivered" is read off the data, not only off the user column: that + // column moves on a live batch alone, so rows from the export.zip import + // left it null and the card said "waiting for first data" beside a + // four-digit record count. + const syncHealth = resolveSyncVerdict({ + configured: true, + lastDataAt: newestAppleHealthDataAt(lastSyncedAt, arrivals, samples), + legacyLastSyncedAt: lastSyncedAt, + cadence: PUSH_CADENCE, + }); return apiSuccess({ entries, diff --git a/src/lib/integrations/healthkit-type-sync.ts b/src/lib/integrations/healthkit-type-sync.ts index 1945b8131..20570d0b0 100644 --- a/src/lib/integrations/healthkit-type-sync.ts +++ b/src/lib/integrations/healthkit-type-sync.ts @@ -131,6 +131,32 @@ export async function getHealthKitTypeArrivals( return out; } +/** + * The newest instant Apple Health data is known to have reached the account: + * the live-batch stamp, the per-type arrival ledger, or — for rows that came + * any other way, such as the export.zip import — the newest stored sample. + * Null only when none of the three holds anything, which is the one case the + * status may honestly call "waiting for first data". + */ +export function newestAppleHealthDataAt( + lastSyncedAt: string | null, + arrivals: Map | null, + samples: readonly { lastSeenAt: string }[], +): string | null { + let newest: number | null = null; + const consider = (value: string | null | undefined) => { + if (!value) return; + const ms = Date.parse(value); + if (Number.isFinite(ms) && (newest === null || ms > newest)) newest = ms; + }; + consider(lastSyncedAt); + for (const arrival of arrivals?.values() ?? []) { + consider(arrival.lastReceivedAt); + } + for (const sample of samples) consider(sample.lastSeenAt); + return newest === null ? null : new Date(newest).toISOString(); +} + /** A freshness entry with the arrival facts beside the newest sample. */ export interface HealthKitFreshnessEntry extends MetricFreshnessEntry { /** When a live sync last carried this type; null when none has. */