mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
fix(integrations): surface Google sync progress and canonical workouts
This commit is contained in:
1 parent
b9879e8829
commit
9354c649fe
15 files changed
+604
-67
No files matched your search
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Gespeichert — zum Ersetzen neu eingeben",
|
||||
"googleHealthSaveCredentials": "Speichern",
|
||||
"googleHealthSync": "Jetzt synchronisieren",
|
||||
"googleHealthSyncCheckStatus": "Status aktualisieren",
|
||||
"googleHealthFullSync": "Alle Daten synchronisieren",
|
||||
"googleHealthFullSyncTitle": "Vollständige Synchronisierung?",
|
||||
"googleHealthFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Je nach Verlauf kann das eine Weile dauern.",
|
||||
"googleHealthSyncResult": "{count} Messwerte synchronisiert",
|
||||
"googleHealthSyncFailed": "Synchronisierung fehlgeschlagen",
|
||||
"googleHealthSyncInProgress": "Synchronisierung läuft: {count} Messwerte gespeichert, {completed} von {total} Bereichen verarbeitet.",
|
||||
"googleHealthResourceFailuresTitle": "Diese Bereiche benötigen noch Aufmerksamkeit:",
|
||||
"googleHealthResource": {
|
||||
"workout": "Workouts",
|
||||
"sleep": "Schlaf",
|
||||
"boundedMetrics": "Gesundheitsmesswerte",
|
||||
"activity": "Aktivität",
|
||||
"heartRate": "Herzfrequenz",
|
||||
"other": "Weitere Daten"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google konnte diese Daten nicht bereitstellen. Versuche die Synchronisierung später erneut.",
|
||||
"tokenFailed": "Die Verbindung ist abgelaufen. Verbinde Google Health erneut.",
|
||||
"upsertFailed": "Die Daten konnten nicht gespeichert werden. Versuche die Synchronisierung erneut.",
|
||||
"rollupFailed": "Die Daten wurden erfasst, aber die Auswertung ist noch nicht aktualisiert. Versuche es später erneut.",
|
||||
"pageLimit": "Der verfügbare Verlauf wurde nur teilweise verarbeitet. Eine weitere Synchronisierung setzt ihn fort.",
|
||||
"generic": "Dieser Bereich wurde nicht vollständig synchronisiert. Versuche es erneut."
|
||||
},
|
||||
"googleHealthSynchronize": "Synchronisieren",
|
||||
"googleHealthDisconnect": "Trennen",
|
||||
"googleHealthDisconnectTitle": "Google Health trennen?",
|
||||
|
||||
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Saved — enter new to replace",
|
||||
"googleHealthSaveCredentials": "Save",
|
||||
"googleHealthSync": "Sync now",
|
||||
"googleHealthSyncCheckStatus": "Refresh status",
|
||||
"googleHealthFullSync": "Sync all data",
|
||||
"googleHealthFullSyncTitle": "Full synchronization?",
|
||||
"googleHealthFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.",
|
||||
"googleHealthSyncResult": "{count} measurements synchronized",
|
||||
"googleHealthSyncFailed": "Sync failed",
|
||||
"googleHealthSyncInProgress": "Sync in progress: {count} measurements saved, {completed} of {total} areas processed.",
|
||||
"googleHealthResourceFailuresTitle": "These areas still need attention:",
|
||||
"googleHealthResource": {
|
||||
"workout": "Workouts",
|
||||
"sleep": "Sleep",
|
||||
"boundedMetrics": "Health measurements",
|
||||
"activity": "Activity",
|
||||
"heartRate": "Heart rate",
|
||||
"other": "Other data"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google could not provide this data. Try syncing again later.",
|
||||
"tokenFailed": "The connection has expired. Reconnect Google Health.",
|
||||
"upsertFailed": "The data could not be saved. Try syncing again.",
|
||||
"rollupFailed": "The data was captured, but its summary is not updated yet. Try again later.",
|
||||
"pageLimit": "Only part of the available history was processed. Another sync will continue it.",
|
||||
"generic": "This area was not fully synchronized. Try again."
|
||||
},
|
||||
"googleHealthSynchronize": "Synchronize",
|
||||
"googleHealthDisconnect": "Disconnect",
|
||||
"googleHealthDisconnectTitle": "Disconnect Google Health?",
|
||||
|
||||
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Guardado — introduce nuevas para reemplazar",
|
||||
"googleHealthSaveCredentials": "Guardar",
|
||||
"googleHealthSync": "Sincronizar ahora",
|
||||
"googleHealthSyncCheckStatus": "Actualizar estado",
|
||||
"googleHealthFullSync": "Sincronizar todos los datos",
|
||||
"googleHealthFullSyncTitle": "¿Sincronización completa?",
|
||||
"googleHealthFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Puede tardar según tu historial.",
|
||||
"googleHealthSyncResult": "{count} mediciones sincronizadas",
|
||||
"googleHealthSyncFailed": "Error de sincronización",
|
||||
"googleHealthSyncInProgress": "Sincronización en curso: {count} mediciones guardadas, {completed} de {total} áreas procesadas.",
|
||||
"googleHealthResourceFailuresTitle": "Estas áreas aún necesitan atención:",
|
||||
"googleHealthResource": {
|
||||
"workout": "Entrenamientos",
|
||||
"sleep": "Sueño",
|
||||
"boundedMetrics": "Mediciones de salud",
|
||||
"activity": "Actividad",
|
||||
"heartRate": "Frecuencia cardíaca",
|
||||
"other": "Otros datos"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google no pudo proporcionar estos datos. Vuelve a sincronizar más tarde.",
|
||||
"tokenFailed": "La conexión ha caducado. Vuelve a conectar Google Health.",
|
||||
"upsertFailed": "No se pudieron guardar los datos. Vuelve a sincronizar.",
|
||||
"rollupFailed": "Los datos se recibieron, pero su resumen aún no se ha actualizado. Inténtalo más tarde.",
|
||||
"pageLimit": "Solo se procesó una parte del historial disponible. Otra sincronización continuará.",
|
||||
"generic": "Esta área no se sincronizó por completo. Vuelve a intentarlo."
|
||||
},
|
||||
"googleHealthSynchronize": "Sincronizar",
|
||||
"googleHealthDisconnect": "Desconectar",
|
||||
"googleHealthDisconnectTitle": "¿Desconectar Google Health?",
|
||||
|
||||
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"googleHealthSaveCredentials": "Enregistrer",
|
||||
"googleHealthSync": "Synchroniser maintenant",
|
||||
"googleHealthSyncCheckStatus": "Actualiser le statut",
|
||||
"googleHealthFullSync": "Synchroniser toutes les données",
|
||||
"googleHealthFullSyncTitle": "Synchronisation complète ?",
|
||||
"googleHealthFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon ton historique.",
|
||||
"googleHealthSyncResult": "{count} mesures synchronisées",
|
||||
"googleHealthSyncFailed": "Échec de la synchronisation",
|
||||
"googleHealthSyncInProgress": "Synchronisation en cours : {count} mesures enregistrées, {completed} zones traitées sur {total}.",
|
||||
"googleHealthResourceFailuresTitle": "Ces zones nécessitent encore votre attention :",
|
||||
"googleHealthResource": {
|
||||
"workout": "Entraînements",
|
||||
"sleep": "Sommeil",
|
||||
"boundedMetrics": "Mesures de santé",
|
||||
"activity": "Activité",
|
||||
"heartRate": "Fréquence cardiaque",
|
||||
"other": "Autres données"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google n’a pas pu fournir ces données. Réessayez la synchronisation plus tard.",
|
||||
"tokenFailed": "La connexion a expiré. Reconnectez Google Health.",
|
||||
"upsertFailed": "Les données n’ont pas pu être enregistrées. Réessayez la synchronisation.",
|
||||
"rollupFailed": "Les données ont été reçues, mais leur synthèse n’est pas encore actualisée. Réessayez plus tard.",
|
||||
"pageLimit": "Seule une partie de l’historique disponible a été traitée. Une autre synchronisation continuera.",
|
||||
"generic": "Cette zone n’a pas été entièrement synchronisée. Réessayez."
|
||||
},
|
||||
"googleHealthSynchronize": "Synchroniser",
|
||||
"googleHealthDisconnect": "Déconnecter",
|
||||
"googleHealthDisconnectTitle": "Déconnecter Google Health ?",
|
||||
|
||||
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Salvate — inseriscine di nuove per sostituirle",
|
||||
"googleHealthSaveCredentials": "Salva",
|
||||
"googleHealthSync": "Sincronizza ora",
|
||||
"googleHealthSyncCheckStatus": "Aggiorna stato",
|
||||
"googleHealthFullSync": "Sincronizza tutti i dati",
|
||||
"googleHealthFullSyncTitle": "Sincronizzazione completa?",
|
||||
"googleHealthFullSyncDescription": "Tutta la cronologia disponibile di Google Health verrà sincronizzata completamente. Può richiedere del tempo in base alla tua cronologia.",
|
||||
"googleHealthSyncResult": "{count} misurazioni sincronizzate",
|
||||
"googleHealthSyncFailed": "Sincronizzazione non riuscita",
|
||||
"googleHealthSyncInProgress": "Sincronizzazione in corso: {count} misurazioni salvate, {completed} aree elaborate su {total}.",
|
||||
"googleHealthResourceFailuresTitle": "Queste aree richiedono ancora attenzione:",
|
||||
"googleHealthResource": {
|
||||
"workout": "Allenamenti",
|
||||
"sleep": "Sonno",
|
||||
"boundedMetrics": "Misurazioni di salute",
|
||||
"activity": "Attività",
|
||||
"heartRate": "Frequenza cardiaca",
|
||||
"other": "Altri dati"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google non ha potuto fornire questi dati. Riprova la sincronizzazione più tardi.",
|
||||
"tokenFailed": "La connessione è scaduta. Riconnetti Google Health.",
|
||||
"upsertFailed": "Non è stato possibile salvare i dati. Riprova la sincronizzazione.",
|
||||
"rollupFailed": "I dati sono stati acquisiti, ma il riepilogo non è ancora aggiornato. Riprova più tardi.",
|
||||
"pageLimit": "È stata elaborata solo una parte della cronologia disponibile. Un’altra sincronizzazione continuerà.",
|
||||
"generic": "Quest’area non è stata sincronizzata completamente. Riprova."
|
||||
},
|
||||
"googleHealthSynchronize": "Sincronizza",
|
||||
"googleHealthDisconnect": "Disconnetti",
|
||||
"googleHealthDisconnectTitle": "Disconnettere Google Health?",
|
||||
|
||||
@@ -5345,11 +5345,30 @@
|
||||
"googleHealthCredentialsSavedPlaceholderSecret": "Zapisane — wpisz nowe, aby zastąpić",
|
||||
"googleHealthSaveCredentials": "Zapisz",
|
||||
"googleHealthSync": "Synchronizuj teraz",
|
||||
"googleHealthSyncCheckStatus": "Odśwież stan",
|
||||
"googleHealthFullSync": "Synchronizuj wszystkie dane",
|
||||
"googleHealthFullSyncTitle": "Pełna synchronizacja?",
|
||||
"googleHealthFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. Może to chwilę potrwać w zależności od historii.",
|
||||
"googleHealthSyncResult": "Zsynchronizowano pomiary: {count}",
|
||||
"googleHealthSyncFailed": "Synchronizacja nie powiodła się",
|
||||
"googleHealthSyncInProgress": "Synchronizacja trwa: zapisano {count} pomiarów, przetworzono {completed} z {total} obszarów.",
|
||||
"googleHealthResourceFailuresTitle": "Te obszary nadal wymagają uwagi:",
|
||||
"googleHealthResource": {
|
||||
"workout": "Treningi",
|
||||
"sleep": "Sen",
|
||||
"boundedMetrics": "Pomiary zdrowotne",
|
||||
"activity": "Aktywność",
|
||||
"heartRate": "Tętno",
|
||||
"other": "Inne dane"
|
||||
},
|
||||
"googleHealthReason": {
|
||||
"collectionFailed": "Google nie mogło udostępnić tych danych. Spróbuj zsynchronizować je później.",
|
||||
"tokenFailed": "Połączenie wygasło. Połącz Google Health ponownie.",
|
||||
"upsertFailed": "Nie udało się zapisać danych. Spróbuj zsynchronizować je ponownie.",
|
||||
"rollupFailed": "Dane zostały odebrane, ale ich podsumowanie nie jest jeszcze zaktualizowane. Spróbuj później.",
|
||||
"pageLimit": "Przetworzono tylko część dostępnej historii. Kolejna synchronizacja będzie ją kontynuować.",
|
||||
"generic": "Ten obszar nie został w pełni zsynchronizowany. Spróbuj ponownie."
|
||||
},
|
||||
"googleHealthSynchronize": "Synchronizuj",
|
||||
"googleHealthDisconnect": "Rozłącz",
|
||||
"googleHealthDisconnectTitle": "Rozłączyć Google Health?",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
@@ -12,6 +13,7 @@ import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
||||
import { canonicalWorkoutDetailHref } from "@/lib/workouts/canonical-detail-route";
|
||||
import {
|
||||
WorkoutDetailHeader,
|
||||
WorkoutDetailStats,
|
||||
@@ -47,8 +49,14 @@ export default function InsightsWorkoutDetailPage({
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const { id } = use(params);
|
||||
const router = useRouter();
|
||||
const { ready } = useModulePageGuard("workouts");
|
||||
const { data, isLoading, error, refetch } = useWorkoutDetail(id);
|
||||
const canonicalHref = canonicalWorkoutDetailHref(id, data?.canonicalId);
|
||||
|
||||
useEffect(() => {
|
||||
if (canonicalHref) router.replace(canonicalHref);
|
||||
}, [canonicalHref, router]);
|
||||
|
||||
// v1.18.0 B1 — bounce a direct URL hit on a disabled-workouts account.
|
||||
if (!ready) {
|
||||
@@ -71,7 +79,7 @@ export default function InsightsWorkoutDetailPage({
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading || canonicalHref ? (
|
||||
<div data-slot="workout-detail-loading" className="space-y-3">
|
||||
<Skeleton className="h-20 w-full rounded-lg" />
|
||||
<Skeleton className="h-40 w-full rounded-lg" />
|
||||
|
||||
@@ -20,6 +20,22 @@ describe("GoogleHealthCard manual-sync terminal contract", () => {
|
||||
expect(source).toMatch(/partial/);
|
||||
expect(source).toMatch(/failed/);
|
||||
expect(source).toMatch(/interrupted/);
|
||||
expect(source).toContain("google-health-sync-progress");
|
||||
expect(source).toContain("settings.googleHealthSyncInProgress");
|
||||
expect(source).toMatch(
|
||||
/syncProgress\?\.state === "in_progress"[\s\S]{0,180}refreshSyncStatus/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders actionable, allowlisted per-resource failure details", () => {
|
||||
expect(source).toContain("google-health-resource-failures");
|
||||
expect(source).toContain("failedGoogleHealthResources(syncProgress)");
|
||||
expect(source).toContain("googleHealthReasonCode(resource)");
|
||||
expect(source).toContain("settings.googleHealthReason.tokenFailed");
|
||||
expect(source).toContain("settings.googleHealthReason.upsertFailed");
|
||||
expect(source).not.toMatch(
|
||||
/resourceFailureMessage[\s\S]{0,160}(resource\.reasonCode|\$\{[^}]*reasonCode)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("invalidates every affected client query after a committed workout outcome", () => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
completedGoogleHealthResourceCount,
|
||||
failedGoogleHealthResources,
|
||||
googleHealthReasonCode,
|
||||
readGoogleHealthProgress,
|
||||
} from "../google-health-progress-view";
|
||||
|
||||
describe("Google Health progress view", () => {
|
||||
it("preserves bounded in-progress resource completion and stable reasons", () => {
|
||||
const progress = readGoogleHealthProgress({
|
||||
data: {
|
||||
state: "in_progress",
|
||||
imported: 12,
|
||||
resources: [
|
||||
{
|
||||
resource: "workout",
|
||||
written: 12,
|
||||
status: "complete",
|
||||
reasonCode: null,
|
||||
},
|
||||
{
|
||||
resource: "sleep",
|
||||
written: 0,
|
||||
status: "failed",
|
||||
reasonCode: "collection_failed",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(progress?.state).toBe("in_progress");
|
||||
expect(progress?.imported).toBe(12);
|
||||
expect(completedGoogleHealthResourceCount(progress)).toBe(1);
|
||||
expect(failedGoogleHealthResources(progress)).toHaveLength(1);
|
||||
expect(
|
||||
googleHealthReasonCode(failedGoogleHealthResources(progress)[0]),
|
||||
).toBe("collection_failed");
|
||||
});
|
||||
|
||||
it("rejects unknown run states and does not expose unknown reason codes", () => {
|
||||
expect(readGoogleHealthProgress({ data: { state: "queued" } })).toBeNull();
|
||||
expect(
|
||||
googleHealthReasonCode({
|
||||
resource: "sleep",
|
||||
written: 0,
|
||||
status: "failed",
|
||||
reasonCode: "raw-provider-secret",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,51 @@ describe("readSyncOutcome", () => {
|
||||
).toEqual({ imported: 0, failed: true, outcome: "failed" });
|
||||
});
|
||||
|
||||
it("preserves only bounded, sanitized per-resource outcomes", () => {
|
||||
expect(
|
||||
readSyncOutcome({
|
||||
data: {
|
||||
imported: 4,
|
||||
failed: true,
|
||||
outcome: "partial",
|
||||
resources: [
|
||||
{
|
||||
resource: "Sleep",
|
||||
written: 4,
|
||||
status: "partial",
|
||||
reasonCode: "collection_failed",
|
||||
},
|
||||
{
|
||||
resource: "../raw secret",
|
||||
written: -4,
|
||||
status: "failed",
|
||||
reasonCode: "provider error: token=secret",
|
||||
},
|
||||
{ resource: "ignored", status: "unknown" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
imported: 4,
|
||||
failed: true,
|
||||
outcome: "partial",
|
||||
resources: [
|
||||
{
|
||||
resource: "sleep",
|
||||
written: 4,
|
||||
status: "partial",
|
||||
reasonCode: "collection_failed",
|
||||
},
|
||||
{
|
||||
resource: "-raw-secret",
|
||||
written: 0,
|
||||
status: "failed",
|
||||
reasonCode: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an envelope that carries only the count", () => {
|
||||
// The pre-fix shape. A card that accepted it would be back to reporting a
|
||||
// number with no verdict attached.
|
||||
|
||||
@@ -66,25 +66,21 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import {
|
||||
completedGoogleHealthResourceCount,
|
||||
failedGoogleHealthResources,
|
||||
googleHealthReasonCode,
|
||||
readGoogleHealthProgress,
|
||||
type GoogleHealthProgress,
|
||||
} from "./google-health-progress-view";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
type SyncOutcomeResource,
|
||||
type SyncOutcomeState,
|
||||
} from "./sync-outcome";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
|
||||
interface GoogleHealthTerminalResource {
|
||||
resource?: unknown;
|
||||
written?: unknown;
|
||||
}
|
||||
|
||||
interface GoogleHealthTerminalProgress {
|
||||
state:
|
||||
"complete" | "partial" | "zero" | "truncated" | "failed" | "interrupted";
|
||||
imported: number;
|
||||
resources: GoogleHealthTerminalResource[];
|
||||
}
|
||||
|
||||
export function GoogleHealthCard({
|
||||
viewModel,
|
||||
}: {
|
||||
@@ -94,6 +90,9 @@ export function GoogleHealthCard({
|
||||
const describeSyncOutcome = useSyncOutcomeMessage();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncResult, setSyncResult] = useState<SyncOutcomeState | null>(null);
|
||||
const [syncProgress, setSyncProgress] = useState<GoogleHealthProgress | null>(
|
||||
null,
|
||||
);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
@@ -105,9 +104,7 @@ export function GoogleHealthCard({
|
||||
|
||||
const status = viewModel;
|
||||
|
||||
function invalidateCommittedWorkout(
|
||||
resources: GoogleHealthTerminalResource[],
|
||||
) {
|
||||
function invalidateCommittedWorkout(resources: SyncOutcomeResource[]) {
|
||||
const workoutCommitted = resources.some(
|
||||
(resource) =>
|
||||
resource.resource === "workout" &&
|
||||
@@ -121,51 +118,37 @@ export function GoogleHealthCard({
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.analytics() });
|
||||
}
|
||||
|
||||
function readTerminalProgress(
|
||||
body: unknown,
|
||||
): GoogleHealthTerminalProgress | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const data = (body as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
if (
|
||||
record.state !== "complete" &&
|
||||
record.state !== "partial" &&
|
||||
record.state !== "zero" &&
|
||||
record.state !== "truncated" &&
|
||||
record.state !== "failed" &&
|
||||
record.state !== "interrupted"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
state: record.state,
|
||||
imported:
|
||||
typeof record.imported === "number" && Number.isFinite(record.imported)
|
||||
? Math.max(0, Math.trunc(record.imported))
|
||||
: 0,
|
||||
resources: Array.isArray(record.resources)
|
||||
? record.resources.slice(0, 16)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function pollSyncStatus(): Promise<GoogleHealthTerminalProgress | null> {
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
async function pollSyncStatus(
|
||||
maxAttempts = 20,
|
||||
): Promise<GoogleHealthProgress | null> {
|
||||
let latest: GoogleHealthProgress | null = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const response = await apiFetchRaw("/api/google-health/sync/status");
|
||||
const progress = readTerminalProgress(await response.json());
|
||||
if (response.ok && progress) return progress;
|
||||
const progress = readGoogleHealthProgress(await response.json());
|
||||
if (response.ok && progress) {
|
||||
latest = progress;
|
||||
setSyncProgress(progress);
|
||||
if (progress.state !== "in_progress") return progress;
|
||||
}
|
||||
} catch {
|
||||
// The POST may have committed even when its response was lost. Keep
|
||||
// this short bounded poll best-effort; the next card refresh can retry.
|
||||
// this bounded poll best-effort; the status CTA can retry it.
|
||||
}
|
||||
if (attempt + 1 < maxAttempts) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
}
|
||||
return null;
|
||||
return latest;
|
||||
}
|
||||
|
||||
function showTerminalProgress(progress: GoogleHealthTerminalProgress): void {
|
||||
function showProgress(progress: GoogleHealthProgress): void {
|
||||
setSyncProgress(progress);
|
||||
invalidateCommittedWorkout(progress.resources);
|
||||
if (progress.state === "in_progress") {
|
||||
setSyncResult(null);
|
||||
return;
|
||||
}
|
||||
const failed =
|
||||
progress.state === "failed" || progress.state === "interrupted";
|
||||
const outcome =
|
||||
@@ -188,7 +171,40 @@ export function GoogleHealthCard({
|
||||
t("settings.googleHealthSyncResult", { count: progress.imported }),
|
||||
),
|
||||
});
|
||||
invalidateCommittedWorkout(progress.resources);
|
||||
}
|
||||
|
||||
function progressFromOutcome(
|
||||
result: ReturnType<typeof readSyncOutcome>,
|
||||
): GoogleHealthProgress | null {
|
||||
if (!result?.resources) return null;
|
||||
return {
|
||||
state:
|
||||
result.outcome === "failed"
|
||||
? "failed"
|
||||
: result.outcome === "partial"
|
||||
? "partial"
|
||||
: result.outcome === "empty"
|
||||
? "zero"
|
||||
: "complete",
|
||||
imported: result.imported,
|
||||
resources: result.resources,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshSyncStatus() {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const progress = await pollSyncStatus(4);
|
||||
if (progress) showProgress(progress);
|
||||
else {
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
message: t("settings.googleHealthSyncFailed"),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
const disconnect = useMutation({
|
||||
@@ -224,6 +240,7 @@ export function GoogleHealthCard({
|
||||
async function handleSync(fullSync = false) {
|
||||
setSyncing(true);
|
||||
setSyncResult(null);
|
||||
setSyncProgress(null);
|
||||
try {
|
||||
const res = await apiFetchRaw("/api/google-health/sync", {
|
||||
method: "POST",
|
||||
@@ -246,12 +263,13 @@ export function GoogleHealthCard({
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
const terminal = readTerminalProgress(json);
|
||||
if (terminal) invalidateCommittedWorkout(terminal.resources);
|
||||
const progress =
|
||||
readGoogleHealthProgress(json) ?? progressFromOutcome(result);
|
||||
if (progress) showProgress(progress);
|
||||
} else {
|
||||
const terminal = await pollSyncStatus();
|
||||
if (terminal) {
|
||||
showTerminalProgress(terminal);
|
||||
const progress = await pollSyncStatus();
|
||||
if (progress) {
|
||||
showProgress(progress);
|
||||
} else {
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
@@ -260,9 +278,9 @@ export function GoogleHealthCard({
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const terminal = await pollSyncStatus();
|
||||
if (terminal) {
|
||||
showTerminalProgress(terminal);
|
||||
const progress = await pollSyncStatus();
|
||||
if (progress) {
|
||||
showProgress(progress);
|
||||
} else {
|
||||
setSyncResult({
|
||||
outcome: "failed",
|
||||
@@ -274,6 +292,40 @@ export function GoogleHealthCard({
|
||||
}
|
||||
}
|
||||
|
||||
function resourceLabel(resource: string): string {
|
||||
switch (resource) {
|
||||
case "workout":
|
||||
return t("settings.googleHealthResource.workout");
|
||||
case "sleep":
|
||||
return t("settings.googleHealthResource.sleep");
|
||||
case "bounded-metrics":
|
||||
return t("settings.googleHealthResource.boundedMetrics");
|
||||
case "activity":
|
||||
return t("settings.googleHealthResource.activity");
|
||||
case "dense-heart-rate":
|
||||
return t("settings.googleHealthResource.heartRate");
|
||||
default:
|
||||
return t("settings.googleHealthResource.other");
|
||||
}
|
||||
}
|
||||
|
||||
function resourceFailureMessage(resource: SyncOutcomeResource): string {
|
||||
switch (googleHealthReasonCode(resource)) {
|
||||
case "collection_failed":
|
||||
return t("settings.googleHealthReason.collectionFailed");
|
||||
case "token_failed":
|
||||
return t("settings.googleHealthReason.tokenFailed");
|
||||
case "upsert_failed":
|
||||
return t("settings.googleHealthReason.upsertFailed");
|
||||
case "rollup_failed":
|
||||
return t("settings.googleHealthReason.rollupFailed");
|
||||
case "existing_page_limit":
|
||||
return t("settings.googleHealthReason.pageLimit");
|
||||
default:
|
||||
return t("settings.googleHealthReason.generic");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCredsSaving(true);
|
||||
@@ -328,6 +380,8 @@ export function GoogleHealthCard({
|
||||
// a disconnected card has no token to renew. `needsReauth` is the 7-day
|
||||
// Testing-mode expiry (or a revoked grant) surfaced by the status route.
|
||||
const showReauth = Boolean(status?.connected && status?.needsReauth);
|
||||
const resourceFailures = failedGoogleHealthResources(syncProgress);
|
||||
const completedResources = completedGoogleHealthResourceCount(syncProgress);
|
||||
|
||||
return (
|
||||
<SettingsCard data-testid="google-health-card">
|
||||
@@ -541,7 +595,13 @@ export function GoogleHealthCard({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
onClick={() => handleSync(false)}
|
||||
onClick={() => {
|
||||
if (syncProgress?.state === "in_progress") {
|
||||
void refreshSyncStatus();
|
||||
} else {
|
||||
void handleSync(false);
|
||||
}
|
||||
}}
|
||||
disabled={syncing}
|
||||
>
|
||||
{syncing ? (
|
||||
@@ -549,7 +609,9 @@ export function GoogleHealthCard({
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("settings.googleHealthSync")}
|
||||
{syncProgress?.state === "in_progress"
|
||||
? t("settings.googleHealthSyncCheckStatus")
|
||||
: t("settings.googleHealthSync")}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -557,7 +619,7 @@ export function GoogleHealthCard({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11"
|
||||
disabled={syncing}
|
||||
disabled={syncing || syncProgress?.state === "in_progress"}
|
||||
>
|
||||
{syncing ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
@@ -625,6 +687,20 @@ export function GoogleHealthCard({
|
||||
{t("settings.googleHealthBackfillInProgress")}
|
||||
</p>
|
||||
)}
|
||||
{syncProgress?.state === "in_progress" && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid="google-health-sync-progress"
|
||||
>
|
||||
{t("settings.googleHealthSyncInProgress", {
|
||||
count: syncProgress.imported,
|
||||
completed: completedResources,
|
||||
total: syncProgress.resources.length,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{syncResult && (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
@@ -632,6 +708,28 @@ export function GoogleHealthCard({
|
||||
testId="google-health-sync-result"
|
||||
/>
|
||||
)}
|
||||
{resourceFailures.length > 0 && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="google-health-resource-failures"
|
||||
className="border-warning/30 bg-warning/10 rounded-md border px-3 py-2"
|
||||
>
|
||||
<p className="text-foreground text-xs font-medium">
|
||||
{t("settings.googleHealthResourceFailuresTitle")}
|
||||
</p>
|
||||
<ul className="text-muted-foreground mt-1 space-y-1 text-xs">
|
||||
{resourceFailures.map((resource) => (
|
||||
<li key={resource.resource}>
|
||||
<span className="text-foreground font-medium">
|
||||
{resourceLabel(resource.resource)}
|
||||
</span>
|
||||
{": "}
|
||||
{resourceFailureMessage(resource)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
cue. */}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { SyncOutcomeResource } from "./sync-outcome";
|
||||
import { readSyncOutcomeResource } from "./sync-outcome";
|
||||
|
||||
export type GoogleHealthProgressState =
|
||||
| "in_progress"
|
||||
| "complete"
|
||||
| "partial"
|
||||
| "zero"
|
||||
| "truncated"
|
||||
| "failed"
|
||||
| "interrupted";
|
||||
|
||||
export type GoogleHealthReasonCode =
|
||||
| "collection_failed"
|
||||
| "token_failed"
|
||||
| "upsert_failed"
|
||||
| "rollup_failed"
|
||||
| "existing_page_limit";
|
||||
|
||||
export interface GoogleHealthProgress {
|
||||
state: GoogleHealthProgressState;
|
||||
imported: number;
|
||||
resources: SyncOutcomeResource[];
|
||||
}
|
||||
|
||||
const PROGRESS_STATES = new Set<GoogleHealthProgressState>([
|
||||
"in_progress",
|
||||
"complete",
|
||||
"partial",
|
||||
"zero",
|
||||
"truncated",
|
||||
"failed",
|
||||
"interrupted",
|
||||
]);
|
||||
|
||||
const GOOGLE_REASON_CODES = new Set<GoogleHealthReasonCode>([
|
||||
"collection_failed",
|
||||
"token_failed",
|
||||
"upsert_failed",
|
||||
"rollup_failed",
|
||||
"existing_page_limit",
|
||||
]);
|
||||
|
||||
export function readGoogleHealthProgress(
|
||||
body: unknown,
|
||||
): GoogleHealthProgress | null {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const data = (body as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return null;
|
||||
const record = data as Record<string, unknown>;
|
||||
if (!PROGRESS_STATES.has(record.state as GoogleHealthProgressState)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
state: record.state as GoogleHealthProgressState,
|
||||
imported:
|
||||
typeof record.imported === "number" && Number.isFinite(record.imported)
|
||||
? Math.max(0, Math.trunc(record.imported))
|
||||
: 0,
|
||||
resources: Array.isArray(record.resources)
|
||||
? record.resources
|
||||
.slice(0, 16)
|
||||
.map(readSyncOutcomeResource)
|
||||
.filter(
|
||||
(resource): resource is SyncOutcomeResource => resource !== null,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function googleHealthReasonCode(
|
||||
resource: SyncOutcomeResource,
|
||||
): GoogleHealthReasonCode | null {
|
||||
return GOOGLE_REASON_CODES.has(resource.reasonCode as GoogleHealthReasonCode)
|
||||
? (resource.reasonCode as GoogleHealthReasonCode)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function failedGoogleHealthResources(
|
||||
progress: GoogleHealthProgress | null,
|
||||
): SyncOutcomeResource[] {
|
||||
if (!progress) return [];
|
||||
return progress.resources
|
||||
.filter(
|
||||
(resource) =>
|
||||
resource.status === "failed" ||
|
||||
resource.status === "partial" ||
|
||||
resource.status === "truncated",
|
||||
)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function completedGoogleHealthResourceCount(
|
||||
progress: GoogleHealthProgress | null,
|
||||
): number {
|
||||
if (!progress) return 0;
|
||||
return progress.resources.filter(
|
||||
(resource) =>
|
||||
resource.status === "complete" ||
|
||||
resource.status === "empty" ||
|
||||
resource.status === "partial" ||
|
||||
resource.status === "truncated",
|
||||
).length;
|
||||
}
|
||||
@@ -18,6 +18,56 @@ export interface SyncOutcomeResponse {
|
||||
failed: boolean;
|
||||
/** The resolved verdict — the card never recomputes this. */
|
||||
outcome: WrittenOutcome;
|
||||
/** Optional bounded per-resource evidence for providers that expose it. */
|
||||
resources?: SyncOutcomeResource[];
|
||||
}
|
||||
|
||||
export interface SyncOutcomeResource {
|
||||
resource: string;
|
||||
written: number;
|
||||
status: "pending" | "complete" | "partial" | "empty" | "truncated" | "failed";
|
||||
reasonCode: string | null;
|
||||
}
|
||||
|
||||
const RESOURCE_STATUSES = new Set<SyncOutcomeResource["status"]>([
|
||||
"pending",
|
||||
"complete",
|
||||
"partial",
|
||||
"empty",
|
||||
"truncated",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
export function readSyncOutcomeResource(
|
||||
value: unknown,
|
||||
): SyncOutcomeResource | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.resource !== "string" ||
|
||||
!RESOURCE_STATUSES.has(record.status as SyncOutcomeResource["status"])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const resource = record.resource
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 48);
|
||||
if (!resource) return null;
|
||||
return {
|
||||
resource,
|
||||
written:
|
||||
typeof record.written === "number" && Number.isFinite(record.written)
|
||||
? Math.max(0, Math.min(2_147_483_647, Math.trunc(record.written)))
|
||||
: 0,
|
||||
status: record.status as SyncOutcomeResource["status"],
|
||||
reasonCode:
|
||||
typeof record.reasonCode === "string" &&
|
||||
/^[a-z0-9_-]{1,48}$/.test(record.reasonCode)
|
||||
? record.reasonCode
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a card holds after a sync attempt, transport failures included. */
|
||||
@@ -46,7 +96,16 @@ export function readSyncOutcome(body: unknown): SyncOutcomeResponse | null {
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { imported, failed, outcome };
|
||||
const rawResources = (data as Record<string, unknown>).resources;
|
||||
const resources = Array.isArray(rawResources)
|
||||
? rawResources
|
||||
.slice(0, 16)
|
||||
.map(readSyncOutcomeResource)
|
||||
.filter(
|
||||
(resource): resource is SyncOutcomeResource => resource !== null,
|
||||
)
|
||||
: undefined;
|
||||
return { imported, failed, outcome, ...(resources ? { resources } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { canonicalWorkoutDetailHref } from "../canonical-detail-route";
|
||||
|
||||
describe("canonicalWorkoutDetailHref", () => {
|
||||
it("redirects a non-canonical twin to the API-selected workout", () => {
|
||||
expect(canonicalWorkoutDetailHref("apple-row", "whoop-row")).toBe(
|
||||
"/insights/workouts/whoop-row",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not redirect an already canonical workout", () => {
|
||||
expect(canonicalWorkoutDetailHref("whoop-row", "whoop-row")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects missing identifiers and encodes unexpected path characters", () => {
|
||||
expect(canonicalWorkoutDetailHref("apple-row", null)).toBeNull();
|
||||
expect(canonicalWorkoutDetailHref("apple-row", " ")).toBeNull();
|
||||
expect(canonicalWorkoutDetailHref("apple-row", "../other?id=1")).toBe(
|
||||
"/insights/workouts/..%2Fother%3Fid%3D1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Resolve a workout detail deep-link to the canonical row selected by the API.
|
||||
*
|
||||
* The API owns duplicate clustering and source priority. The client must only
|
||||
* follow its bounded identifier; it must never try to reimplement matching or
|
||||
* source selection from workout data.
|
||||
*/
|
||||
export function canonicalWorkoutDetailHref(
|
||||
requestedId: string,
|
||||
canonicalId: unknown,
|
||||
): string | null {
|
||||
if (typeof canonicalId !== "string") return null;
|
||||
const normalized = canonicalId.trim();
|
||||
if (normalized.length === 0 || normalized === requestedId) return null;
|
||||
return `/insights/workouts/${encodeURIComponent(normalized)}`;
|
||||
}
|
||||
Reference in new issue
Block a user