mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
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:
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"
|
||||
|
||||
Reference in new issue
Block a user