feat(integrations): say where an imported Google Health history ends

The two status routes publish backfillEnded: each data type whose
backfill stopped short of the start of its history, with the instant it
reaches back to and whether the horizon or a walk that stopped moving
ended it. The integration card lists them in place of an import that
would otherwise read as still loading, in all seven locales.

Refs #1220
This commit is contained in:
Marc-André Bombeck committed 2026-10-19 17:25:57 +02:00
1 parent 72c259d85c
commit f6b09f07cd
15 files changed
+213

No files matched your search

@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { loadOneConnectionHealth } from "@/lib/integrations/connection-health-load";
import { googleHealthBackfillEndedList } from "@/lib/google-health/sync-core";
/**
* Get Google Health connection status for the current user (v1.26.0).
@@ -37,6 +38,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
tokenExpiresAt: true,
backfillCompletedAt: true,
backfillProgress: true,
needsReauth: true,
createdAt: true,
scope: true,
@@ -57,6 +59,7 @@ export const GET = apiHandler(async () => {
tokenExpired,
tokenExpiresAt: connection.tokenExpiresAt,
backfillCompleted: !!connection.backfillCompletedAt,
backfillEnded: googleHealthBackfillEndedList(connection.backfillProgress),
needsReauth: connection.needsReauth,
scope: connection.scope,
// The one server-resolved state (ok / limited / degraded /
+9
View File
@@ -52,6 +52,10 @@ import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import {
googleHealthBackfillEndedList,
type GoogleHealthBackfillEndedEntry,
} from "@/lib/google-health/sync-core";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
export const dynamic = "force-dynamic";
@@ -156,6 +160,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
createdAt: true,
backfillCompletedAt: true,
backfillProgress: true,
needsReauth: true,
},
}),
@@ -384,6 +389,9 @@ export const GET = apiHandler(async () => {
backfillCompleted: googleHealthConn
? !!googleHealthConn.backfillCompletedAt
: null,
backfillEnded: googleHealthConn
? googleHealthBackfillEndedList(googleHealthConn.backfillProgress)
: [],
// v1.27.0 — `needsReauth` is the 7-day Testing-mode refresh expiry (or a
// user-revoked grant) surfaced from the connection row; the card reads it
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
@@ -555,6 +563,7 @@ interface GoogleHealthExtras {
tokenExpiresAt: string | null;
tokenExpired: boolean | null;
backfillCompleted: boolean | null;
backfillEnded: GoogleHealthBackfillEndedEntry[];
needsReauth: boolean;
}
@@ -137,6 +137,35 @@ describe("<IntegrationCard> per state", () => {
);
});
it("history ended short: no longer loading, and says where it ends and why", () => {
const html = render(
"google-health",
connected(healthFixture(), {
backfillCompleted: true,
backfillEnded: [
{
type: "PULSE",
endsAt: "2024-05-11T08:00:00.000Z",
reason: "no_progress",
},
{
type: "WEIGHT",
endsAt: "2006-10-17T12:00:00.000Z",
reason: "horizon",
},
],
}),
);
expect(html).not.toContain("Older history is loading");
expect(html).toContain('data-testid="google-health-history-ends"');
expect(html).toMatch(
/data-reason="no_progress"[^>]*>Pulse: imported back to [^<]*2024[^<]*\. Google stopped returning older readings/,
);
expect(html).toMatch(
/data-reason="horizon"[^>]*>Weight: imported back to [^<]*2006[^<]*\. Older readings lie beyond the import limit\./,
);
});
it("limited by plan: the sentence names up to three, counts the rest, no button", () => {
const html = render(
"withings",
@@ -0,0 +1,46 @@
"use client";
/**
* Where an integration's imported history ends, when its backfill stopped
* before the beginning of a data type's history (#1220).
*
* Without it a backfill that could not go further either kept the card
* saying older history was loading, forever, or (once it completes) would
* leave a gap nobody explained. The server resolves where each type ends and
* why; this only words it. Each line is two sentences about the user's own
* data, so it reads as body text (`text-sm`, foreground), not as muted meta.
*/
import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { IntegrationStatusViewModel } from "./shared";
export function HistoryEndsNote({
provider,
entries,
}: {
provider: string;
entries: IntegrationStatusViewModel["backfillEnded"];
}) {
const { t } = useTranslations();
const fmt = useFormatters();
if (!entries?.length) return null;
return (
<ul className="space-y-1 text-sm" data-testid={`${provider}-history-ends`}>
{entries.map((entry) => {
const labelKey = MEASUREMENT_TYPE_LABEL_KEYS[entry.type];
const values = {
type: labelKey ? t(labelKey) : entry.type,
date: fmt.date(entry.endsAt),
};
return (
<li key={entry.type} data-reason={entry.reason}>
{entry.reason === "horizon"
? t("settings.integrationCard.historyEnds.horizon", values)
: t("settings.integrationCard.historyEnds.noProgress", values)}
</li>
);
})}
</ul>
);
}
@@ -55,6 +55,7 @@ import {
IntegrationCard,
type IntegrationMenuAction,
} from "./integration-card";
import { HistoryEndsNote } from "./history-ends-note";
import { MetricFreshnessList } from "./metric-freshness-list";
import {
CallbackMismatchNotice,
@@ -505,6 +506,7 @@ export function OAuthIntegrationCardView({
) : undefined
}
>
<HistoryEndsNote provider={provider} entries={viewModel?.backfillEnded} />
{syncResults ?? (
<ActionResultLines provider={provider} sync={sync} test={test} />
)}
@@ -54,6 +54,15 @@ export interface IntegrationStatusViewModel {
connected?: boolean;
/** WHOOP / Fitbit / Google Health: older history still loading. */
backfillCompleted?: boolean | null;
/**
* Google Health: data types whose history backfill ended before the start
* of their history, with where and why.
*/
backfillEnded?: Array<{
type: string;
endsAt: string;
reason: "horizon" | "no_progress";
}>;
/**
* Polar / Oura / Strava: usable credentials resolve (BYO or the shared
* app). Greys out "Connect" when false.
+31
View File
@@ -207,6 +207,31 @@ export const connectionHealth = z
"The one server-resolved health state of a connection (v1.42.4). It folds the ledger state, the liveness verdict, plan and permission gaps, provider reauth flags and the integration watchdog's silence flag into four states. Clients render it and never recompute it. The older health-shaped fields beside it stay for one release after the clients read `health`, then go.",
});
// #1220 — a Google Health backfill that ended a data type's history short of
// its beginning says where and why, so a client can say so instead of
// "older history is still loading".
const googleHealthBackfillEnded = z
.array(
z.object({
type: z
.string()
.describe(
"The measurement type whose imported history ends here (e.g. `PULSE` for heart rate).",
),
endsAt: isoDateTime().describe(
"The oldest instant the imported history of this type reaches back to.",
),
reason: z
.enum(["horizon", "no_progress"])
.describe(
"`horizon`: the backfill reads back a set number of years (`GOOGLE_HEALTH_BACKFILL_HORIZON_YEARS`, twenty by default) and the history goes further. `no_progress`: Google kept returning pages that reached no further back, so the import stopped there.",
),
}),
)
.describe(
"Data types whose history backfill ended before the beginning of their history, with where and why. Empty when every type was read to its start. Kept after the backfill completes; cleared on reconnect.",
);
const metricFreshnessEntry = z
.object({
type: z
@@ -975,6 +1000,7 @@ const fitbitStatusResponse = z.object(connectionStatusShape()).meta({
const googleHealthStatusResponse = z
.object({
...connectionStatusShape(),
backfillEnded: googleHealthBackfillEnded.optional(),
needsReauth: z
.boolean()
.optional()
@@ -1088,6 +1114,11 @@ const integrationEnvelopeEntry = z
.optional()
.meta(legacy("Null — not false — when there is no connection to judge.")),
backfillCompleted: z.boolean().nullable().optional(),
backfillEnded: googleHealthBackfillEnded
.optional()
.describe(
"Google Health only. Data types whose history backfill ended before the beginning of their history, with where and why.",
),
scope: z.string().nullable().optional().describe("Withings only."),
hasActivityScope: z.boolean().optional().meta(legacy("Withings only.")),
planUnavailable: z