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

+56
View File
@@ -77868,6 +77868,34 @@ components:
anyOf:
- $ref: "#/components/schemas/ConnectionHealth"
- type: "null"
backfillEnded:
type: array
items:
type: object
properties:
type:
type: string
description: The measurement type whose imported history ends here (e.g. `PULSE` for heart rate).
endsAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: The oldest instant the imported history of this type reaches back to.
reason:
type: string
enum:
- horizon
- no_progress
description: "`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."
required:
- type
- endsAt
- reason
additionalProperties: false
description: 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.
needsReauth:
deprecated: true
description: "Deprecated: removed in v1.44, read `health`. The stored refresh token was rejected with `invalid_grant` —
@@ -78590,6 +78618,34 @@ components:
type:
- boolean
- "null"
backfillEnded:
description: Google Health only. Data types whose history backfill ended before the beginning of their history, with
where and why.
type: array
items:
type: object
properties:
type:
type: string
description: The measurement type whose imported history ends here (e.g. `PULSE` for heart rate).
endsAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: The oldest instant the imported history of this type reaches back to.
reason:
type: string
enum:
- horizon
- no_progress
description: "`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."
required:
- type
- endsAt
- reason
additionalProperties: false
scope:
description: Withings only.
type:
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "Freigabe",
"failureStreak": "Fehlerserie: {count} von {threshold}",
"copyFailed": "Kopieren hat nicht geklappt. Markiere den Text und kopiere ihn von Hand."
},
"historyEnds": {
"horizon": "{type}: übernommen bis zurück zum {date}. Ältere Werte liegen jenseits der Importgrenze.",
"noProgress": "{type}: übernommen bis zurück zum {date}. Google lieferte keine älteren Werte mehr, deshalb endet der Import dort."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "permission",
"failureStreak": "Failure streak: {count} of {threshold}",
"copyFailed": "Could not copy. Select the text and copy it by hand."
},
"historyEnds": {
"horizon": "{type}: imported back to {date}. Older readings lie beyond the import limit.",
"noProgress": "{type}: imported back to {date}. Google stopped returning older readings, so the import ends there."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "permiso",
"failureStreak": "Racha de fallos: {count} de {threshold}",
"copyFailed": "No se pudo copiar. Selecciona el texto y cópialo a mano."
},
"historyEnds": {
"horizon": "{type}: importado hasta el {date}. Las lecturas más antiguas quedan fuera del límite de importación.",
"noProgress": "{type}: importado hasta el {date}. Google dejó de devolver lecturas más antiguas, así que la importación termina ahí."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "autorisation",
"failureStreak": "Série d’échecs : {count} sur {threshold}",
"copyFailed": "La copie a échoué. Sélectionnez le texte et copiez-le à la main."
},
"historyEnds": {
"horizon": "{type} : importé jusqu'au {date}. Les mesures plus anciennes dépassent la limite d'import.",
"noProgress": "{type} : importé jusqu'au {date}. Google ne renvoyait plus de mesures plus anciennes, l'import s'arrête donc là."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "autorizzazione",
"failureStreak": "Serie di errori: {count} su {threshold}",
"copyFailed": "Copia non riuscita. Seleziona il testo e copialo a mano."
},
"historyEnds": {
"horizon": "{type}: importato fino al {date}. Le misurazioni più vecchie superano il limite di importazione.",
"noProgress": "{type}: importato fino al {date}. Google non restituiva più misurazioni più vecchie, quindi l'importazione si ferma lì."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "권한",
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
},
"historyEnds": {
"horizon": "{type}: {date}까지 가져왔습니다. 그보다 오래된 기록은 가져오기 한도를 넘습니다.",
"noProgress": "{type}: {date}까지 가져왔습니다. Google이 더 오래된 기록을 반환하지 않아 가져오기가 여기서 끝났습니다."
}
},
"dawarich": "Dawarich",
+4
View File
@@ -7398,6 +7398,10 @@
"causePermission": "uprawnienie",
"failureStreak": "Seria błędów: {count} z {threshold}",
"copyFailed": "Nie udało się skopiować. Zaznacz tekst i skopiuj go ręcznie."
},
"historyEnds": {
"horizon": "{type}: zaimportowano wstecz do {date}. Starsze odczyty wykraczają poza limit importu.",
"noProgress": "{type}: zaimportowano wstecz do {date}. Google przestał zwracać starsze odczyty, więc import kończy się w tym miejscu."
}
},
"dawarich": "Dawarich",
@@ -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