Read Apple Health liveness off the data, not only the batch stamp

The status verdict looked only at the user's last-batch column, which
moves on a live batch alone. Rows that reached the account another way,
such as the export.zip import, left it null, and the card said 'waiting
for first data' beside a four-digit record count. The verdict now takes
the newest of that stamp, the per-type arrival ledger and the newest
stored sample.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:18:46 +02:00
1 parent ac482c508d
commit f7b6ea5dad
3 files changed
+78 -15

No files matched your search

@@ -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,
+18 -15
View File
@@ -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,
@@ -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<string, HealthKitTypeArrivalRecord> | 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. */