Tell a first-run what Apple Health is actually doing

Refs #778. Two honesty gaps from the first-run report: the web onboarding
presented Apple Health like a web connector although the connection only
completes in the iPhone app, and the initial backfill gave no sign of
progress, so a working first sync looked broken.

The onboarding source step now says plainly that Apple Health is set up in
the iPhone app after onboarding, without dead-ending the flow. The
integration card gains a server-authoritative progress summary — rows
accepted, oldest reading reached, and an honest flowing/waiting line
derived from the last accepted batch. Nothing the server does not know is
shown: no percentage, no ETA, no invented throttle state; a quiet stretch
reads as waiting for the phone, not as an error.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 09:46:24 +02:00
1 parent b1be67bfa7
commit a3561b4600
10 files changed
+486 -4

No files matched your search

@@ -6,9 +6,10 @@ vi.mock("@/lib/db", () => ({
user: { findUnique: vi.fn(), update: vi.fn() },
// v1.4.43 W6 — audit-ledger breadcrumb for validation-failed paths.
auditLog: { create: vi.fn() },
// Per-metric freshness for the APPLE_HEALTH source.
measurement: { groupBy: vi.fn() },
workout: { groupBy: vi.fn() },
// Per-metric freshness + first-run sync progress for the APPLE_HEALTH
// source (#778).
measurement: { groupBy: vi.fn(), aggregate: vi.fn() },
workout: { groupBy: vi.fn(), aggregate: vi.fn() },
},
toJson: <T>(v: T) => v,
}));
@@ -54,6 +55,14 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.measurement.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.workout.groupBy).mockResolvedValue([] as never);
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
_count: { _all: 0 },
_min: { measuredAt: null },
} as never);
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
_count: { _all: 0 },
_min: { startedAt: null },
} as never);
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
@@ -227,6 +236,10 @@ describe("GET /api/integrations/healthkit — sync health", () => {
lastSeenAt: string;
stale: boolean;
}>;
syncProgress: {
recordsAccepted: number;
oldestMeasuredAt: string | null;
} | null;
};
async function read(): Promise<Body> {
@@ -327,6 +340,43 @@ describe("GET /api/integrations/healthkit — sync health", () => {
expect(body.lastBackgroundSyncAt).toBeNull();
});
// #778 — a first-run backfill was invisible from the web: nothing said how
// many records had arrived or how far back they reached. The GET now carries
// the two figures the server actually holds.
it("publishes the accepted-row count and the oldest instant reached", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(60 * 60 * 1000),
} as never);
vi.mocked(prisma.measurement.aggregate).mockResolvedValue({
_count: { _all: 4200 },
_min: { measuredAt: new Date("2019-05-04T06:00:00.000Z") },
} as never);
vi.mocked(prisma.workout.aggregate).mockResolvedValue({
_count: { _all: 12 },
_min: { startedAt: new Date("2021-01-01T09:00:00.000Z") },
} as never);
const body = await read();
expect(body.syncProgress).toEqual({
recordsAccepted: 4212,
oldestMeasuredAt: "2019-05-04T06:00:00.000Z",
});
});
it("degrades to a null progress summary rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
healthKitLastSyncedAt: ago(DAY),
} as never);
vi.mocked(prisma.measurement.aggregate).mockRejectedValue(
new Error("aggregate hiccup"),
);
const body = await read();
expect(body.syncProgress).toBeNull();
expect(body.syncHealth.verdict).toBe("fresh");
});
it("degrades to no per-metric data rather than failing the config read", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValue({
healthKitConfigJson: null,
@@ -20,6 +20,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma, toJson } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getAppleHealthSyncProgress } from "@/lib/integrations/apple-health-progress";
import { getSourceFreshness } from "@/lib/integrations/metric-freshness";
import {
classifyMetricFreshness,
@@ -168,6 +169,16 @@ export const GET = apiHandler(async () => {
const samples = await getSourceFreshness(user.id, "APPLE_HEALTH").catch(
() => [],
);
// 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
// instant) are everything the server actually knows about backfill
// 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,
);
return apiSuccess({
entries,
@@ -176,6 +187,7 @@ export const GET = apiHandler(async () => {
lastBackgroundSyncAt,
syncHealth,
metricFreshness: classifyMetricFreshness(samples, syncHealth.verdict),
syncProgress,
});
});