fix(integrations): surface Google sync progress and canonical workouts

This commit is contained in:
Marc-André Bombeck committed 2026-07-29 15:55:37 +02:00
1 parent b9879e8829
commit 9354c649fe
15 files changed
+604 -67

No files matched your search

+19
View File
@@ -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?",
+19
View File
@@ -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?",
+19
View File
@@ -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?",
+19
View File
@@ -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 ?",
+19
View File
@@ -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?",
+19
View File
@@ -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?",
+10 -2
View File
@@ -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)}`;
}