From 9354c649fec10a4cee5323f5fb6e10edc0dc2abc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:55:37 +0200 Subject: [PATCH] fix(integrations): surface Google sync progress and canonical workouts --- messages/de.json | 19 ++ messages/en.json | 19 ++ messages/es.json | 19 ++ messages/fr.json | 19 ++ messages/it.json | 19 ++ messages/pl.json | 19 ++ src/app/insights/workouts/[id]/page.tsx | 12 +- .../__tests__/google-health-card.test.tsx | 16 ++ .../google-health-progress-view.test.ts | 53 ++++ .../__tests__/sync-outcome.test.ts | 45 ++++ .../integrations/google-health-card.tsx | 226 +++++++++++++----- .../google-health-progress-view.ts | 105 ++++++++ .../settings/integrations/sync-outcome.ts | 61 ++++- .../__tests__/canonical-detail-route.test.ts | 23 ++ src/lib/workouts/canonical-detail-route.ts | 16 ++ 15 files changed, 604 insertions(+), 67 deletions(-) create mode 100644 src/components/settings/integrations/__tests__/google-health-progress-view.test.ts create mode 100644 src/components/settings/integrations/google-health-progress-view.ts create mode 100644 src/lib/workouts/__tests__/canonical-detail-route.test.ts create mode 100644 src/lib/workouts/canonical-detail-route.ts diff --git a/messages/de.json b/messages/de.json index 93e0985b2..154d3616a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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?", diff --git a/messages/en.json b/messages/en.json index 7667cc0b7..8b5031059 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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?", diff --git a/messages/es.json b/messages/es.json index 815332031..ec40c8490 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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?", diff --git a/messages/fr.json b/messages/fr.json index 45575fc98..c5e81b5ee 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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 ?", diff --git a/messages/it.json b/messages/it.json index da70db024..4e83596dc 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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?", diff --git a/messages/pl.json b/messages/pl.json index 1ccbe428c..f9df40039 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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?", diff --git a/src/app/insights/workouts/[id]/page.tsx b/src/app/insights/workouts/[id]/page.tsx index bf9bf6a79..7798f06a3 100644 --- a/src/app/insights/workouts/[id]/page.tsx +++ b/src/app/insights/workouts/[id]/page.tsx @@ -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 ? (
diff --git a/src/components/settings/integrations/__tests__/google-health-card.test.tsx b/src/components/settings/integrations/__tests__/google-health-card.test.tsx index d55e87412..24184e7a3 100644 --- a/src/components/settings/integrations/__tests__/google-health-card.test.tsx +++ b/src/components/settings/integrations/__tests__/google-health-card.test.tsx @@ -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", () => { diff --git a/src/components/settings/integrations/__tests__/google-health-progress-view.test.ts b/src/components/settings/integrations/__tests__/google-health-progress-view.test.ts new file mode 100644 index 000000000..62a7dd15b --- /dev/null +++ b/src/components/settings/integrations/__tests__/google-health-progress-view.test.ts @@ -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(); + }); +}); diff --git a/src/components/settings/integrations/__tests__/sync-outcome.test.ts b/src/components/settings/integrations/__tests__/sync-outcome.test.ts index ae0da88d3..cc3f7c172 100644 --- a/src/components/settings/integrations/__tests__/sync-outcome.test.ts +++ b/src/components/settings/integrations/__tests__/sync-outcome.test.ts @@ -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. diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 7f3169b60..c0bc37130 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -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(null); + const [syncProgress, setSyncProgress] = useState( + 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; - 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 { - for (let attempt = 0; attempt < 4; attempt++) { + async function pollSyncStatus( + maxAttempts = 20, + ): Promise { + 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, + ): 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 ( @@ -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({ ) : ( )} - {t("settings.googleHealthSync")} + {syncProgress?.state === "in_progress" + ? t("settings.googleHealthSyncCheckStatus") + : t("settings.googleHealthSync")} @@ -557,7 +619,7 @@ export function GoogleHealthCard({ variant="outline" size="sm" className="min-h-11" - disabled={syncing} + disabled={syncing || syncProgress?.state === "in_progress"} > {syncing ? ( @@ -625,6 +687,20 @@ export function GoogleHealthCard({ {t("settings.googleHealthBackfillInProgress")}

)} + {syncProgress?.state === "in_progress" && ( +

+ {t("settings.googleHealthSyncInProgress", { + count: syncProgress.imported, + completed: completedResources, + total: syncProgress.resources.length, + })} +

+ )} {syncResult && ( )} + {resourceFailures.length > 0 && ( +
+

+ {t("settings.googleHealthResourceFailuresTitle")} +

+
    + {resourceFailures.map((resource) => ( +
  • + + {resourceLabel(resource.resource)} + + {": "} + {resourceFailureMessage(resource)} +
  • + ))} +
+
+ )} {/* connect→data loop: a discreet link to where this provider's readings now surface — doubles as the "your data is richer" cue. */} diff --git a/src/components/settings/integrations/google-health-progress-view.ts b/src/components/settings/integrations/google-health-progress-view.ts new file mode 100644 index 000000000..45ea9dca5 --- /dev/null +++ b/src/components/settings/integrations/google-health-progress-view.ts @@ -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([ + "in_progress", + "complete", + "partial", + "zero", + "truncated", + "failed", + "interrupted", +]); + +const GOOGLE_REASON_CODES = new Set([ + "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; + 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; +} diff --git a/src/components/settings/integrations/sync-outcome.ts b/src/components/settings/integrations/sync-outcome.ts index 3eccbb1cc..1a31ebc55 100644 --- a/src/components/settings/integrations/sync-outcome.ts +++ b/src/components/settings/integrations/sync-outcome.ts @@ -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([ + "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; + 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).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 } : {}) }; } /** diff --git a/src/lib/workouts/__tests__/canonical-detail-route.test.ts b/src/lib/workouts/__tests__/canonical-detail-route.test.ts new file mode 100644 index 000000000..8ca56d458 --- /dev/null +++ b/src/lib/workouts/__tests__/canonical-detail-route.test.ts @@ -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", + ); + }); +}); diff --git a/src/lib/workouts/canonical-detail-route.ts b/src/lib/workouts/canonical-detail-route.ts new file mode 100644 index 000000000..d859e02b5 --- /dev/null +++ b/src/lib/workouts/canonical-detail-route.ts @@ -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)}`; +}