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

@@ -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"