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)}`;
+}