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

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