From a3561b4600df059abedbf9071e2e5a1cf588f016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 09:46:24 +0200 Subject: [PATCH] Tell a first-run what Apple Health is actually doing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- e2e/settings-integrations-mobile.spec.ts | 3 + .../healthkit/__tests__/route.test.ts | 56 ++++++++- src/app/api/integrations/healthkit/route.ts | 12 ++ .../__tests__/source-card-grid.test.tsx | 20 ++++ .../onboarding/source-card-grid.tsx | 12 ++ .../__tests__/apple-health-card.test.tsx | 109 ++++++++++++++++++ .../integrations/apple-health-card.tsx | 92 ++++++++++++++- .../__tests__/apple-health-progress.test.ts | 93 +++++++++++++++ src/lib/integrations/apple-health-progress.ts | 63 ++++++++++ src/lib/openapi/routes/integrations.ts | 30 +++++ 10 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 src/lib/integrations/__tests__/apple-health-progress.test.ts create mode 100644 src/lib/integrations/apple-health-progress.ts diff --git a/e2e/settings-integrations-mobile.spec.ts b/e2e/settings-integrations-mobile.spec.ts index b06f1bb41..f827442c8 100644 --- a/e2e/settings-integrations-mobile.spec.ts +++ b/e2e/settings-integrations-mobile.spec.ts @@ -243,6 +243,9 @@ test.describe("Apple Health guidance", () => { lastSyncedAt: null, syncHealth: { verdict: "pending_first_sync", since: null }, metricFreshness: [], + // #778 — nothing accepted yet, so the card must show no + // progress section for this pending-first-sync account. + syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, }, error: null, }), diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts index 067113a7f..26ac0eae7 100644 --- a/src/app/api/integrations/healthkit/__tests__/route.test.ts +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -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: (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; @@ -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 { @@ -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, diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts index 8509b12ba..e9428e4c1 100644 --- a/src/app/api/integrations/healthkit/route.ts +++ b/src/app/api/integrations/healthkit/route.ts @@ -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, }); }); diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx index 381cbcce9..ca25ec0d6 100644 --- a/src/components/onboarding/__tests__/source-card-grid.test.tsx +++ b/src/components/onboarding/__tests__/source-card-grid.test.tsx @@ -112,3 +112,23 @@ describe(" Withings card", () => { expect(html).not.toContain("Connect Withings"); }); }); + +// #778 — the step listed Apple Health next to the web connectors with nothing +// saying its setup happens in the iOS app afterwards, so it read like one more +// OAuth flow to hunt for. +describe(" Apple Health note", () => { + it("says Apple Health setup completes in the iPhone app, not on the web", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('data-testid="source-more-apple-health-note"'); + expect(html).toContain("open the HealthLog app on your iPhone"); + expect(html).toContain("no web connection"); + }); + + it("keeps the Apple Health chip tappable so the note is not a dead end", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('data-testid="source-more-apple-health"'); + expect(html).toContain('href="/settings/integrations"'); + }); +}); diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index e4d0d369c..4e872c0a6 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -222,6 +222,18 @@ export function SourceCardGrid() { ))} + {/* #778 — Apple Health looked like one more web connector here, but + there is nothing to connect on the web: the sync is set up in the + iOS app after onboarding. Say so, so nobody hunts Settings for an + OAuth flow that does not exist. The chip stays tappable (it lands + on the Integrations page, whose Apple Health card walks through + the iPhone-app setup) and Next / Skip advance as always. */} +

+ {t("onboarding.source.more.appleHealthHint")} +

; + syncProgress?: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; }; let statusPayload: Payload | undefined; @@ -295,3 +299,108 @@ describe(" — delivery diagnostic (#586)", () => { expect(html).not.toContain("Last background sync"); }); }); + +// #778 — the first-run backfill used to be invisible: a user watching the web +// app had no way to tell "still flowing" from "looks dead but is working". +describe(" — sync progress (#778)", () => { + beforeEach(() => { + statusPayload = undefined; + statusLoading = false; + statusError = false; + }); + + it("shows the accepted-row count and the oldest reading reached", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { + recordsAccepted: 12345, + oldestMeasuredAt: "2019-05-04T06:00:00.000Z", + }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-progress"'); + expect(html).toContain('data-testid="apple-health-progress-received"'); + // Locale-grouped integer, so the count reads as a number, not a code. + expect(html).toContain("12,345"); + expect(html).toContain('data-testid="apple-health-progress-oldest"'); + // The 2019 date renders with its year — the whole point is showing how + // far back the backfill has reached. + expect(html).toContain("2019"); + }); + + it("reads as flowing while batches keep arriving", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, + }; + + const html = render(); + + expect(html).toContain('data-testid="apple-health-progress-state"'); + expect(html).toContain('data-state="flowing"'); + expect(html).toContain("still delivering"); + }); + + it("waits honestly instead of inventing throttle state when batches pause", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: { recordsAccepted: 100, oldestMeasuredAt: null }, + }; + + const html = render(); + + expect(html).toContain('data-state="waiting"'); + // The server does not track the phone's throttle/queue state, so the copy + // says "waiting for the iPhone app", never a fabricated percentage or ETA. + expect(html).toContain("Waiting for the iPhone app to send more data"); + expect(html).not.toContain("%"); + }); + + it("renders no progress section before anything has arrived", () => { + statusPayload = { + lastSyncedAt: null, + syncHealth: { verdict: "pending_first_sync", since: null }, + syncProgress: { recordsAccepted: 0, oldestMeasuredAt: null }, + }; + + const html = render(); + + // The delivery section's "no sync yet" line already owns the blank state. + expect(html).not.toContain('data-testid="apple-health-progress"'); + }); + + it("renders no progress section when the server read failed", () => { + statusPayload = { + lastSyncedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + syncHealth: { verdict: "fresh", since: null }, + syncProgress: null, + }; + + const html = render(); + + expect(html).not.toContain('data-testid="apple-health-progress"'); + }); + + it("defines every progress key in all six locale catalogs", () => { + const requiredKeys = [ + "title", + "receivedLabel", + "oldestLabel", + "flowing", + "waiting", + ] as const; + + for (const catalog of [en, de, es, fr, itMessages, pl]) { + const progress = catalog.settings.appleHealth.progress; + for (const key of requiredKeys) { + expect(progress[key]).toBeTypeOf("string"); + expect(progress[key].length).toBeGreaterThan(0); + } + } + }); +}); diff --git a/src/components/settings/integrations/apple-health-card.tsx b/src/components/settings/integrations/apple-health-card.tsx index d35563e9d..adb54f414 100644 --- a/src/components/settings/integrations/apple-health-card.tsx +++ b/src/components/settings/integrations/apple-health-card.tsx @@ -24,7 +24,7 @@ import { } from "@/components/settings/integration-status-pill"; import { Button } from "@/components/ui/button"; import { apiGet } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { MetricFreshnessEntry, SyncHealth, @@ -44,8 +44,23 @@ interface HealthKitStatus { lastBackgroundSyncAt?: string | null; syncHealth?: SyncHealth; metricFreshness?: MetricFreshnessEntry[]; + /** Server-side backfill progress (#778); null when the read failed. */ + syncProgress?: { + recordsAccepted: number; + oldestMeasuredAt: string | null; + } | null; } +/** + * How long after the last accepted batch the progress line stops claiming + * data is flowing. The server tracks no device-side throttle or queue state, + * so the only honest signal is the age of the newest batch: within the hour + * reads as "still arriving", beyond it as "waiting for the iPhone app" — a + * backfill that iOS has parked and a backfill that finished look identical + * from here, and the copy says neither is an error. + */ +const PROGRESS_WAITING_AFTER_MS = 60 * 60 * 1000; + const TRIGGER_KEYS: Record = { foreground: "settings.appleHealth.delivery.trigger.foreground", background: "settings.appleHealth.delivery.trigger.background", @@ -119,6 +134,79 @@ function DeliveryDiagnostic({ ); } +/** + * First-run backfill progress (#778). During the initial HealthKit backfill + * the card said nothing about how much had arrived or how far back it + * reached, so a working first run looked broken. This renders the figures + * the server actually holds — accepted-row count and the oldest instant + * reached — plus one honest state line derived from the age of the newest + * batch. No ETA and no percentage: the remaining queue lives on the phone + * and the server cannot see it. + */ +function SyncProgress({ + status, + t, + now, +}: { + status: HealthKitStatus; + t: (key: string, params?: Record) => string; + /** Injectable reference instant, mirroring the pill's own prop. */ + now?: Date; +}) { + const formatters = useFormatters(); + const progress = status.syncProgress; + // Nothing accepted and nothing synced: the delivery section's "no sync yet" + // line already covers the blank state — a zero-row table would only repeat it. + if (!progress || (progress.recordsAccepted <= 0 && !status.lastSyncedAt)) { + return null; + } + const reference = now ?? new Date(); + const lastBatchAge = status.lastSyncedAt + ? Math.max(0, reference.getTime() - new Date(status.lastSyncedAt).getTime()) + : null; + const flowing = + lastBatchAge !== null && lastBatchAge < PROGRESS_WAITING_AFTER_MS; + + return ( +
+

+ {t("settings.appleHealth.progress.title")} +

+
+
+
+ {t("settings.appleHealth.progress.receivedLabel")} +
+
+ {formatters.integer(progress.recordsAccepted)} +
+
+ {progress.oldestMeasuredAt ? ( +
+
+ {t("settings.appleHealth.progress.oldestLabel")} +
+
+ {formatters.date(new Date(progress.oldestMeasuredAt))} +
+
+ ) : null} +
+ {lastBatchAge !== null ? ( +

+ {flowing + ? t("settings.appleHealth.progress.flowing") + : t("settings.appleHealth.progress.waiting")} +

+ ) : null} +
+ ); +} + export function AppleHealthCard({ enabled }: { enabled: boolean }) { const { t } = useTranslations(); const { @@ -175,6 +263,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) { {showPill && } + {showPill && } + ({ + prisma: { + measurement: { aggregate: vi.fn() }, + workout: { aggregate: vi.fn() }, + }, +})); + +import { getAppleHealthSyncProgress } from "../apple-health-progress"; +import { prisma } from "@/lib/db"; + +function mockAggregates( + measurement: { count: number; min: Date | null }, + workout: { count: number; min: Date | null }, +) { + vi.mocked(prisma.measurement.aggregate).mockResolvedValue({ + _count: { _all: measurement.count }, + _min: { measuredAt: measurement.min }, + } as never); + vi.mocked(prisma.workout.aggregate).mockResolvedValue({ + _count: { _all: workout.count }, + _min: { startedAt: workout.min }, + } as never); +} + +describe("getAppleHealthSyncProgress", () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it("sums measurement and workout rows into one accepted count", async () => { + mockAggregates( + { count: 1200, min: new Date("2019-03-01T08:00:00.000Z") }, + { count: 45, min: new Date("2021-06-01T06:30:00.000Z") }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.recordsAccepted).toBe(1245); + }); + + it("reports the oldest instant across both tables", async () => { + mockAggregates( + { count: 10, min: new Date("2020-01-05T00:00:00.000Z") }, + { count: 3, min: new Date("2018-11-20T07:15:00.000Z") }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.oldestMeasuredAt).toBe("2018-11-20T07:15:00.000Z"); + }); + + it("uses the measurement minimum when workouts have no rows", async () => { + mockAggregates( + { count: 8, min: new Date("2022-02-02T12:00:00.000Z") }, + { + count: 0, + min: null, + }, + ); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress.recordsAccepted).toBe(8); + expect(progress.oldestMeasuredAt).toBe("2022-02-02T12:00:00.000Z"); + }); + + it("is honest about a source that has delivered nothing", async () => { + mockAggregates({ count: 0, min: null }, { count: 0, min: null }); + + const progress = await getAppleHealthSyncProgress("user-1"); + + expect(progress).toEqual({ recordsAccepted: 0, oldestMeasuredAt: null }); + }); + + it("scopes both reads to the user and the APPLE_HEALTH source", async () => { + mockAggregates({ count: 0, min: null }, { count: 0, min: null }); + + await getAppleHealthSyncProgress("user-9"); + + expect(prisma.measurement.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-9", deletedAt: null, source: "APPLE_HEALTH" }, + }), + ); + expect(prisma.workout.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: "user-9", source: "APPLE_HEALTH" }, + }), + ); + }); +}); diff --git a/src/lib/integrations/apple-health-progress.ts b/src/lib/integrations/apple-health-progress.ts new file mode 100644 index 000000000..386f5be76 --- /dev/null +++ b/src/lib/integrations/apple-health-progress.ts @@ -0,0 +1,63 @@ +/** + * First-run Apple Health sync progress (issue #778). + * + * The iOS app drives the HealthKit backfill; the server only ever sees the + * batches that actually arrive. During a first run nothing on the web said + * how far that had come, so a backfill that was quietly working looked + * broken. This module computes the figures the server genuinely holds — how + * many rows carry the `APPLE_HEALTH` source and how far back in time they + * reach — and nothing more: the phone's throttle state, its remaining queue, + * and any ETA live on the device and are deliberately not invented here. + * + * Both the live batch ingest and the one-shot export.zip import write + * `source = APPLE_HEALTH`, so the count is "Apple Health records in + * HealthLog", not "records from one specific pipe" — which is the number a + * user watching a first run actually wants to see grow. + */ +import { prisma } from "@/lib/db"; + +export interface AppleHealthSyncProgress { + /** Measurement + workout rows carrying the `APPLE_HEALTH` source. */ + recordsAccepted: number; + /** + * Earliest instant reached (`measuredAt` for measurements, `startedAt` for + * workouts), as an ISO string; null when nothing has arrived. + */ + oldestMeasuredAt: string | null; +} + +/** + * Two grouped aggregates (count + MIN), mirroring the freshness reads in + * `./metric-freshness.ts` which cover the same two tables for the same + * reason: workouts are Apple Health data too, but never `Measurement` rows. + */ +export async function getAppleHealthSyncProgress( + userId: string, +): Promise { + const [measurements, workouts] = await Promise.all([ + prisma.measurement.aggregate({ + where: { userId, deletedAt: null, source: "APPLE_HEALTH" }, + _count: { _all: true }, + _min: { measuredAt: true }, + }), + // `Workout` rows are hard-deleted, so there is no tombstone predicate. + prisma.workout.aggregate({ + where: { userId, source: "APPLE_HEALTH" }, + _count: { _all: true }, + _min: { startedAt: true }, + }), + ]); + + const minima = [measurements._min.measuredAt, workouts._min.startedAt].filter( + (d): d is Date => d instanceof Date, + ); + const oldest = + minima.length > 0 + ? new Date(Math.min(...minima.map((d) => d.getTime()))) + : null; + + return { + recordsAccepted: measurements._count._all + workouts._count._all, + oldestMeasuredAt: oldest?.toISOString() ?? null, + }; +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 5318d9d57..f40e24eb2 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -85,6 +85,30 @@ const metricFreshnessEntry = z "Per-metric-type last-seen timestamp with the server-computed staleness flag. Only types that have actually delivered appear — absence is absence, never an invented row.", }); +// Issue #778 — the two backfill-progress figures the server genuinely holds. +// The iOS app drives the backfill; its queue, throttle state, and any ETA +// live on the device and are deliberately absent here. +const appleHealthSyncProgress = z + .object({ + recordsAccepted: z + .number() + .int() + .describe( + "Measurement + workout rows carrying the `APPLE_HEALTH` source (live batch ingest and the one-shot export import both write it). Soft-deleted measurement rows are excluded.", + ), + oldestMeasuredAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Earliest instant reached across those rows (`measuredAt` for measurements, `startedAt` for workouts); null when nothing has arrived. During a first-run backfill this walks backwards as history lands.", + ), + }) + .meta({ + id: "AppleHealthSyncProgress", + description: + "Server-side Apple Health backfill progress: how many rows have been accepted and how far back in time they reach. Only what the server actually knows — no ETA, no device-side queue state.", + }); + const healthKitConfigResponse = z .object({ entries: z.array(healthKitEntry), @@ -116,6 +140,12 @@ const healthKitConfigResponse = z .describe( "Per-metric-type freshness for the `APPLE_HEALTH` source. Present on the GET read; omitted from the PATCH echo.", ), + syncProgress: appleHealthSyncProgress + .nullable() + .optional() + .describe( + "Backfill/sync progress summary. Present on the GET read (null when the progress read failed); omitted from the PATCH echo.", + ), }) .meta({ id: "HealthKitConfigResponse",