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

+3
View File
@@ -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,
}),
@@ -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,
});
});
@@ -112,3 +112,23 @@ describe("<SourceCardGrid> 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("<SourceCardGrid> 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"');
});
});
@@ -222,6 +222,18 @@ export function SourceCardGrid() {
</li>
))}
</ul>
{/* #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. */}
<p
className="text-muted-foreground text-xs leading-relaxed"
data-testid="source-more-apple-health-note"
>
{t("onboarding.source.more.appleHealthHint")}
</p>
</div>
<Link
@@ -20,6 +20,10 @@ type Payload = {
lastSeenAt: string;
stale: boolean;
}>;
syncProgress?: {
recordsAccepted: number;
oldestMeasuredAt: string | null;
} | null;
};
let statusPayload: Payload | undefined;
@@ -295,3 +299,108 @@ describe("<AppleHealthCard> — 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("<AppleHealthCard> — 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);
}
}
});
});
@@ -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<SyncTrigger, string> = {
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, string | number>) => 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 (
<div className="space-y-2" data-testid="apple-health-progress">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
{t("settings.appleHealth.progress.title")}
</h3>
<dl className="space-y-1 text-sm">
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.progress.receivedLabel")}
</dt>
<dd data-testid="apple-health-progress-received">
{formatters.integer(progress.recordsAccepted)}
</dd>
</div>
{progress.oldestMeasuredAt ? (
<div className="flex flex-wrap gap-x-2">
<dt className="text-muted-foreground">
{t("settings.appleHealth.progress.oldestLabel")}
</dt>
<dd data-testid="apple-health-progress-oldest">
{formatters.date(new Date(progress.oldestMeasuredAt))}
</dd>
</div>
) : null}
</dl>
{lastBatchAge !== null ? (
<p
className="text-muted-foreground text-xs"
data-testid="apple-health-progress-state"
data-state={flowing ? "flowing" : "waiting"}
>
{flowing
? t("settings.appleHealth.progress.flowing")
: t("settings.appleHealth.progress.waiting")}
</p>
) : null}
</div>
);
}
export function AppleHealthCard({ enabled }: { enabled: boolean }) {
const { t } = useTranslations();
const {
@@ -175,6 +263,8 @@ export function AppleHealthCard({ enabled }: { enabled: boolean }) {
{showPill && <DeliveryDiagnostic status={status} t={t} />}
{showPill && <SyncProgress status={status} t={t} />}
<MetricFreshnessDisclosure
entries={status?.metricFreshness}
idPrefix="apple-health"
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
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" },
}),
);
});
});
@@ -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<AppleHealthSyncProgress> {
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,
};
}
+30
View File
@@ -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",