mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
1 parent
72c259d85c
commit
f6b09f07cd
15 files changed
+213
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7398,6 +7398,10 @@
|
||||
"causePermission": "권한",
|
||||
"failureStreak": "연속 실패: {threshold}회 중 {count}회",
|
||||
"copyFailed": "복사하지 못했어요. 텍스트를 선택해서 직접 복사해 주세요."
|
||||
},
|
||||
"historyEnds": {
|
||||
"horizon": "{type}: {date}까지 가져왔습니다. 그보다 오래된 기록은 가져오기 한도를 넘습니다.",
|
||||
"noProgress": "{type}: {date}까지 가져왔습니다. Google이 더 오래된 기록을 반환하지 않아 가져오기가 여기서 끝났습니다."
|
||||
}
|
||||
},
|
||||
"dawarich": "Dawarich",
|
||||
|
||||
@@ -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 /
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user