From f6b09f07cd63b6c41c23d3f8e646e6f6480659ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 19 Oct 2026 17:25:57 +0200 Subject: [PATCH] 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 --- docs/api/openapi.yaml | 56 +++++++++++++++++++ messages/de.json | 4 ++ messages/en.json | 4 ++ messages/es.json | 4 ++ messages/fr.json | 4 ++ messages/it.json | 4 ++ messages/ko.json | 4 ++ messages/pl.json | 4 ++ src/app/api/google-health/status/route.ts | 3 + src/app/api/integrations/status/route.ts | 9 +++ .../__tests__/integration-card.test.tsx | 29 ++++++++++ .../integrations/history-ends-note.tsx | 46 +++++++++++++++ .../integrations/oauth-integration-card.tsx | 2 + .../settings/integrations/shared.tsx | 9 +++ src/lib/openapi/routes/integrations.ts | 31 ++++++++++ 15 files changed, 213 insertions(+) create mode 100644 src/components/settings/integrations/history-ends-note.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 626d0e6b9..63cc0cf76 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/messages/de.json b/messages/de.json index 1e7d0ea10..4d4a6876c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index 5e59955ce..c0f59b448 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index 1c799ce67..5b9273d4f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 230f4a60a..26ac6e2ec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 5446a3205..1e0afae0d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index 753810a79..d7d0fbdab 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -7398,6 +7398,10 @@ "causePermission": "권한", "failureStreak": "연속 실패: {threshold}회 중 {count}회", "copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요." + }, + "historyEnds": { + "horizon": "{type}: {date}까지 가져왔습니다. 그보다 오래된 기록은 가져오기 한도를 넘습니다.", + "noProgress": "{type}: {date}까지 가져왔습니다. Google이 더 오래된 기록을 반환하지 않아 가져오기가 여기서 끝났습니다." } }, "dawarich": "Dawarich", diff --git a/messages/pl.json b/messages/pl.json index 3046dc530..6b0c0359f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/app/api/google-health/status/route.ts b/src/app/api/google-health/status/route.ts index d63ea0c12..1fead2eda 100644 --- a/src/app/api/google-health/status/route.ts +++ b/src/app/api/google-health/status/route.ts @@ -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 / diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 559125a3a..4bff2b04b 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -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; } diff --git a/src/components/settings/integrations/__tests__/integration-card.test.tsx b/src/components/settings/integrations/__tests__/integration-card.test.tsx index 83e53af48..9a1a09fdb 100644 --- a/src/components/settings/integrations/__tests__/integration-card.test.tsx +++ b/src/components/settings/integrations/__tests__/integration-card.test.tsx @@ -137,6 +137,35 @@ describe(" 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", diff --git a/src/components/settings/integrations/history-ends-note.tsx b/src/components/settings/integrations/history-ends-note.tsx new file mode 100644 index 000000000..f646fa84d --- /dev/null +++ b/src/components/settings/integrations/history-ends-note.tsx @@ -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 ( + + ); +} diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx index 0d9d653f1..d8909b2d1 100644 --- a/src/components/settings/integrations/oauth-integration-card.tsx +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -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 } > + {syncResults ?? ( )} diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 8e83b8863..6cb9c0dd4 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -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. diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index f7a6cc166..5d8c057f3 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -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