diff --git a/.env.production.example b/.env.production.example index c07d9e292..dcfe5afeb 100644 --- a/.env.production.example +++ b/.env.production.example @@ -50,6 +50,19 @@ API_TOKEN_HMAC_KEY="" # WHOOP_WEBHOOK_SECRET="" # WHOOP_REDIRECT_URI="" +# ----------------------------------------------------------------------------- +# Fitbit -- optional integration (classic Web API, per-user BYO-keys) +# ----------------------------------------------------------------------------- +# Each user registers their own app at https://dev.fitbit.com (self-serve, no +# brand verification) and pastes the OAuth Client ID + Client Secret into +# Settings (stored encrypted in the DB). There is therefore no +# FITBIT_CLIENT_ID/SECRET here. The OAuth flow uses Authorization Code + PKCE. +# +# FITBIT_REDIRECT_URI overrides the derived +# `${NEXT_PUBLIC_APP_URL}/api/fitbit/callback` when the public URL differs; it +# must exactly match the Callback URL registered in the dev.fitbit.com app. +# FITBIT_REDIRECT_URI="" + # ----------------------------------------------------------------------------- # Polar AccessLink -- optional integration (full OAuth, shared app from env) # ----------------------------------------------------------------------------- diff --git a/messages/de.json b/messages/de.json index 6425dcc38..479802947 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4454,9 +4454,9 @@ "token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.", + "fitbitDescription": "Verbinde Fitbit, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Herzfrequenz und mehr.", "fitbitExperimentalBadge": "Experimentell", "fitbitCredentials": "API-Zugangsdaten", "fitbitClientId": "Client-ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Jetzt synchronisieren", "fitbitFullSync": "Alle Daten synchronisieren", "fitbitFullSyncTitle": "Vollständige Synchronisierung?", - "fitbitFullSyncDescription": "Der gesamte verfügbare Google-Health-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.", + "fitbitFullSyncDescription": "Der gesamte verfügbare Fitbit-Verlauf wird vollständig synchronisiert. Das kann je nach Umfang deines Verlaufs einige Zeit dauern.", "fitbitSyncResult": "{count} Messungen synchronisiert", "fitbitSyncFailed": "Synchronisierung fehlgeschlagen", "fitbitSynchronize": "Synchronisieren", "fitbitDisconnect": "Trennen", - "fitbitDisconnectTitle": "Google Health trennen?", - "fitbitDisconnectDescription": "Die Verbindung zu Google Health wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.", - "fitbitConnect": "Mit Google Health verbinden", - "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.", - "fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…", - "fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.", - "fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen", + "fitbitDisconnectTitle": "Fitbit trennen?", + "fitbitDisconnectDescription": "Die Verbindung zu Fitbit wird getrennt. Zuvor synchronisierte Daten bleiben erhalten.", + "fitbitConnect": "Mit Fitbit verbinden", + "fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Fitbit zu verbinden.", + "fitbitBackfillInProgress": "Dein Fitbit-Verlauf wird im Hintergrund importiert…", + "fitbitOauthConnected": "Fitbit verbunden — die erste Synchronisierung startet im Hintergrund.", + "fitbitOauthFailed": "Fitbit-Verbindung fehlgeschlagen", "fitbitOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", - "state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", + "state": "Die Antwort von Fitbit passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.", "expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.", "cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.", - "nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.", - "nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.", - "token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.", + "nocode": "Fitbit hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Fitbit-Dialog.", + "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", + "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." }, "integrations": { diff --git a/messages/en.json b/messages/en.json index e010dba9a..f49afdad7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4454,9 +4454,9 @@ "token": "The token exchange with WHOOP failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", - "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.", + "fitbitDescription": "Connect Fitbit to sync your Fitbit and Pixel Watch vitals — weight, heart rate, and more.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "API Credentials", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sync now", "fitbitFullSync": "Sync all data", "fitbitFullSyncTitle": "Full synchronization?", - "fitbitFullSyncDescription": "All available Google Health history will be fully synchronized. This may take some time depending on your history.", + "fitbitFullSyncDescription": "All available Fitbit history will be fully synchronized. This may take some time depending on your history.", "fitbitSyncResult": "{count} measurements synchronized", "fitbitSyncFailed": "Sync failed", "fitbitSynchronize": "Synchronize", "fitbitDisconnect": "Disconnect", - "fitbitDisconnectTitle": "Disconnect Google Health?", - "fitbitDisconnectDescription": "The connection to Google Health will be disconnected. Previously synced data will be preserved.", - "fitbitConnect": "Connect with Google Health", - "fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.", - "fitbitBackfillInProgress": "Importing your Google Health history in the background…", - "fitbitOauthConnected": "Google Health connected — the first sync starts in the background.", - "fitbitOauthFailed": "Google Health connection failed", + "fitbitDisconnectTitle": "Disconnect Fitbit?", + "fitbitDisconnectDescription": "The connection to Fitbit will be disconnected. Previously synced data will be preserved.", + "fitbitConnect": "Connect with Fitbit", + "fitbitNoCredentials": "Please enter your API credentials above to connect Fitbit.", + "fitbitBackfillInProgress": "Importing your Fitbit history in the background…", + "fitbitOauthConnected": "Fitbit connected — the first sync starts in the background.", + "fitbitOauthFailed": "Fitbit connection failed", "fitbitOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", - "state": "The response from Google did not match any open connection request. Please start the connection again.", + "state": "The response from Fitbit did not match any open connection request. Please start the connection again.", "expired": "The connection request expired before it completed. Please start the connection again.", "cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.", - "nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.", - "nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.", - "token": "The token exchange with Google failed. Please start the connection again.", + "nocode": "Fitbit did not return an authorization code. Try again and allow access in the Fitbit dialog.", + "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", + "token": "The token exchange with Fitbit failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again." }, "integrations": { diff --git a/messages/es.json b/messages/es.json index 4f7cdf34a..7113cf9a5 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4454,9 +4454,9 @@ "token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit y Pixel", - "fitbitDescription": "Conecta Google Health para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.", + "fitbitDescription": "Conecta Fitbit para sincronizar tus datos de Fitbit y Pixel Watch — peso, frecuencia cardíaca y más.", "fitbitExperimentalBadge": "Experimental", "fitbitCredentials": "Credenciales de API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sincronizar ahora", "fitbitFullSync": "Sincronizar todos los datos", "fitbitFullSyncTitle": "¿Sincronización completa?", - "fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Google Health. Esto puede tardar un tiempo según tu historial.", + "fitbitFullSyncDescription": "Se sincronizará por completo todo el historial disponible de Fitbit. Esto puede tardar un tiempo según tu historial.", "fitbitSyncResult": "{count} mediciones sincronizadas", "fitbitSyncFailed": "Error de sincronización", "fitbitSynchronize": "Sincronizar", "fitbitDisconnect": "Desconectar", - "fitbitDisconnectTitle": "¿Desconectar Google Health?", - "fitbitDisconnectDescription": "Se desconectará la conexión con Google Health. Los datos sincronizados previamente se conservarán.", - "fitbitConnect": "Conectar con Google Health", - "fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.", - "fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…", - "fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.", - "fitbitOauthFailed": "Error al conectar con Google Health", + "fitbitDisconnectTitle": "¿Desconectar Fitbit?", + "fitbitDisconnectDescription": "Se desconectará la conexión con Fitbit. Los datos sincronizados previamente se conservarán.", + "fitbitConnect": "Conectar con Fitbit", + "fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Fitbit.", + "fitbitBackfillInProgress": "Importando tu historial de Fitbit en segundo plano…", + "fitbitOauthConnected": "Fitbit conectado: la primera sincronización empieza en segundo plano.", + "fitbitOauthFailed": "Error al conectar con Fitbit", "fitbitOauthError": { "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", - "state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", + "state": "La respuesta de Fitbit no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.", "expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.", "cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.", - "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.", - "nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.", - "token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.", + "nocode": "Fitbit no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Fitbit.", + "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", + "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." }, "integrations": { diff --git a/messages/fr.json b/messages/fr.json index e669ee63e..80cc9e0ea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4454,9 +4454,9 @@ "token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit et Pixel", - "fitbitDescription": "Connecte Google Health pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.", + "fitbitDescription": "Connecte Fitbit pour synchroniser tes données Fitbit et Pixel Watch — poids, fréquence cardiaque et plus.", "fitbitExperimentalBadge": "Expérimental", "fitbitCredentials": "Identifiants d'API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Synchroniser maintenant", "fitbitFullSync": "Tout synchroniser", "fitbitFullSyncTitle": "Synchronisation complète ?", - "fitbitFullSyncDescription": "Tout l'historique Google Health disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.", + "fitbitFullSyncDescription": "Tout l'historique Fitbit disponible sera entièrement synchronisé. Cela peut prendre un certain temps selon votre historique.", "fitbitSyncResult": "{count} mesures synchronisées", "fitbitSyncFailed": "Échec de la synchronisation", "fitbitSynchronize": "Synchroniser", "fitbitDisconnect": "Déconnecter", - "fitbitDisconnectTitle": "Déconnecter Google Health ?", - "fitbitDisconnectDescription": "La connexion à Google Health sera déconnectée. Les données déjà synchronisées seront conservées.", - "fitbitConnect": "Se connecter à Google Health", - "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.", - "fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…", - "fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.", - "fitbitOauthFailed": "Échec de la connexion à Google Health", + "fitbitDisconnectTitle": "Déconnecter Fitbit ?", + "fitbitDisconnectDescription": "La connexion à Fitbit sera déconnectée. Les données déjà synchronisées seront conservées.", + "fitbitConnect": "Se connecter à Fitbit", + "fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Fitbit.", + "fitbitBackfillInProgress": "Importation de votre historique Fitbit en arrière-plan…", + "fitbitOauthConnected": "Fitbit connecté — la première synchronisation démarre en arrière-plan.", + "fitbitOauthFailed": "Échec de la connexion à Fitbit", "fitbitOauthError": { "csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.", - "state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", + "state": "La réponse de Fitbit ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.", "expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.", "cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.", - "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.", - "nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.", - "token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.", + "nocode": "Fitbit n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Fitbit.", + "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", + "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." }, "integrations": { diff --git a/messages/it.json b/messages/it.json index e68135dfa..86e56afcd 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4454,9 +4454,9 @@ "token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit e Pixel", - "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.", + "fitbitDescription": "Collega Fitbit per sincronizzare i dati di Fitbit e Pixel Watch — peso, frequenza cardiaca e altro.", "fitbitExperimentalBadge": "Sperimentale", "fitbitCredentials": "Credenziali API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Sincronizza ora", "fitbitFullSync": "Sincronizza tutti i dati", "fitbitFullSyncTitle": "Sincronizzazione completa?", - "fitbitFullSyncDescription": "Tutto lo storico disponibile di Google Health verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.", + "fitbitFullSyncDescription": "Tutto lo storico disponibile di Fitbit verrà sincronizzato completamente. L'operazione può richiedere del tempo a seconda del tuo storico.", "fitbitSyncResult": "{count} misurazioni sincronizzate", "fitbitSyncFailed": "Sincronizzazione non riuscita", "fitbitSynchronize": "Sincronizza", "fitbitDisconnect": "Disconnetti", - "fitbitDisconnectTitle": "Disconnettere Google Health?", - "fitbitDisconnectDescription": "La connessione a Google Health verrà disconnessa. I dati già sincronizzati saranno conservati.", - "fitbitConnect": "Connetti con Google Health", - "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.", - "fitbitBackfillInProgress": "Importazione dello storico Google Health in background…", - "fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.", - "fitbitOauthFailed": "Connessione a Google Health non riuscita", + "fitbitDisconnectTitle": "Disconnettere Fitbit?", + "fitbitDisconnectDescription": "La connessione a Fitbit verrà disconnessa. I dati già sincronizzati saranno conservati.", + "fitbitConnect": "Connetti con Fitbit", + "fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Fitbit.", + "fitbitBackfillInProgress": "Importazione dello storico Fitbit in background…", + "fitbitOauthConnected": "Fitbit collegato — la prima sincronizzazione parte in background.", + "fitbitOauthFailed": "Connessione a Fitbit non riuscita", "fitbitOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", - "state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", + "state": "La risposta di Fitbit non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.", "expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.", "cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.", - "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.", - "nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.", - "token": "Lo scambio del token con Google è fallito. Riavvia la connessione.", + "nocode": "Fitbit non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Fitbit.", + "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", + "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." }, "integrations": { diff --git a/messages/pl.json b/messages/pl.json index 54ae78cff..94eb6acf4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4454,9 +4454,9 @@ "token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, - "fitbit": "Google Health", + "fitbit": "Fitbit", "fitbitTag": "Fitbit i Pixel", - "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.", + "fitbitDescription": "Połącz Fitbit, aby synchronizować dane z Fitbit i Pixel Watch — wagę, tętno i więcej.", "fitbitExperimentalBadge": "Eksperymentalne", "fitbitCredentials": "Dane uwierzytelniające API", "fitbitClientId": "Client ID", @@ -4468,26 +4468,26 @@ "fitbitSync": "Synchronizuj teraz", "fitbitFullSync": "Synchronizuj wszystkie dane", "fitbitFullSyncTitle": "Pełna synchronizacja?", - "fitbitFullSyncDescription": "Cała dostępna historia Google Health zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.", + "fitbitFullSyncDescription": "Cała dostępna historia Fitbit zostanie w pełni zsynchronizowana. W zależności od historii może to chwilę potrwać.", "fitbitSyncResult": "Zsynchronizowano {count} pomiarów", "fitbitSyncFailed": "Synchronizacja nie powiodła się", "fitbitSynchronize": "Synchronizuj", "fitbitDisconnect": "Rozłącz", - "fitbitDisconnectTitle": "Rozłączyć Google Health?", - "fitbitDisconnectDescription": "Połączenie z Google Health zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", - "fitbitConnect": "Połącz z Google Health", - "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.", - "fitbitBackfillInProgress": "Importowanie historii Google Health w tle…", - "fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.", - "fitbitOauthFailed": "Połączenie z Google Health nie powiodło się", + "fitbitDisconnectTitle": "Rozłączyć Fitbit?", + "fitbitDisconnectDescription": "Połączenie z Fitbit zostanie rozłączone. Wcześniej zsynchronizowane dane zostaną zachowane.", + "fitbitConnect": "Połącz z Fitbit", + "fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Fitbit.", + "fitbitBackfillInProgress": "Importowanie historii Fitbit w tle…", + "fitbitOauthConnected": "Fitbit połączony — pierwsza synchronizacja startuje w tle.", + "fitbitOauthFailed": "Połączenie z Fitbit nie powiodło się", "fitbitOauthError": { "csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.", - "state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", + "state": "Odpowiedź z Fitbit nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.", "expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.", "cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.", - "nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.", - "nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.", - "token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.", + "nocode": "Fitbit nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Fitbit.", + "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", + "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." }, "integrations": { diff --git a/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql b/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql new file mode 100644 index 000000000..edb1e15e1 --- /dev/null +++ b/prisma/migrations/0191_v1200_fitbit_pkce_verifier/migration.sql @@ -0,0 +1,8 @@ +-- v1.20.0 — classic Fitbit Web API (api.fitbit.com) authorises with PKCE +-- (Authorization Code + S256). Carry the per-handshake `code_verifier` from the +-- connect route to the callback on the short-lived OAuth-state ledger row. +-- +-- Additive + nullable: a legacy in-flight row (or any non-PKCE path) simply +-- carries no verifier, so this is reversible and rerun-safe with no data loss. +-- `IF NOT EXISTS` guards a prod rerun. +ALTER TABLE "fitbit_oauth_states" ADD COLUMN IF NOT EXISTS "code_verifier" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bf098311f..e41c0f57f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2658,11 +2658,20 @@ model FitbitConnection { /// v1.12.0 — short-lived `(nonce → userId)` ledger backing the Fitbit OAuth /// state check, identical in shape + lifecycle to `WhoopOAuthState`. +/// +/// v1.20.0 — the classic Fitbit Web API (`api.fitbit.com`) authorises with PKCE +/// (Authorization Code + S256), so the connect route mints a `code_verifier` +/// alongside the nonce and stashes it here; the callback reads it back to present +/// on the token exchange. Nullable + additive: a legacy row (or a non-PKCE path) +/// simply carries no verifier. model FitbitOAuthState { - nonce String @id - userId String @map("user_id") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @map("expires_at") + nonce String @id + userId String @map("user_id") + /// PKCE code_verifier carried from connect to callback (v1.20.0). Null when no + /// PKCE handshake is in flight (legacy rows). + codeVerifier String? @map("code_verifier") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 95b83ce63..984698e3c 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -19,9 +19,9 @@ import { NextRequest, NextResponse } from "next/server"; import { timingSafeEqual } from "node:crypto"; /** - * OAuth callback from Google for Fitbit / Google Health (v1.12.0). Mirrors the - * WHOOP callback: the `state` param is a random base64url nonce keyed against - * the `FitbitOAuthState` ledger. The in-flight user is resolved via the row's + * OAuth callback from Fitbit (classic Web API, v1.20.0). Mirrors the WHOOP + * callback: the `state` param is a random base64url nonce keyed against the + * `FitbitOAuthState` ledger. The in-flight user is resolved via the row's * `userId` (never parsed from the cookie value) and cross-checked against the * session. The row is consumed (deleted) atomically on every exit branch so a * replay of the same nonce fails the second time. @@ -31,9 +31,12 @@ import { timingSafeEqual } from "node:crypto"; * nonce already consumed), `expired` (valid row, TTL elapsed), and `cross_user` * (valid row, session userId mismatch). * - * On success: exchange the code, fetch the Google Health profile for the - * external `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any - * prior reauth state, and enqueue the self-converging history backfill. + * v1.20.0 — the deleted ledger row carries the PKCE `code_verifier` minted at + * connect; it is presented on the token exchange. + * + * On success: exchange the code, fetch the Fitbit profile for the external + * `fitbitUserId`, persist the encrypted `FitbitConnection`, clear any prior + * reauth state, and enqueue the self-converging history backfill. */ const ERR = (reason: string) => NextResponse.redirect( @@ -70,7 +73,11 @@ export const GET = apiHandler(async (request: NextRequest) => { // payload. P2025 means the nonce was already consumed (replay) or never // existed. Atomic at the Postgres row level: two concurrent callbacks with the // same nonce can't both pass before either deletes. - let stateRow: { userId: string; expiresAt: Date } | null = null; + let stateRow: { + userId: string; + expiresAt: Date; + codeVerifier: string | null; + } | null = null; try { stateRow = await prisma.fitbitOAuthState.delete({ where: { nonce: state }, @@ -102,19 +109,26 @@ export const GET = apiHandler(async (request: NextRequest) => { return ERR("nocode"); } + // The PKCE verifier minted at connect rides on the ledger row. Its absence + // means a malformed / legacy handshake — refuse rather than send a code + // exchange Fitbit will reject for a missing verifier. + if (!stateRow.codeVerifier) { + annotate({ meta: { reason: "no_verifier" } }); + return ERR("no_verifier"); + } + try { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return ERR("nocreds"); } - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, stateRow.codeVerifier, creds); - // The initial code exchange MUST return a refresh token (the connect route - // forces `prompt=consent` + `access_type=offline`). If Google ever omits it, - // refuse to persist — a stored empty refresh token silently bricks every - // future token refresh (the refresh POST then 400s and the connection dies - // the moment the access token expires). Error out cleanly so the user + // The initial code exchange MUST return a refresh token. If Fitbit ever + // omits it, refuse to persist — a stored empty refresh token silently bricks + // every future token refresh (the refresh POST then 400s and the connection + // dies the moment the access token expires). Error out cleanly so the user // re-consents rather than landing a dead connection. if (!tokens.refresh_token) { getEvent()?.addWarning( diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index 93a30fb5a..cf1e33ae6 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -3,7 +3,7 @@ import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; -import { getAuthorizationUrl } from "@/lib/fitbit/client"; +import { getAuthorizationUrl, generatePkcePair } from "@/lib/fitbit/client"; import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; import { FITBIT_OAUTH_STATE_COOKIE, @@ -14,8 +14,8 @@ import { NextRequest, NextResponse } from "next/server"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; /** - * Redirect the user to the Google OAuth consent page for Fitbit / Google Health - * (v1.12.0). + * Redirect the user to the Fitbit OAuth consent page (classic + * `www.fitbit.com/oauth2/authorize`, v1.20.0). * * Mirrors the WHOOP connect route: a fully-random base64url state nonce backed * by a 10-minute `FitbitOAuthState` ledger row carries the `(nonce → userId)` @@ -23,6 +23,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * land in request logs / network captures). The httpOnly + Secure cookie * carries JUST the nonce; the callback resolves the user via the row's `userId`. * + * v1.20.0 — the classic Fitbit Web API uses PKCE (S256). The route mints a + * `code_verifier`/`code_challenge` pair, stashes the verifier on the ledger row + * (server-side, never exposed to the browser), and sends the challenge to the + * authorize endpoint. The callback reads the verifier back to present on the + * token exchange. + * * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the entry point is a browser @@ -53,17 +59,19 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { return apiError( - "Please configure your Google OAuth Client ID and Client Secret in Settings first.", + "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", 400, ); } const nonce = mintFitbitOAuthStateNonce(); + const pkce = generatePkcePair(); try { await prisma.fitbitOAuthState.create({ data: { nonce, userId: user.id, + codeVerifier: pkce.verifier, expiresAt: new Date(Date.now() + FITBIT_OAUTH_STATE_TTL_MS), }, }); @@ -75,7 +83,7 @@ export const GET = apiHandler(async (req: NextRequest) => { ); } - const url = getAuthorizationUrl(nonce, creds); + const url = getAuthorizationUrl(nonce, creds, pkce.challenge); const response = NextResponse.redirect(url); response.cookies.set(FITBIT_OAUTH_STATE_COOKIE, nonce, { diff --git a/src/app/api/fitbit/sync/route.ts b/src/app/api/fitbit/sync/route.ts index 02c1b1b2d..5197ef8e8 100644 --- a/src/app/api/fitbit/sync/route.ts +++ b/src/app/api/fitbit/sync/route.ts @@ -6,17 +6,17 @@ import { syncUserFitbit } from "@/lib/fitbit/sync"; import { NextRequest } from "next/server"; /** - * Manually trigger a Fitbit / Google Health sync for the current user (v1.12.0). + * Manually trigger a Fitbit sync for the current user. * * Mirrors the WHOOP manual-sync route: incremental by default, full history when * `{ fullSync: true }` is posted. * - * v1.12.1 — rate-limited (M-1). Every sibling Fitbit route (connect / test / - * resume) is limited; this one was the outlier, while `{ fullSync: true }` - * drives four paginated Google walkers (each capped at 1000 pages). A tight - * loop or a stolen native token could pin Prisma + the Google Health quota. - * A baseline 5/60s bucket (matching the test route) gates the route, and the - * expensive `fullSync` path carries a tighter 1/hour bucket of its own. + * Rate-limited: every sibling Fitbit route (connect / test / resume) is limited; + * this one was the outlier, while `{ fullSync: true }` drives four resource syncs + * over the classic Web API's tight 150 req/h-per-user budget. A tight loop or a + * stolen native token could exhaust that budget and pin Prisma. A baseline 5/60s + * bucket (matching the test route) gates the route, and the expensive `fullSync` + * path carries a tighter 1/hour bucket of its own. */ export const POST = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); diff --git a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts index bef0ba2ba..eade1195d 100644 --- a/src/app/api/integrations/fitbit/test/__tests__/route.test.ts +++ b/src/app/api/integrations/fitbit/test/__tests__/route.test.ts @@ -206,7 +206,7 @@ describe("POST /api/integrations/fitbit/test", () => { new Response( JSON.stringify({ error: "Authorization: Bearer sk-secret echoed", - url: "https://health.googleapis.com/v4/users/me/profile", + url: "https://api.fitbit.com/1/user/-/profile.json", }), { status: 401 }, ), @@ -215,6 +215,5 @@ describe("POST /api/integrations/fitbit/test", () => { const text = await response.text(); expect(response.status).toBe(502); expect(text).not.toMatch(/sk-/); - expect(text).not.toMatch(/health\.googleapis\.com/); }); }); diff --git a/src/app/api/integrations/fitbit/test/route.ts b/src/app/api/integrations/fitbit/test/route.ts index 899576742..b13e7dfbf 100644 --- a/src/app/api/integrations/fitbit/test/route.ts +++ b/src/app/api/integrations/fitbit/test/route.ts @@ -13,7 +13,7 @@ export const dynamic = "force-dynamic"; // Cheap authenticated probe — the single (non-paginated) profile endpoint the // Fitbit client already calls via `fetchProfile`. // Base from `src/lib/fitbit/client.ts` (`FITBIT_API_BASE`). -const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/users/me/profile`; +const FITBIT_PROFILE_URL = `${FITBIT_API_BASE}/1/user/-/profile.json`; const TIMEOUT_MS = 5_000; type CategorisedError = { code: string; message: string }; @@ -22,24 +22,24 @@ function categoriseHttpStatus(status: number): CategorisedError { if (status === 401 || status === 403) { return { code: "credentials_rejected", - message: "Google Health rejected the credentials", + message: "Fitbit rejected the credentials", }; } if (status === 429) { return { code: "rate_limited", - message: "Google Health rate-limited the request", + message: "Fitbit rate-limited the request", }; } if (status >= 500) { return { code: "upstream_error", - message: "Google Health returned a server error", + message: "Fitbit returned a server error", }; } return { code: "connection_failed", - message: "Google Health connection failed", + message: "Fitbit connection failed", }; } @@ -57,7 +57,7 @@ export const POST = apiHandler(async (request: NextRequest) => { const tokenInfo = await getValidToken(user.id); if (!tokenInfo) { - return apiError("Google Health not connected", 422, { + return apiError("Fitbit not connected", 422, { errorCode: "not_configured", }); } @@ -115,9 +115,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); return apiError( - isAbort - ? "Google Health request timed out" - : "Google Health connection failed", + isAbort ? "Fitbit request timed out" : "Fitbit connection failed", 502, { errorCode: code }, ); diff --git a/src/lib/fitbit/__tests__/client.test.ts b/src/lib/fitbit/__tests__/client.test.ts index 04d215110..23bbf4081 100644 --- a/src/lib/fitbit/__tests__/client.test.ts +++ b/src/lib/fitbit/__tests__/client.test.ts @@ -1,12 +1,14 @@ +import { createHash } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { FITBIT_API_BASE, - FITBIT_DATA_TYPES, FITBIT_FIELD_MAP, FITBIT_OAUTH_SCOPE, exchangeCode, - fetchDataPoints, - fetchProfile, + fetchSleepRange, + fetchSpo2Range, + fetchWeightRange, + generatePkcePair, getAuthorizationUrl, mapActiveCalories, mapBodyFat, @@ -15,14 +17,17 @@ import { mapFitbitSportType, mapFloors, mapHeartRateVariability, - mapHeightCm, mapOxygenSaturation, + mapRespiratoryRate, mapRestingHeartRate, mapSleepSession, mapSteps, mapVo2Max, mapWeight, mapWorkout, + parseVo2Max, + readActivityList, + readSleepSessions, refreshAccessToken, resolveFitbitUserId, } from "../client"; @@ -44,8 +49,7 @@ function installFetchMock(pages: Array<{ status: number; body: unknown }>) { return fetchMock; } -// v1.12.1 — the redirect_uri allowlist (M-5) requires a configured, absolute -// https app origin. Pin one for the OAuth-handshake cases. +// The redirect_uri allowlist requires a configured, absolute https app origin. const PRIOR_APP_URL = process.env.NEXT_PUBLIC_APP_URL; beforeEach(() => { process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; @@ -58,48 +62,61 @@ afterEach(() => { vi.restoreAllMocks(); }); +describe("generatePkcePair", () => { + it("mints a high-entropy verifier and the matching S256 challenge", () => { + const { verifier, challenge } = generatePkcePair(); + // 64 random bytes → 86 base64url chars; inside Fitbit's 43–128 range. + expect(verifier.length).toBeGreaterThanOrEqual(43); + expect(verifier.length).toBeLessThanOrEqual(128); + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/); + // challenge = BASE64URL(SHA256(verifier)). + const expected = createHash("sha256").update(verifier).digest("base64url"); + expect(challenge).toBe(expected); + }); + + it("mints a fresh pair each call", () => { + expect(generatePkcePair().verifier).not.toBe(generatePkcePair().verifier); + }); +}); + describe("getAuthorizationUrl", () => { - it("builds the Google authorize URL with offline access + consent prompt", () => { - const url = getAuthorizationUrl("nonce123", CREDS); - expect(url).toContain("accounts.google.com/o/oauth2/v2/auth"); + it("builds the classic Fitbit authorize URL with PKCE S256", () => { + const url = getAuthorizationUrl("nonce123", CREDS, "chal-xyz"); + expect(url).toContain("www.fitbit.com/oauth2/authorize"); expect(url).toContain("response_type=code"); expect(url).toContain("client_id=cid"); expect(url).toContain("state=nonce123"); - expect(url).toContain("access_type=offline"); - expect(url).toContain("prompt=consent"); - // URLSearchParams encodes the space-separated scope; compare the parsed - // `scope` param back to the canonical constant. - const scope = new URL(url).searchParams.get("scope"); - expect(scope).toBe(FITBIT_OAUTH_SCOPE); - expect(FITBIT_OAUTH_SCOPE).toContain("googlehealth.profile.readonly"); + const parsed = new URL(url); + expect(parsed.searchParams.get("code_challenge")).toBe("chal-xyz"); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(parsed.searchParams.get("scope")).toBe(FITBIT_OAUTH_SCOPE); }); - it("requests exactly the four launch Restricted read bundles", () => { + it("requests the classic self-serve scopes and omits temperature", () => { const scopes = FITBIT_OAUTH_SCOPE.split(" "); - expect(scopes).toHaveLength(4); - expect(scopes).toContain( - "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", - ); - expect(scopes).toContain( - "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", - ); - expect(scopes).toContain( - "https://www.googleapis.com/auth/googlehealth.sleep.readonly", - ); - // ECG / nutrition / location are deliberately omitted from the launch set. - expect(FITBIT_OAUTH_SCOPE).not.toContain("ecg"); + expect(scopes).toContain("activity"); + expect(scopes).toContain("heartrate"); + expect(scopes).toContain("sleep"); + expect(scopes).toContain("weight"); + expect(scopes).toContain("oxygen_saturation"); + expect(scopes).toContain("respiratory_rate"); + expect(scopes).toContain("cardio_fitness"); + expect(scopes).toContain("profile"); + // temperature deliberately omitted — the classic skin-temp reading is a + // baseline delta with no honest absolute slot. + expect(FITBIT_OAUTH_SCOPE).not.toContain("temperature"); expect(FITBIT_OAUTH_SCOPE).not.toContain("nutrition"); expect(FITBIT_OAUTH_SCOPE).not.toContain("location"); }); it("carries the configured app origin in the redirect_uri", () => { - const url = getAuthorizationUrl("nonce123", CREDS); + const url = getAuthorizationUrl("nonce123", CREDS, "c"); const redirect = new URL(url).searchParams.get("redirect_uri"); expect(redirect).toBe("https://app.example.test/api/fitbit/callback"); }); }); -describe("redirect_uri allowlist (M-5)", () => { +describe("redirect_uri allowlist", () => { const PRIOR_REDIRECT = process.env.FITBIT_REDIRECT_URI; afterEach(() => { if (PRIOR_REDIRECT === undefined) delete process.env.FITBIT_REDIRECT_URI; @@ -108,12 +125,12 @@ describe("redirect_uri allowlist (M-5)", () => { it("rejects a non-https app origin", () => { process.env.NEXT_PUBLIC_APP_URL = "http://app.example.test"; - expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/must be https/); + expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow(/must be https/); }); it("allows http on localhost (dev)", () => { process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; - const url = getAuthorizationUrl("n", CREDS); + const url = getAuthorizationUrl("n", CREDS, "c"); expect(new URL(url).searchParams.get("redirect_uri")).toBe( "http://localhost:3000/api/fitbit/callback", ); @@ -121,7 +138,7 @@ describe("redirect_uri allowlist (M-5)", () => { it("rejects an explicit redirect that targets the wrong path", () => { process.env.FITBIT_REDIRECT_URI = "https://app.example.test/evil"; - expect(() => getAuthorizationUrl("n", CREDS)).toThrow( + expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow( /must target \/api\/fitbit\/callback/, ); }); @@ -130,80 +147,72 @@ describe("redirect_uri allowlist (M-5)", () => { process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; process.env.FITBIT_REDIRECT_URI = "https://attacker.example.test/api/fitbit/callback"; - expect(() => getAuthorizationUrl("n", CREDS)).toThrow(/does not match/); - }); - - it("accepts an explicit same-origin redirect", () => { - process.env.NEXT_PUBLIC_APP_URL = "https://app.example.test"; - process.env.FITBIT_REDIRECT_URI = - "https://app.example.test/api/fitbit/callback"; - const url = getAuthorizationUrl("n", CREDS); - expect(new URL(url).searchParams.get("redirect_uri")).toBe( - "https://app.example.test/api/fitbit/callback", + expect(() => getAuthorizationUrl("n", CREDS, "c")).toThrow( + /does not match/, ); }); }); describe("token exchange + refresh", () => { - it("exchanges an authorization code for a token pair via Basic auth", async () => { + it("exchanges an authorization code WITH the PKCE verifier via Basic auth", async () => { const fetchMock = installFetchMock([ { status: 200, body: { access_token: "at", refresh_token: "rt", - expires_in: 3600, - scope: "https://www.googleapis.com/auth/googlehealth.sleep.readonly", + expires_in: 28800, + scope: "activity sleep weight", + user_id: "ABC123", }, }, ]); - const tok = await exchangeCode("code", CREDS); + const tok = await exchangeCode("code", "verifier-123", CREDS); expect(tok.access_token).toBe("at"); expect(tok.refresh_token).toBe("rt"); - expect(tok.expires_in).toBe(3600); + expect(tok.expires_in).toBe(28800); const [url, init] = fetchMock.mock.calls[0] as unknown as [ string, { headers: Record; body: string }, ]; - expect(url).toContain("oauth2.googleapis.com/token"); + expect(url).toContain("api.fitbit.com/oauth2/token"); expect(init.body).toContain("grant_type=authorization_code"); - // Confidential client credentials ride in the Basic-auth header. + expect(init.body).toContain("code_verifier=verifier-123"); const expected = `Basic ${Buffer.from("cid:csecret").toString("base64")}`; expect(init.headers.Authorization).toBe(expected); }); - it("refreshes WITHOUT re-sending scope (Google preserves the grant)", async () => { + it("refreshes and returns the ROTATED refresh token (classic rotates)", async () => { const fetchMock = installFetchMock([ { status: 200, - body: { access_token: "at2", expires_in: 3600 }, + body: { + access_token: "at2", + refresh_token: "rt2", + expires_in: 28800, + }, }, ]); const tok = await refreshAccessToken("rt1", CREDS); expect(tok.access_token).toBe("at2"); + // Classic Fitbit rotates: a fresh refresh token is returned every time. + expect(tok.refresh_token).toBe("rt2"); const [, init] = fetchMock.mock.calls[0] as unknown as [ string, { body: string }, ]; expect(init.body).toContain("grant_type=refresh_token"); expect(init.body).toContain("refresh_token=rt1"); - // Google preserves the original grant — no scope param is re-sent. + // The grant's scope is preserved — no scope param re-sent. expect(init.body).not.toContain("scope="); }); - it("returns an absent refresh_token unchanged (Google does not rotate)", async () => { - installFetchMock([ - { status: 200, body: { access_token: "at3", expires_in: 3600 } }, - ]); - const tok = await refreshAccessToken("rt1", CREDS); - expect(tok.access_token).toBe("at3"); - expect(tok.refresh_token).toBeUndefined(); - }); - it("throws a classified FitbitApiError on a 401 token response", async () => { - installFetchMock([{ status: 401, body: { error: "invalid_grant" } }]); - await expect(exchangeCode("bad", CREDS)).rejects.toMatchObject({ + installFetchMock([ + { status: 401, body: { errors: [{ errorType: "invalid_grant" }] } }, + ]); + await expect(exchangeCode("bad", "v", CREDS)).rejects.toMatchObject({ name: "FitbitApiError", classification: "reauth_required", }); @@ -211,131 +220,51 @@ describe("token exchange + refresh", () => { }); describe("fetchProfile + resolveFitbitUserId", () => { - it("fetches the profile from the Google Health base", async () => { - const fetchMock = installFetchMock([ - { status: 200, body: { name: "users/abc123" } }, - ]); - const profile = await fetchProfile("at"); - expect(profile.name).toBe("users/abc123"); - const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(url).toBe(`${FITBIT_API_BASE}/users/me/profile`); - }); - - it("throws a classified error on a 403 profile read", async () => { - installFetchMock([{ status: 403, body: {} }]); - await expect(fetchProfile("at")).rejects.toMatchObject({ - name: "FitbitApiError", - classification: "reauth_required", - }); - }); - - it("resolves the external user id from a users/{id} resource name", () => { - expect(resolveFitbitUserId({ name: "users/abc123" })).toBe("abc123"); - }); - - it("falls back to a bare id, then to 'me'", () => { - expect(resolveFitbitUserId({ id: "xyz" })).toBe("xyz"); + it("resolves the external user id from the profile encodedId", () => { + expect(resolveFitbitUserId({ user: { encodedId: "ABC123" } })).toBe( + "ABC123", + ); expect(resolveFitbitUserId({})).toBe("me"); + expect(resolveFitbitUserId({ user: {} })).toBe("me"); }); }); -describe("fetchDataPoints", () => { - it("encodes the data type kebab-case in the path and snake_case in the filter, walking nextPageToken", async () => { - const fetchMock = installFetchMock([ - { - status: 200, - body: { - dataPoints: [{ a: 1 }], - nextPageToken: "tok2", - }, - }, - { status: 200, body: { dataPoints: [{ a: 2 }] } }, - ]); - - const points = await fetchDataPoints( - FITBIT_DATA_TYPES.bodyFat, +describe("date-range fetchers", () => { + it("encodes the weight endpoint path with the metric Accept-Language", async () => { + const fetchMock = installFetchMock([{ status: 200, body: { weight: [] } }]); + await fetchWeightRange( "at", - "fetchBodyFat", - { start: new Date("2026-01-01T00:00:00.000Z") }, + new Date("2026-05-01T00:00:00Z"), + new Date("2026-05-30T00:00:00Z"), ); - expect(points).toHaveLength(2); - - const [url1] = fetchMock.mock.calls[0] as unknown as [string]; - // Path uses kebab-case; filter uses snake_case. - expect(url1).toContain( - `${FITBIT_API_BASE}/users/me/dataTypes/body-fat/dataPoints`, + const [url, init] = fetchMock.mock.calls[0] as unknown as [ + string, + { headers: Record }, + ]; + expect(url).toBe( + `${FITBIT_API_BASE}/1/user/-/body/log/weight/date/2026-05-01/2026-05-30.json`, ); - const parsed = new URL(url1); - expect(parsed.searchParams.get("filter")).toBe( - 'body_fat.sample_time.physical_time >= "2026-01-01T00:00:00.000Z"', - ); - - // Second page carries the pageToken from the first response. - const [url2] = fetchMock.mock.calls[1] as unknown as [string]; - expect(new URL(url2).searchParams.get("pageToken")).toBe("tok2"); + expect(init.headers["Accept-Language"]).toBe("en_GB"); + expect(init.headers.Authorization).toBe("Bearer at"); }); - it("filters daily summaries on the civil date, not the sample time", async () => { - const fetchMock = installFetchMock([ - { status: 200, body: { dataPoints: [] } }, - ]); - await fetchDataPoints( - FITBIT_DATA_TYPES.restingHeartRate, + it("encodes the sleep endpoint on the 1.2 version path", async () => { + const fetchMock = installFetchMock([{ status: 200, body: { sleep: [] } }]); + await fetchSleepRange( "at", - "fetchRhr", - { start: new Date("2026-03-04T10:00:00.000Z") }, + new Date("2026-05-01T00:00:00Z"), + new Date("2026-05-30T00:00:00Z"), ); const [url] = fetchMock.mock.calls[0] as unknown as [string]; - expect(new URL(url).searchParams.get("filter")).toBe( - 'daily_resting_heart_rate.date >= "2026-03-04"', + expect(url).toBe( + `${FITBIT_API_BASE}/1.2/user/-/sleep/date/2026-05-01/2026-05-30.json`, ); }); - it("filters INTERVAL types on interval.start_time, not a sample_time or a bare date", async () => { - // steps / distance / calories / floors, sleep and exercise are INTERVAL data - // types — Google anchors them on `interval.start_time`. A sample_time filter - // 400s/empties for these and stalls incremental sync. - const stepsMock = installFetchMock([ - { status: 200, body: { dataPoints: [] } }, - ]); - await fetchDataPoints(FITBIT_DATA_TYPES.steps, "at", "fetchSteps", { - start: new Date("2026-03-04T10:00:00.000Z"), - }); - expect( - new URL( - (stepsMock.mock.calls[0] as unknown as [string])[0], - ).searchParams.get("filter"), - ).toBe('steps.interval.start_time >= "2026-03-04T10:00:00.000Z"'); - - const sleepMock = installFetchMock([ - { status: 200, body: { dataPoints: [] } }, - ]); - await fetchDataPoints(FITBIT_DATA_TYPES.sleep, "at", "fetchSleep", { - start: new Date("2026-03-04T10:00:00.000Z"), - }); - expect( - new URL( - (sleepMock.mock.calls[0] as unknown as [string])[0], - ).searchParams.get("filter"), - ).toBe('sleep.interval.start_time >= "2026-03-04T10:00:00.000Z"'); - - const exMock = installFetchMock([ - { status: 200, body: { dataPoints: [] } }, - ]); - await fetchDataPoints(FITBIT_DATA_TYPES.exercise, "at", "fetchExercise", { - start: new Date("2026-03-04T10:00:00.000Z"), - }); - expect( - new URL( - (exMock.mock.calls[0] as unknown as [string])[0], - ).searchParams.get("filter"), - ).toBe('exercise.interval.start_time >= "2026-03-04T10:00:00.000Z"'); - }); - - it("throws a classified FitbitApiError on a non-2xx page", async () => { + it("throws a classified FitbitApiError on a 403 page", async () => { installFetchMock([{ status: 403, body: {} }]); await expect( - fetchDataPoints(FITBIT_DATA_TYPES.weight, "at", "fetchWeight"), + fetchSpo2Range("at", new Date(), new Date()), ).rejects.toMatchObject({ name: "FitbitApiError", classification: "reauth_required", @@ -344,150 +273,145 @@ describe("fetchDataPoints", () => { }); }); -describe("metric mappers", () => { - it("maps weight in kg with a stable sample-time anchor + field-tag externalId", () => { - const point = { - weight: { - kilograms: 81.456, - sample_time: { physical_time: "2026-05-10T07:30:00.000Z" }, - }, - }; - const out = mapWeight(point); +describe("metric mappers (classic shapes)", () => { + it("maps weight in kg, anchoring the externalId on the logId", () => { + const out = mapWeight({ + weight: [ + { + date: "2026-05-10", + time: "07:30:00", + weight: 81.46, + logId: 1551080804000, + source: "Aria", + }, + ], + }); expect(out).toHaveLength(1); - expect(out[0]).toMatchObject({ - type: "WEIGHT", - value: 81.46, - unit: "kg", - }); + expect(out[0]).toMatchObject({ type: "WEIGHT", value: 81.46, unit: "kg" }); expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T07:30:00.000Z"); - // externalId field-tag: :; anchor is the sample time ISO string. - expect(out[0]!.fieldTag).toBe("2026-05-10T07:30:00.000Z:weight"); + expect(out[0]!.fieldTag).toBe("1551080804000:weight"); }); - it("maps body fat percentage", () => { - const out = mapBodyFat({ body_fat: { percentage: 18.2 } }); - expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" }); - }); - - it("maps daily SpO2 with a civil-date anchor", () => { - const out = mapOxygenSaturation({ - daily_oxygen_saturation: { - average_percentage: 97.4, - date: { year: 2026, month: 5, day: 10 }, - }, + it("maps body fat percentage from the fat array", () => { + const out = mapBodyFat({ + fat: [{ date: "2026-05-10", time: "07:30:00", fat: 18.2, logId: 42 }], }); + expect(out[0]).toMatchObject({ type: "BODY_FAT", value: 18.2, unit: "%" }); + expect(out[0]!.fieldTag).toBe("42:body_fat"); + }); + + it("maps daily SpO2 from value.avg with a day-keyed externalId (bare array)", () => { + const out = mapOxygenSaturation([ + { dateTime: "2026-05-10", value: { avg: 97.4, min: 94, max: 100 } }, + ]); expect(out[0]).toMatchObject({ type: "OXYGEN_SATURATION", value: 97.4, unit: "%", }); - // Daily anchor is the civil date (YYYY-MM-DD), so a re-fetch of the same day - // overwrites in place. expect(out[0]!.fieldTag).toBe("2026-05-10:spo2"); }); - it("maps Fitbit HRV into the SDNN HEART_RATE_VARIABILITY slot, NOT HRV_RMSSD", () => { + it("maps HRV dailyRmssd into the canonical HEART_RATE_VARIABILITY slot", () => { const out = mapHeartRateVariability({ - daily_heart_rate_variability: { - rmssd_milliseconds: 42.7, - date: { year: 2026, month: 5, day: 10 }, - }, + hrv: [ + { dateTime: "2026-05-10", value: { dailyRmssd: 42.7, deepRmssd: 50 } }, + ], }); expect(out[0]!.type).toBe("HEART_RATE_VARIABILITY"); - expect(out[0]!.type).not.toBe("HRV_RMSSD"); expect(out[0]).toMatchObject({ value: 42.7, unit: "ms" }); + expect(out[0]!.fieldTag).toBe("2026-05-10:hrv"); }); - it("maps daily resting heart rate", () => { + it("maps resting HR from the activities-heart value.restingHeartRate", () => { const out = mapRestingHeartRate({ - daily_resting_heart_rate: { - beats_per_minute: 54, - date: { year: 2026, month: 5, day: 10 }, - }, + "activities-heart": [ + { dateTime: "2026-05-10", value: { restingHeartRate: 54 } }, + ], }); expect(out[0]).toMatchObject({ type: "RESTING_HEART_RATE", value: 54 }); }); - it("drops a garbage / non-positive reading rather than minting a row", () => { - expect(mapWeight({ weight: { kilograms: 0 } })).toHaveLength(0); - expect(mapWeight({ weight: { kilograms: Number.NaN } })).toHaveLength(0); - expect(mapWeight({})).toHaveLength(0); + it("maps respiratory rate from value.breathingRate", () => { + const out = mapRespiratoryRate({ + br: [{ dateTime: "2026-05-10", value: { breathingRate: 17.8 } }], + }); + expect(out[0]).toMatchObject({ + type: "RESPIRATORY_RATE", + value: 17.8, + unit: "breaths/min", + }); }); - it("resolves height to cm from either centimetres or metres, never as a Measurement", () => { - expect(mapHeightCm({ height: { centimeters: 178 } })).toBe(178); - expect(mapHeightCm({ height: { meters: 1.78 } })).toBe(178); - expect(mapHeightCm({ height: {} })).toBeNull(); + it("drops a garbage / non-positive / absent reading rather than minting a row", () => { + expect( + mapWeight({ weight: [{ date: "2026-05-10", weight: 0 }] }), + ).toHaveLength(0); + expect(mapWeight({ weight: [] })).toHaveLength(0); + expect(mapWeight({})).toHaveLength(0); + expect( + mapOxygenSaturation([{ dateTime: "2026-05-10", value: {} }]), + ).toHaveLength(0); }); }); -describe("FITBIT_FIELD_MAP ↔ FITBIT_DATA_TYPES casing parity", () => { - it("keeps the kebab-path / snake-filter pair consistent across both tables", () => { - for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) { - // Path is kebab-case, filter is snake_case — they must never carry the - // wrong separator (the design §A.1 casing gotcha). - expect(entry.path).not.toContain("_"); - expect(entry.filter).not.toContain("-"); - } - // Every FITBIT_DATA_TYPES entry has matching casing too. - for (const dt of Object.values(FITBIT_DATA_TYPES)) { - expect(dt.path).not.toContain("_"); - expect(dt.filter).not.toContain("-"); - } +describe("VO2 max", () => { + it("parses a single numeric string and a range midpoint", () => { + expect(parseVo2Max("45")).toBe(45); + expect(parseVo2Max("44-48")).toBe(46); + expect(parseVo2Max(50)).toBe(50); + expect(parseVo2Max("not-a-number")).toBeNull(); + }); + + it("maps cardioScore value.vo2Max (range → midpoint) with a day-keyed stats externalId", () => { + const out = mapVo2Max({ + cardioScore: [ + { dateTime: "2026-05-10", value: { vo2Max: "44-48" } }, + { dateTime: "2026-05-11", value: { vo2Max: "45" } }, + ], + }); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 46 }); + expect(out[0]!.cumulativeDaily).toBe(true); + expect(out[0]!.fieldTag).toBe("vo2_max:2026-05-10"); + expect(out[1]!.value).toBe(45); }); }); describe("activity mappers (cumulative daily)", () => { - // INTERVAL data types: Google buckets the daily total into an `interval` with - // a `start_time` (physical instant) — NOT a bare civil `date`. The mapper - // anchors measuredAt on `interval.start_time` and day-keys the externalId. - const interval = { start_time: "2026-05-10T00:00:00.000Z" }; - // A civil-only fallback shape (no physical start_time) still day-keys cleanly. - const civilInterval = { - civil_start_time: { year: 2026, month: 5, day: 10 }, - }; - - it("maps steps with a stats:-shaped daily externalId and preserves a zero", () => { - const out = mapSteps({ steps: { count: 8421, interval } }); - expect(out).toHaveLength(1); + it("maps steps from the string value, preserving a zero, with a day-keyed externalId", () => { + const out = mapSteps({ + "activities-steps": [ + { dateTime: "2026-05-10", value: "8421" }, + { dateTime: "2026-05-11", value: "0" }, + ], + }); + expect(out).toHaveLength(2); expect(out[0]).toMatchObject({ type: "ACTIVITY_STEPS", value: 8421, unit: "steps", cumulativeDaily: true, }); - // fieldTag carries :; the sync layer prefixes `stats:`. expect(out[0]!.fieldTag).toBe("steps:2026-05-10"); - // measuredAt anchored on the interval start instant. - expect(out[0]!.measuredAt.toISOString()).toBe("2026-05-10T00:00:00.000Z"); - - // The civil-only fallback day-keys the externalId at UTC midday. - const civil = mapSteps({ steps: { count: 100, interval: civilInterval } }); - expect(civil[0]!.fieldTag).toBe("steps:2026-05-10"); - - // A rest day records 0 steps — that is real data, not a gap. - const rest = mapSteps({ steps: { count: 0, interval } }); - expect(rest).toHaveLength(1); - expect(rest[0]!.value).toBe(0); + // A rest day of 0 steps is real data. + expect(out[1]!.value).toBe(0); }); - it("maps distance in metres, converting km → m when reported in km", () => { - expect( - mapDistance({ distance: { meters: 6200, interval } })[0], - ).toMatchObject({ + it("maps distance km → metres", () => { + const out = mapDistance({ + "activities-distance": [{ dateTime: "2026-05-10", value: "6.2" }], + }); + expect(out[0]).toMatchObject({ type: "WALKING_RUNNING_DISTANCE", value: 6200, unit: "m", }); - // km path multiplies by 1000. - expect( - mapDistance({ distance: { kilometers: 6.2, interval } })[0]!.value, - ).toBe(6200); }); it("maps the ACTIVE-calories portion into ACTIVE_ENERGY_BURNED", () => { const out = mapActiveCalories({ - active_calories: { active_kilocalories: 540, interval }, + "activities-activityCalories": [{ dateTime: "2026-05-10", value: "540" }], }); expect(out[0]).toMatchObject({ type: "ACTIVE_ENERGY_BURNED", @@ -497,201 +421,141 @@ describe("activity mappers (cumulative daily)", () => { }); it("maps floors and preserves a zero", () => { - expect(mapFloors({ floors: { count: 12, interval } })[0]).toMatchObject({ - type: "FLIGHTS_CLIMBED", - value: 12, - unit: "flights", + const out = mapFloors({ + "activities-floors": [ + { dateTime: "2026-05-10", value: "12" }, + { dateTime: "2026-05-11", value: "0" }, + ], }); - expect(mapFloors({ floors: { count: 0, interval } })[0]!.value).toBe(0); - }); - - it("maps VO2 max (strictly positive, daily latest-wins) and drops a zero", () => { - // VO2 max is a daily-summary metric (civil `date` anchor), not an interval. - const day = { year: 2026, month: 5, day: 10 }; - const out = mapVo2Max({ - vo2_max: { milliliters_per_kilogram_per_minute: 47.3, date: day }, - }); - expect(out[0]).toMatchObject({ type: "VO2_MAX", value: 47.3 }); - // VO2 max of 0 is garbage — dropped (unlike the running totals). - expect( - mapVo2Max({ - vo2_max: { milliliters_per_kilogram_per_minute: 0, date: day }, - }), - ).toHaveLength(0); + expect(out[0]).toMatchObject({ type: "FLIGHTS_CLIMBED", value: 12 }); + expect(out[1]!.value).toBe(0); }); }); -describe("sleep-stage mapping", () => { - it("harmonises Google stage labels onto the shared SleepStage enum", () => { +describe("sleep mapping (1.2 levels.data)", () => { + it("harmonises classic Fitbit level labels onto the shared SleepStage enum", () => { expect(mapFitbitSleepStage("light")).toBe("CORE"); // Fitbit light ↔ Apple core expect(mapFitbitSleepStage("deep")).toBe("DEEP"); expect(mapFitbitSleepStage("rem")).toBe("REM"); - expect(mapFitbitSleepStage("awake")).toBe("AWAKE"); expect(mapFitbitSleepStage("wake")).toBe("AWAKE"); + expect(mapFitbitSleepStage("awake")).toBe("AWAKE"); expect(mapFitbitSleepStage("restless")).toBe("AWAKE"); - expect(mapFitbitSleepStage("in-bed")).toBe("IN_BED"); expect(mapFitbitSleepStage("asleep")).toBe("ASLEEP"); - // Unknown / non-string → null (skipped, never mis-bucketed). expect(mapFitbitSleepStage("snoring")).toBeNull(); expect(mapFitbitSleepStage(42)).toBeNull(); }); - it("maps a session into per-SEGMENT SLEEP_DURATION rows with measuredAt = segment END (real timeline)", () => { - const session = { - sleep: { - startTime: "2026-05-10T22:00:00.000Z", - endTime: "2026-05-11T06:00:00.000Z", - segments: [ - { - stage: "light", - startTime: "2026-05-10T22:00:00.000Z", - endTime: "2026-05-10T22:30:00.000Z", + it("maps a session into per-SEGMENT rows with measuredAt = segment END (real timeline)", () => { + const sessions = readSleepSessions({ + sleep: [ + { + logId: 999, + startTime: "2026-05-10T22:00:00.000", + endTime: "2026-05-11T06:00:00.000", + levels: { + data: [ + { + dateTime: "2026-05-10T22:00:00.000", + level: "light", + seconds: 1800, + }, + { + dateTime: "2026-05-10T22:30:00.000", + level: "deep", + seconds: 3600, + }, + { + dateTime: "2026-05-10T23:30:00.000", + level: "rem", + seconds: 2700, + }, + ], }, - { - stage: "deep", - startTime: "2026-05-10T22:30:00.000Z", - endTime: "2026-05-10T23:30:00.000Z", - }, - { - stage: "light", - startTime: "2026-05-10T23:30:00.000Z", - endTime: "2026-05-11T00:15:00.000Z", - }, - { - stage: "rem", - startTime: "2026-05-11T00:15:00.000Z", - endTime: "2026-05-11T01:00:00.000Z", - }, - ], - }, - }; - const out = mapSleepSession(session); + }, + ], + }); + expect(sessions).toHaveLength(1); + const out = mapSleepSession(sessions[0]!); - // One row PER segment (no collapse) — 4 segments → 4 rows. - expect(out).toHaveLength(4); + // One row per segment. + expect(out).toHaveLength(3); expect(out.every((m) => m.type === "SLEEP_DURATION")).toBe(true); expect(out.every((m) => m.unit === "minutes")).toBe(true); - // Each row carries its OWN end instant + duration — a real clock-time - // timeline, not a stage collapse. - const core = out.filter((m) => m.sleepStage === "CORE"); - expect(core.map((m) => m.value)).toEqual([30, 45]); - expect(core.map((m) => m.measuredAt.toISOString())).toEqual([ - "2026-05-10T22:30:00.000Z", - "2026-05-11T00:15:00.000Z", - ]); + const core = out.find((m) => m.sleepStage === "CORE")!; + expect(core.value).toBe(30); // 1800s → 30 min + // measuredAt = segment START + seconds = END (local instant). + expect(core.measuredAt.getTime()).toBe( + new Date("2026-05-10T22:30:00.000").getTime(), + ); const deep = out.find((m) => m.sleepStage === "DEEP")!; expect(deep.value).toBe(60); - expect(deep.measuredAt.toISOString()).toBe("2026-05-10T23:30:00.000Z"); const rem = out.find((m) => m.sleepStage === "REM")!; expect(rem.value).toBe(45); - // Every fieldTag is distinct (session anchor + stage + segment index) so - // the several segments of one stage never collide under the dedup key. + // Every fieldTag distinct (logId anchor + stage + segment index). const tags = out.map((m) => m.fieldTag); expect(new Set(tags).size).toBe(tags.length); - expect(tags).toContain("2026-05-11T06:00:00.000Z:sleep_deep:1"); - }); - - it("anchors the session externalId on sleep.interval.end_time for an INTERVAL-shaped point", () => { - const out = mapSleepSession({ - sleep: { - interval: { - start_time: "2026-05-10T22:00:00.000Z", - end_time: "2026-05-11T06:00:00.000Z", - }, - segments: [ - { - stage: "deep", - startTime: "2026-05-10T22:30:00.000Z", - endTime: "2026-05-10T23:30:00.000Z", - }, - ], - }, - }); - const deep = out.find((m) => m.sleepStage === "DEEP"); - expect(deep!.fieldTag).toBe("2026-05-11T06:00:00.000Z:sleep_deep:0"); + expect(deep.fieldTag).toBe("999:sleep_deep:1"); }); it("yields nothing for a session with no parseable segments", () => { - expect(mapSleepSession({ sleep: {} })).toHaveLength(0); + expect(mapSleepSession({ levels: { data: [] } })).toHaveLength(0); expect(mapSleepSession({})).toHaveLength(0); }); }); -describe("workout mapping", () => { - it("maps an exercise session into a Workout shape with a stable externalId", () => { - const w = mapWorkout({ - exercise: { - session_id: "ex-123", - activity_type: "run", - startTime: "2026-05-10T07:00:00.000Z", - endTime: "2026-05-10T07:45:00.000Z", - active_kilocalories: 410, - distance: { meters: 7800 }, - average_heart_rate: { beats_per_minute: 148 }, - maximum_heart_rate: { beats_per_minute: 172 }, - }, +describe("workout mapping (activities list)", () => { + it("maps an activity-list entry into a Workout keyed on the logId", () => { + const entries = readActivityList({ + activities: [ + { + logId: 123456, + activityName: "Run", + startTime: "2026-05-10T07:00:00.000", + duration: 45 * 60 * 1000, + calories: 410, + distance: 7.8, // km + averageHeartRate: 148, + }, + ], }); + expect(entries).toHaveLength(1); + const w = mapWorkout(entries[0]!); expect(w).not.toBeNull(); expect(w).toMatchObject({ - externalId: "ex-123", + externalId: "123456", sportType: "running", durationSec: 45 * 60, totalEnergyKcal: 410, - totalDistanceM: 7800, + totalDistanceM: 7800, // km → m avgHeartRate: 148, - maxHeartRate: 172, + maxHeartRate: null, + minHeartRate: null, }); - expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z"); + expect(w!.startedAt.getTime()).toBe( + new Date("2026-05-10T07:00:00.000").getTime(), + ); }); it("falls back to a start-anchored externalId + 'other' sport when fields are absent", () => { const w = mapWorkout({ - exercise: { - startTime: "2026-05-10T07:00:00.000Z", - endTime: "2026-05-10T07:30:00.000Z", - }, + startTime: "2026-05-10T07:00:00.000", + duration: 30 * 60 * 1000, }); - expect(w!.externalId).toBe("exercise:2026-05-10T07:00:00.000Z"); + expect(w!.externalId).toMatch(/^exercise:/); expect(w!.sportType).toBe("other"); expect(w!.totalEnergyKcal).toBeNull(); expect(w!.avgHeartRate).toBeNull(); }); - it("reads the start/end from exercise.interval for an INTERVAL-shaped point", () => { - const w = mapWorkout({ - exercise: { - session_id: "ex-iv", - activity_type: "run", - interval: { - start_time: "2026-05-10T07:00:00.000Z", - end_time: "2026-05-10T07:45:00.000Z", - }, - }, - }); - expect(w).not.toBeNull(); - expect(w!.startedAt.toISOString()).toBe("2026-05-10T07:00:00.000Z"); - expect(w!.endedAt.toISOString()).toBe("2026-05-10T07:45:00.000Z"); - expect(w!.durationSec).toBe(45 * 60); - }); - - it("returns null for a session with no usable time span", () => { - expect( - mapWorkout({ exercise: { startTime: "2026-05-10T07:00:00.000Z" } }), - ).toBeNull(); - expect( - mapWorkout({ - exercise: { - startTime: "2026-05-10T07:30:00.000Z", - endTime: "2026-05-10T07:00:00.000Z", // end before start - }, - }), - ).toBeNull(); + it("returns null for an entry with no usable time span", () => { + expect(mapWorkout({ startTime: "2026-05-10T07:00:00.000" })).toBeNull(); + expect(mapWorkout({ duration: 1000 })).toBeNull(); expect(mapWorkout({})).toBeNull(); }); - it("resolves Google activity types to canonical sport labels", () => { + it("resolves activity names to canonical sport labels", () => { expect(mapFitbitSportType("Walk")).toBe("walking"); expect(mapFitbitSportType("biking")).toBe("cycling"); expect(mapFitbitSportType("weights")).toBe("strength"); @@ -699,3 +563,15 @@ describe("workout mapping", () => { expect(mapFitbitSportType("")).toBe("other"); }); }); + +describe("FITBIT_FIELD_MAP", () => { + it("documents a type + unit for every launch metric", () => { + for (const [, entry] of Object.entries(FITBIT_FIELD_MAP)) { + expect(typeof entry.type).toBe("string"); + expect(entry.type.length).toBeGreaterThan(0); + expect(typeof entry.unit).toBe("string"); + } + expect(FITBIT_FIELD_MAP.weight!.type).toBe("WEIGHT"); + expect(FITBIT_FIELD_MAP.vo2Max!.type).toBe("VO2_MAX"); + }); +}); diff --git a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts index f9c3f1d16..e138ab964 100644 --- a/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts +++ b/src/lib/fitbit/__tests__/sync-user-fitbit.test.ts @@ -241,19 +241,26 @@ describe("syncUserFitbit — single cycle-wide watermark", () => { expect(recordSyncSuccess).not.toHaveBeenCalled(); }); - it("passes fullSync + deferRollup through with an undefined start on a backfill cycle", async () => { + it("passes fullSync + deferRollup through with a bounded backfill start", async () => { syncUserMetrics.mockResolvedValue(2); await syncUserFitbit("user1", { fullSync: true }); const opts = syncUserMetrics.mock.calls[0]![1] as { start?: Date; + end?: Date; fullSync?: boolean; deferRollup?: boolean; }; expect(opts.fullSync).toBe(true); - // Full sync walks deep history with no lower bound. - expect(opts.start).toBeUndefined(); + // The classic Web API range caps mean the backfill walks a bounded horizon + // (~1 year back), not an unbounded deep history. + expect(opts.start).toBeInstanceOf(Date); + expect(opts.end).toBeInstanceOf(Date); + const spanDays = + (opts.end!.getTime() - opts.start!.getTime()) / (24 * 60 * 60 * 1000); + expect(spanDays).toBeGreaterThan(360); + expect(spanDays).toBeLessThan(370); // The backfill defers the per-write rollup hook to one end-of-cycle pass. expect(opts.deferRollup).toBe(true); }); diff --git a/src/lib/fitbit/__tests__/sync.test.ts b/src/lib/fitbit/__tests__/sync.test.ts index 38dc851c4..58be146a8 100644 --- a/src/lib/fitbit/__tests__/sync.test.ts +++ b/src/lib/fitbit/__tests__/sync.test.ts @@ -1,7 +1,8 @@ /** - * v1.12.0 — Fitbit token-management tests (mocked). Covers `getValidToken`: - * - the NO-ROTATION refresh branch (refresh token preserved when the response - * omits it; overwritten only when a fresh one is returned); + * Fitbit token-management tests (mocked). Covers `getValidToken`: + * - the ROTATING refresh branch (classic Fitbit returns a fresh refresh token + * on every refresh; persist it, replacing the stored one); + * - the defensive keep-existing guard when a malformed response omits it; * - the stored-token fast path when not near expiry; * - a reauth failure recorded when credentials are missing on refresh. */ @@ -86,8 +87,8 @@ afterEach(() => { vi.clearAllMocks(); }); -describe("getValidToken — non-rotating refresh", () => { - it("persists the new access token and KEEPS the stored refresh token when the response omits one", async () => { +describe("getValidToken — rotating refresh", () => { + it("defensively KEEPS the stored refresh token when a malformed response omits one", async () => { prismaMock.fitbitConnection.findUnique.mockResolvedValue({ id: "conn1", fitbitUserId: "abc123", @@ -96,7 +97,8 @@ describe("getValidToken — non-rotating refresh", () => { // Expired so the refresh path fires. tokenExpiresAt: new Date(Date.now() - 1000), }); - // Google does NOT return a refresh_token on a routine refresh. + // Classic Fitbit always rotates; a missing refresh_token is a malformed + // reply. Guard it by keeping the existing token rather than writing a blank. refreshAccessTokenMock.mockResolvedValue({ access_token: "new-access", expires_in: 3600, @@ -112,11 +114,11 @@ describe("getValidToken — non-rotating refresh", () => { }); const updateArg = prismaMock.fitbitConnection.update.mock.calls[0]![0]; expect(updateArg.data.accessToken).toBe("enc(new-access)"); - // The stored refresh token must NOT be touched when the response omits one. + // The stored refresh token must NOT be wiped when the response omits one. expect(updateArg.data).not.toHaveProperty("refreshToken"); }); - it("overwrites the stored refresh token only when the response carries a fresh one", async () => { + it("persists the ROTATED refresh token when the response carries a fresh one", async () => { prismaMock.fitbitConnection.findUnique.mockResolvedValue({ id: "conn1", fitbitUserId: "abc123", diff --git a/src/lib/fitbit/client.ts b/src/lib/fitbit/client.ts index 9021c2143..22a59049f 100644 --- a/src/lib/fitbit/client.ts +++ b/src/lib/fitbit/client.ts @@ -1,33 +1,58 @@ /** - * Fitbit / Google Health API client — OAuth half (v1.12.0). - * Docs: https://developers.google.com/health (re-verify at build — the Google - * Health API is young; v4, post-Fitbit-Web-API). + * Fitbit Web API client — classic `api.fitbit.com` transport (v1.20.0). + * Docs: https://dev.fitbit.com/build/reference/web-api/ * - * Mirrors the WHOOP client structure (`src/lib/whoop/client.ts`): hand-rolled - * fetch over `safeFetch` (no SDK), an OAuth handshake (`getAuthorizationUrl` / - * `exchangeCode` / `refreshAccessToken`), and a single profile fetch for the - * connection's external user id. The data-fetch half (paginated `dataPoints` - * walker + per-type mappers + `FITBIT_FIELD_MAP`) lands in a later wave. + * v1.20.0 retargets the Fitbit integration from the Google Health API + * (`health.googleapis.com`, Restricted scopes behind brand-verification + an + * annual CASA assessment — a hard adoption wall for self-hosters) onto the + * CLASSIC Fitbit Web API. A self-hoster registers an app at dev.fitbit.com in + * minutes (no CASA), so this is the path that actually ships to users. * - * KEY DELTA vs WHOOP — Google does NOT rotate refresh tokens. WHOOP invalidates - * the prior refresh token on every refresh; Google's refresh tokens are stable - * (long-lived in production, 7-day expiry only in Testing mode). So - * `refreshAccessToken` may return a token response WITHOUT a `refresh_token`, - * and the sync layer keeps the stored one in that case (see `getValidToken` in - * `sync.ts`). + * Only the TRANSPORT layer is forked: the auth/token URLs, PKCE handshake, + * classic scopes, rotating-refresh-token handling, per-endpoint REST fetchers, + * and the classic-shape value extractors. Everything downstream — the + * `FitbitMappedMeasurement` / `FitbitMappedWorkout` output shapes, the + * upsert/dedup/rollup tail in `sync.ts`, the source-priority ladders, the + * settings card, the rotation registry, the poll worker, the backfill — reuses + * unchanged. * - * Confidential web-server client: HealthLog holds the client secret - * server-side, so PKCE is omitted (matches WHOOP). Client credentials are sent - * to the token endpoint via HTTP Basic auth (RFC 6749 §2.3.1), the way Google's - * token endpoint accepts a confidential client. + * KEY DELTAS vs the prior Google transport: + * - PKCE (S256). Fitbit recommends PKCE; we mint a `code_verifier` at connect, + * stash it on the OAuth-state row, and present it on the token exchange. The + * confidential client secret still rides in the Basic-auth header (Fitbit + * accepts Basic + PKCE together), so a self-hoster's secret is not the only + * protection. + * - Refresh tokens ROTATE (one-time use). The refresh response carries a NEW + * `refresh_token` that MUST be persisted, replacing the stored one — the + * inverse of the Google path. `getValidToken` (sync.ts) persists it + * unconditionally now. + * - 150 requests/hour/user rate limit (far tighter than Google). The reads are + * date-RANGE endpoints (one request covers a whole window, capped per the + * per-endpoint range limits), and the page-walk / cohort concurrency caps are + * re-tuned accordingly. + * + * NOTE (deprecation): the classic Fitbit Web API is announced for deprecation in + * September 2026, with migration directed back at the Google Health API. Until a + * self-serve Google path exists, the classic API is the only viable transport + * for self-hosters; the card keeps its experimental badge. */ +import { createHash, randomBytes } from "node:crypto"; import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; import { FitbitApiError, classifyFitbitResponse } from "./response-classifier"; -export const FITBIT_API_BASE = "https://health.googleapis.com/v4"; -const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; -const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; +/** Classic Fitbit Web API base. */ +export const FITBIT_API_BASE = "https://api.fitbit.com"; +const FITBIT_OAUTH_AUTH_URL = "https://www.fitbit.com/oauth2/authorize"; +const FITBIT_OAUTH_TOKEN_URL = "https://api.fitbit.com/oauth2/token"; + +/** + * Fitbit returns body weight / distance in metric (kg / km) only when an + * `Accept-Language` that maps to a metric locale is sent — the US default is + * imperial. Pin a metric locale on every data read so the mappers can trust the + * unit. (`en_GB` is metric for weight + distance.) + */ +const FITBIT_METRIC_LOCALE = "en_GB"; export interface FitbitCredentials { clientId: string; @@ -36,18 +61,16 @@ export interface FitbitCredentials { /** * Resolve the OAuth `redirect_uri`, then assert it against an allowlist - * (v1.12.1 — M-5, defence-in-depth). + * (defence-in-depth; carried over from the prior transport). * * The value is operator-controlled config (`FITBIT_REDIRECT_URI`, else derived - * from `NEXT_PUBLIC_APP_URL`), not user input, so Google's registered-redirect + * from `NEXT_PUBLIC_APP_URL`), not user input, and Fitbit's registered-redirect * check is the real backstop. But a misconfigured or `Host`-coerced * `NEXT_PUBLIC_APP_URL` (a mis-deployed reverse proxy reflecting a forwarded * Host) would otherwise send the authorization code's landing URL off-origin. - * Pin the target here so a malformed origin fails fast at the handshake rather - * than silently redirecting elsewhere: + * Pin the target so a malformed origin fails fast at the handshake: * - must be an absolute, parseable URL, - * - must be https (the one exception is a localhost/loopback dev host, which - * Google itself permits over http), + * - must be https (the one exception is a localhost/loopback dev host), * - must land on the fixed `/api/fitbit/callback` path, * - when derived from `NEXT_PUBLIC_APP_URL`, must stay same-origin with it. */ @@ -111,56 +134,82 @@ function getRedirectUri(): string { } /** - * OAuth scopes HealthLog requests (space-separated on the wire). The four - * launch Restricted read bundles cover every v1.12.0 metric; ECG / nutrition / - * location / IRN are deliberately omitted to keep the Restricted-scope CASA - * review surface minimal. Every scope is Restricted → the operator's OAuth - * client needs Google brand verification + an annual CASA assessment before it - * leaves Testing mode. + * Classic Fitbit OAuth scopes HealthLog requests (space-separated on the wire). + * Each is independently self-serve grantable at the consent screen and the + * `temperature` set is omitted on purpose (see the skin-temperature note in + * `mapping.md` — the classic skin-temp reading is a baseline DELTA, not an + * absolute reading, so it has no honest canonical slot here). The launch bundle + * covers every metric the v1.20.0 mappers extract. */ export const FITBIT_OAUTH_SCOPE = [ - "https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly", - "https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly", - "https://www.googleapis.com/auth/googlehealth.sleep.readonly", - "https://www.googleapis.com/auth/googlehealth.profile.readonly", + "activity", + "cardio_fitness", + "heartrate", + "oxygen_saturation", + "profile", + "respiratory_rate", + "sleep", + "weight", ].join(" "); +// ─── PKCE ────────────────────────────────────────────────────── +// +// Fitbit recommends PKCE (S256). The verifier is a high-entropy random string; +// the challenge is BASE64URL(SHA256(verifier)). The verifier is stashed on the +// OAuth-state row at connect and presented on the token exchange at callback. + +export interface FitbitPkcePair { + verifier: string; + challenge: string; +} + /** - * Generate the Google OAuth authorization URL (a browser redirect, not a - * fetch). `state` is the opaque CSRF nonce minted by `oauth-state.ts`. - * - * `access_type=offline` + `prompt=consent` are Google's requirement to receive - * a refresh token (the equivalent of WHOOP's `offline` scope); `prompt=consent` - * forces the consent screen so a re-connect always returns a fresh refresh - * token even if the user previously granted. + * Mint a PKCE verifier + S256 challenge. 64 random bytes → 86 base64url chars, + * comfortably inside Fitbit's 43–128 char verifier range and well past the + * 256-bit entropy floor. + */ +export function generatePkcePair(): FitbitPkcePair { + const verifier = randomBytes(64).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * Build the Fitbit authorization URL (a browser redirect, not a fetch). `state` + * is the opaque CSRF nonce minted by `oauth-state.ts`; `codeChallenge` is the + * S256 PKCE challenge whose verifier the callback presents on token exchange. */ export function getAuthorizationUrl( state: string, creds: FitbitCredentials, + codeChallenge: string, ): string { const params = new URLSearchParams({ response_type: "code", client_id: creds.clientId, redirect_uri: getRedirectUri(), scope: FITBIT_OAUTH_SCOPE, - access_type: "offline", - prompt: "consent", + code_challenge: codeChallenge, + code_challenge_method: "S256", state, }); - return `${GOOGLE_OAUTH_AUTH_URL}?${params}`; + return `${FITBIT_OAUTH_AUTH_URL}?${params}`; } export interface FitbitTokenResponse { access_token: string; /** - * Present on the initial code exchange and whenever Google issues a new - * refresh token; ABSENT on a routine refresh because Google does not rotate. - * The sync layer keeps the stored token when this is missing. + * Classic Fitbit refresh tokens ROTATE (one-time use) — every token response + * (initial exchange AND every refresh) carries a fresh `refresh_token` that + * MUST replace the stored one. Typed optional only to guard a malformed + * response defensively; the callers treat an absent value as a hard error / + * keep-existing fallback. */ refresh_token?: string; expires_in: number; scope?: string; token_type?: string; + user_id?: string; } /** Basic-auth header carrying the confidential client credentials. */ @@ -175,7 +224,7 @@ async function postToken( verb: string, ): Promise { const start = performance.now(); - const res = await safeFetch(GOOGLE_OAUTH_TOKEN_URL, { + const res = await safeFetch(FITBIT_OAUTH_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -199,15 +248,25 @@ async function postToken( classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: + typeof json?.errors?.[0]?.errorType === "string" + ? json.errors[0].errorType + : typeof json?.error === "string" + ? json.error + : undefined, }); } return json as FitbitTokenResponse; } -/** Exchange an authorization code for the initial token pair. */ +/** + * Exchange an authorization code for the initial token pair, presenting the PKCE + * verifier. `redirect_uri` must exactly match the one sent to the authorize + * endpoint. + */ export async function exchangeCode( code: string, + codeVerifier: string, creds: FitbitCredentials, ): Promise { return postToken( @@ -216,6 +275,7 @@ export async function exchangeCode( code, client_id: creds.clientId, redirect_uri: getRedirectUri(), + code_verifier: codeVerifier, }), creds, "exchangeCode", @@ -223,11 +283,10 @@ export async function exchangeCode( } /** - * Refresh an expired access token. Google does NOT rotate refresh tokens — the - * response carries a fresh `access_token` + `expires_in` but usually omits - * `refresh_token`. The caller persists the new access token + expiry and keeps - * the stored refresh token unless a new one is returned. The original scope is - * preserved by Google, so no `scope` param is re-sent. + * Refresh an expired access token. Classic Fitbit ROTATES the refresh token: + * the response carries a fresh `access_token` AND a fresh `refresh_token`; the + * caller MUST persist the new refresh token (the old one is now invalid). The + * grant's scope is preserved, so no `scope` param is re-sent. */ export async function refreshAccessToken( refreshToken: string, @@ -237,7 +296,6 @@ export async function refreshAccessToken( new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken, - client_id: creds.clientId, }), creds, "refreshAccessToken", @@ -245,27 +303,18 @@ export async function refreshAccessToken( } /** - * The Google Health profile carries the external user id used as the - * connection's `fitbitUserId`. Mirrors WHOOP's `fetchProfile` (a single GET, - * not a paginated collection). + * The Fitbit profile carries the stable external user id (`encodedId`) used as + * the connection's `fitbitUserId`. A single GET, not a paginated collection. */ export interface FitbitProfile { - /** - * External user identifier. Google returns `me`-relative profile data; the - * stable id is surfaced under `name` (a `users/{id}` resource name) or `id` - * depending on the API surface — both are captured here and resolved at the - * call site. Re-verify the exact field against a live account at the data-sync - * wave. - */ - name?: string; - id?: string; + user?: { encodedId?: string }; } export async function fetchProfile( accessToken: string, ): Promise { const start = performance.now(); - const res = await safeFetch(`${FITBIT_API_BASE}/users/me/profile`, { + const res = await safeFetch(`${FITBIT_API_BASE}/1/user/-/profile.json`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}` }, }); @@ -290,294 +339,138 @@ export async function fetchProfile( } /** - * Resolve the stable external user id from a Google Health profile. The - * `users/{id}` resource name is the canonical anchor; fall back to a bare `id` - * or "me" so the connection always persists a non-empty `fitbitUserId`. + * Resolve the stable external user id from a Fitbit profile. The `encodedId` is + * the canonical anchor; fall back to "me" so the connection always persists a + * non-empty `fitbitUserId` even if the field is missing. */ export function resolveFitbitUserId(profile: FitbitProfile): string { - if (profile.name) { - const tail = profile.name.split("/").pop(); - if (tail) return tail; - } - if (profile.id) return profile.id; - return "me"; + return profile.user?.encodedId ?? "me"; } -// ─── Data-point reads (Google Health `dataPoints.list`) ──────── +// ─── Date-range reads (classic Fitbit Web API) ───────────────── // -// The Google Health API exposes one uniform `DataPoint` resource shape across -// every data type, read through `GET /v4/users/me/dataTypes/{dataType}/dataPoints` -// with `nextPageToken` pagination. This mirrors the WHOOP `fetchCollection` -// page-walk (`whoop/client.ts:270`), but Google's value-field JSON is NOT fully -// published yet — so the mappers below are intentionally defensive: every value -// is pulled out of a small set of candidate field shapes and guarded by a -// finite-positive check before it becomes a Measurement. Capture the real -// per-type value-field JSON into `src/lib/fitbit/mapping.md` against a live test -// account at build and tighten the extractors then. -// -// Casing gotcha (design §A.1): the data-type id is **kebab-case in the path** -// (`body-fat`) and **snake_case in the `filter`** (`body_fat`). `FITBIT_DATA_TYPES` -// pins both forms so a fetcher can never encode the wrong one. +// Unlike the Google Health uniform `dataPoints` walker, the classic API exposes +// one bespoke endpoint per metric, each returning a date-RANGE array in its own +// JSON shape. A single request covers a whole window (no token pagination), so +// the 150 req/h budget is spent on a handful of range calls per sync rather than +// a per-day fan-out. Each endpoint caps its own range, so a deep backfill is +// chunked into per-endpoint windows by the sync layer. /** - * Page-size ceiling for `dataPoints.list`. The daily/intraday reads default to - * 1440 (one-per-minute) and cap at 10 000; sleep/exercise cap at 25 (same as - * WHOOP's `WHOOP_PAGE_LIMIT`). The launch metrics use the daily/spot reads, so - * the default page size is the larger value. + * The longest date range (in days) the chunking sync layer requests per call. + * The tightest launch endpoint cap is 30 days (body-fat, SpO2, respiratory rate, + * VO2 max); pin the chunk to 30 so one chunk is always a single valid request. */ -export const FITBIT_PAGE_SIZE = 1000; -/** Sleep/exercise read cap — matches the Google Health 25-cap for those types. */ -export const FITBIT_ACTIVITY_PAGE_SIZE = 25; +export const FITBIT_RANGE_DAYS = 30; -/** - * One data type's two on-the-wire encodings. `path` is the kebab-case segment - * spliced into the request URL; `filter` is the snake_case prefix used to build - * the incremental `filter=` predicate. The `timeField` names the value object's - * time anchor (a sample time for spot readings, a date for daily summaries) so - * the incremental filter targets the right field. - */ -export interface FitbitDataType { - /** kebab-case segment for the request path. */ - path: string; - /** snake_case prefix for the `filter` predicate. */ - filter: string; - /** - * Which time anchor the filter / measuredAt resolution targets: - * - `sample` → spot reading (`{type}.sample_time.physical_time`). - * - `date` → daily summary keyed on a civil date (`{type}.date`). - * - `interval` → an INTERVAL data type (steps / distance / calories / floors, - * sleep, exercise). Google Health anchors these on `{type}.interval.start_time` - * (the physical instant) with `{type}.interval.civil_start_time` as the civil - * fallback — NOT on a `sample_time` (which 400s/empties for interval types and - * stalls the incremental filter) and NOT on a bare `date`. - */ - timeField: "sample" | "date" | "interval"; +/** Sleep is fetched on the same 30-day chunk; one request per chunk. */ +export const FITBIT_SLEEP_RANGE_DAYS = 30; + +/** `YYYY-MM-DD` (UTC) for a Fitbit date-path segment. */ +export function fitbitDate(d: Date): string { + return d.toISOString().slice(0, 10); } -/** - * The launch (W3 metrics) data types. Each entry pins the kebab-path + snake-filter - * pair so the two encodings can never drift. Activity/sleep/workout types land in - * W5; only the health-metrics bundle is read here. - */ -export const FITBIT_DATA_TYPES = { - weight: { path: "weight", filter: "weight", timeField: "sample" }, - bodyFat: { path: "body-fat", filter: "body_fat", timeField: "sample" }, - oxygenSaturation: { - path: "daily-oxygen-saturation", - filter: "daily_oxygen_saturation", - timeField: "date", - }, - heartRateVariability: { - path: "daily-heart-rate-variability", - filter: "daily_heart_rate_variability", - timeField: "date", - }, - restingHeartRate: { - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - timeField: "date", - }, - respiratoryRate: { - path: "daily-respiratory-rate", - filter: "daily_respiratory_rate", - timeField: "date", - }, - heartRate: { path: "heart-rate", filter: "heart_rate", timeField: "sample" }, - height: { path: "height", filter: "height", timeField: "sample" }, - sleepTemperature: { - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - timeField: "date", - }, - // ── Activity bundle (W5) — daily cumulative totals ───────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. These are INTERVAL data - // types: Google buckets a daily total into an `interval` (a `start_time` + - // `end_time`, with `civil_start_time` for the calendar-day grain). The - // incremental filter targets `interval.start_time` and the externalId carries - // the `stats:` prefix so a re-fetched day overwrites in place (mirrors the - // Apple-Health `stats::` daily-total overwrite contract). - steps: { path: "steps", filter: "steps", timeField: "interval" }, - distance: { path: "distance", filter: "distance", timeField: "interval" }, - activeCalories: { - path: "active-calories", - filter: "active_calories", - timeField: "interval", - }, - floors: { path: "floors", filter: "floors", timeField: "interval" }, - // VO2 max is a daily-summary metric (one civil-date reading), not an interval - // bucket — keep the `date` anchor. - vo2Max: { path: "vo2-max", filter: "vo2_max", timeField: "date" }, - // ── Sleep bundle (W5) ────────────────────────────────────────── - // Scope: `googlehealth.sleep.readonly`. A sleep session is an INTERVAL data - // type (a start + end span carrying a per-stage breakdown); the incremental - // filter must target `interval.start_time`, not a `sample_time` (which 400s - // for interval types). Mapped to per-stage SLEEP_DURATION rows. - sleep: { path: "sleep", filter: "sleep", timeField: "interval" }, - // ── Exercise bundle (W5) ─────────────────────────────────────── - // Scope: `googlehealth.activity_and_fitness.readonly`. An exercise session is - // an INTERVAL data type (a start + end span) → a `Workout` row (NOT a - // Measurement). The incremental filter targets `interval.start_time`. - exercise: { path: "exercise", filter: "exercise", timeField: "interval" }, -} as const satisfies Record; - -/** Google Health `DataPoint` — value object is type-keyed + carries a time anchor. */ -export interface FitbitDataPoint { - [key: string]: unknown; -} - -/** `dataPoints.list` envelope: `{ dataPoints, nextPageToken }`. */ -interface FitbitDataPointPage { - dataPoints?: FitbitDataPoint[]; - nextPageToken?: string | null; -} - -interface DataPointQuery { - /** Lower-bound incremental cursor; omitted on a full backfill. */ - start?: Date; - /** Page size (defaults to `FITBIT_PAGE_SIZE`). */ - pageSize?: number; - /** Hard ceiling on pages walked (defence against a runaway cursor). */ - maxPages?: number; -} - -/** - * Build the incremental `filter` field + bound for one data type. Centralised so - * the predicate and the read-time anchor resolution can never drift on which - * field a given `timeField` targets. - * - `sample` → spot reading, filter on `{filter}.sample_time.physical_time`. - * - `interval` → INTERVAL type, filter on `{filter}.interval.start_time` - * (Google's anchor for steps/distance/calories/floors, sleep, exercise — a - * `sample_time` filter 400s/empties for these and stalls incremental sync). - * - `date` → daily civil-date summary, filter on `{filter}.date`. - */ -export function incrementalFilter( - dataType: FitbitDataType, - start: Date, -): { field: string; bound: string } { - switch (dataType.timeField) { - case "sample": - return { - field: `${dataType.filter}.sample_time.physical_time`, - bound: start.toISOString(), - }; - case "interval": - return { - field: `${dataType.filter}.interval.start_time`, - bound: start.toISOString(), - }; - case "date": - return { - field: `${dataType.filter}.date`, - bound: start.toISOString().slice(0, 10), - }; - } -} - -/** - * Walk every `DataPoint` for one data type since the incremental cursor. - * Mirrors the WHOOP `fetchCollection` page-loop: `nextPageToken` pagination, a - * 1000-page `maxPages` ceiling, and per-page `addExternalCall` telemetry. The - * data-type id is kebab-cased in the path; the `filter` predicate is built from - * the snake_case form against the type's time anchor. - */ -export async function fetchDataPoints( - dataType: FitbitDataType, +/** GET a classic Fitbit endpoint and return the parsed JSON body. */ +async function fitbitGet( + path: string, accessToken: string, verb: string, - query: DataPointQuery = {}, -): Promise { - const points: FitbitDataPoint[] = []; - let pageToken: string | null | undefined; - let pageCount = 0; - const maxPages = query.maxPages ?? 1000; - const pageSize = query.pageSize ?? FITBIT_PAGE_SIZE; - - do { - const params = new URLSearchParams({ pageSize: String(pageSize) }); - if (query.start) { - // snake_case filter prefix. The filter field + bound shape follow the - // data type's time anchor: - // - sample → `{filter}.sample_time.physical_time` >= an ISO instant - // - interval → `{filter}.interval.start_time` >= an ISO instant - // - date → `{filter}.date` >= a civil date - const { field, bound } = incrementalFilter(dataType, query.start); - params.set("filter", `${field} >= "${bound}"`); - } - if (pageToken) params.set("pageToken", pageToken); - - const pageStart = performance.now(); - const res = await safeFetch( - `${FITBIT_API_BASE}/users/me/dataTypes/${dataType.path}/dataPoints?${params}`, - { - method: "GET", - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); - - const json = (await res - .json() - .catch(() => null)) as FitbitDataPointPage | null; - const verdict = classifyFitbitResponse(res.status); - getEvent()?.addExternalCall({ - service: "fitbit", - method: `${verb}(page=${pageCount})`, - duration_ms: Math.round(performance.now() - pageStart), - status: res.status, - error: verdict.classification === "success" ? undefined : verdict.reason, +): Promise { + const start = performance.now(); + const res = await safeFetch(`${FITBIT_API_BASE}${path}`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept-Language": FITBIT_METRIC_LOCALE, + }, + }); + const json = await res.json().catch(() => null); + const verdict = classifyFitbitResponse(res.status); + getEvent()?.addExternalCall({ + service: "fitbit", + method: verb, + duration_ms: Math.round(performance.now() - start), + status: res.status, + error: verdict.classification === "success" ? undefined : verdict.reason, + }); + if (verdict.classification !== "success") { + throw new FitbitApiError({ + verb, + classification: verdict.classification, + httpStatus: verdict.httpStatus, + reason: verdict.reason, }); - if (verdict.classification !== "success") { - throw new FitbitApiError({ - verb, - classification: verdict.classification, - httpStatus: verdict.httpStatus, - reason: verdict.reason, - }); - } - - for (const p of json?.dataPoints ?? []) points.push(p); - pageToken = json?.nextPageToken ?? null; - pageCount += 1; - } while (pageToken && pageCount < maxPages); - - return points; + } + return json; } // ─── Field → Measurement mapping ─────────────────────────────── -// The single source of truth is `src/lib/fitbit/mapping.md` — keep both in -// sync when adding entries. - -/** Metres → centimetres (Fitbit `height` → `User.heightCm`). */ -const M_TO_CM = 100; +// The single source of truth is `src/lib/fitbit/mapping.md` — keep both in sync +// when adding entries. Every mapper takes the parsed endpoint body and returns +// `FitbitMappedMeasurement[]` (the source + final externalId are stamped by the +// sync layer). function round2(n: number): number { return parseFloat(n.toFixed(2)); } -/** Finite + strictly-positive guard — the WHOOP `positive()` discipline. */ +/** Finite + strictly-positive guard. */ function positive(n: unknown): n is number { return typeof n === "number" && Number.isFinite(n) && n > 0; } +/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ +function nonNegative(n: unknown): n is number { + return typeof n === "number" && Number.isFinite(n) && n >= 0; +} + +/** + * Coerce a Fitbit value that may arrive as a number OR a numeric string (the + * activity time-series returns string values, e.g. `"2504"`). Returns null when + * not finite. + */ +function toFiniteNumber(v: unknown): number | null { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() !== "") { + const n = Number(v); + if (Number.isFinite(n)) return n; + } + return null; +} + +/** + * Parse a Fitbit `YYYY-MM-DD` civil date into a UTC-midday Date. Anchored at + * midday so a timezone shift can't roll the civil day across a boundary. Returns + * null for an unparseable string. + */ +function parseCivilDate(dateStr: unknown): Date | null { + if (typeof dateStr !== "string") return null; + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr.trim()); + if (!m) return null; + return new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 12)); +} + /** * A single mapped Fitbit reading destined for one `Measurement` row. The - * `source` (`FITBIT`) and `externalId` (`:`) are stamped by the - * sync layer; the mapper emits only type/value/unit/measuredAt + the field-tag - * that disambiguates the externalId. Mirrors WHOOP's `MappedMeasurement`. + * `source` (`FITBIT`) and final `externalId` are stamped by the sync layer; the + * mapper emits type/value/unit/measuredAt + the field-tag that forms the + * externalId. */ export interface FitbitMappedMeasurement { type: string; value: number; unit: string; measuredAt: Date; - /** Disambiguator appended to the per-point anchor to form the externalId. */ + /** Full externalId payload for the row (`:` / `:`). */ fieldTag: string; /** Per-stage sleep rows carry the SleepStage; everything else omits it. */ sleepStage?: FitbitSleepStage; /** * `true` when the externalId carries the `stats:` daily-total prefix (the - * cumulative activity metrics). The sync layer stamps the externalId; this - * flag lets it pick the right shape (`stats::` vs the - * `:` spot shape) without re-deriving the grain. + * cumulative activity metrics). The sync layer reads this flag to assemble the + * `stats::` overwrite-in-place shape (matching the + * Apple-Health daily-total contract). */ cumulativeDaily?: boolean; } @@ -591,590 +484,421 @@ export type FitbitSleepStage = | "CORE" | "DEEP"; -/** - * Pull the first finite number out of a list of candidate value paths on a - * `DataPoint`. The Google value-field JSON is undocumented, so each mapper hands - * the small set of shapes the field is likely to take (a bare `value`, a typed - * sub-object, a `{ value: { fpVal } }` wrapper, …); the first finite hit wins. - */ -function firstNumber(point: FitbitDataPoint, paths: string[]): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (positive(v)) return v; - // A value can legitimately be zero for some metrics, but the launch metrics - // (weight, body-fat, SpO2, HRV, RHR, respiratory rate, HR, height) are all - // strictly positive — a zero is a garbage/empty reading, so `positive` is - // the right guard. - } - return null; +// ── Weight + body fat (/body/log/{weight,fat}) ───────────────── +// +// GET /1/user/-/body/log/weight/date/{start}/{end}.json +// → { weight: [{ date, time, weight, logId, bmi, fat?, source }] } +// GET /1/user/-/body/log/fat/date/{start}/{end}.json +// → { fat: [{ date, time, fat, logId, source }] } +// Weight is kg under the metric Accept-Language. The `logId` is a stable +// per-reading id → a re-fetch overwrites in place. + +interface FitbitBodyLogEntry { + date?: string; + time?: string; + logId?: number; + weight?: number; + fat?: number; } -/** Resolve a dotted path against a nested object; undefined on any miss. */ -function readPath(obj: unknown, path: string): unknown { - let cur: unknown = obj; - for (const seg of path.split(".")) { - if (cur === null || typeof cur !== "object") return undefined; - cur = (cur as Record)[seg]; +/** Resolve the measuredAt for a body-log entry from its `date` + `time`. */ +function bodyLogInstant(entry: FitbitBodyLogEntry): Date { + const date = typeof entry.date === "string" ? entry.date : undefined; + const time = typeof entry.time === "string" ? entry.time : "12:00:00"; + if (date) { + const d = new Date(`${date}T${time}Z`); + if (!Number.isNaN(d.getTime())) return d; + const civil = parseCivilDate(date); + if (civil) return civil; } - return cur; + return new Date(); } -/** Parse a `{year,month,day}` civil-date object into a UTC-midday Date, or null. */ -function parseCivilDateObject(val: unknown): Date | null { - if (!val || typeof val !== "object") return null; - const o = val as Record; - if ( - typeof o.year === "number" && - typeof o.month === "number" && - typeof o.day === "number" - ) { - // Google civil dates are 1-based months; anchor at UTC midday so a - // timezone shift can't roll the civil day across a boundary. - return new Date(Date.UTC(o.year, o.month - 1, o.day, 12)); +/** Stable anchor for a body-log externalId — the logId, else the instant. */ +function bodyLogAnchor(entry: FitbitBodyLogEntry): string { + if (typeof entry.logId === "number" && Number.isFinite(entry.logId)) { + return String(entry.logId); } - return null; + return bodyLogInstant(entry).toISOString(); +} + +export function mapWeight(body: unknown): FitbitMappedMeasurement[] { + const arr = readArray(body, "weight"); + const out: FitbitMappedMeasurement[] = []; + for (const raw of arr) { + const e = raw as FitbitBodyLogEntry; + if (!positive(e.weight)) continue; + out.push({ + type: "WEIGHT", + value: round2(e.weight), + unit: "kg", + measuredAt: bodyLogInstant(e), + fieldTag: `${bodyLogAnchor(e)}:weight`, + }); + } + return out; +} + +export function mapBodyFat(body: unknown): FitbitMappedMeasurement[] { + const arr = readArray(body, "fat"); + const out: FitbitMappedMeasurement[] = []; + for (const raw of arr) { + const e = raw as FitbitBodyLogEntry; + if (!positive(e.fat)) continue; + out.push({ + type: "BODY_FAT", + value: round2(e.fat), + unit: "%", + measuredAt: bodyLogInstant(e), + fieldTag: `${bodyLogAnchor(e)}:body_fat`, + }); + } + return out; +} + +// ── Daily-summary metrics keyed on a civil date ──────────────── +// +// SpO2, HRV, resting HR, respiratory rate, and VO2 max are one reading per +// calendar day. The externalId is day-keyed so a re-fetch of the same day +// overwrites in place. The reading is dropped (not zero-filled) when its value +// is absent or non-positive — these metrics are all strictly positive. + +/** A `{ dateTime, value: {...} }` daily-summary row. */ +interface FitbitDailyRow { + dateTime?: string; + value?: unknown; } /** - * Resolve a `DataPoint`'s measurement timestamp. - * - `sample` → `sample_time.physical_time` (a spot ISO instant). - * - `interval` → `interval.start_time` (the bucket's start ISO instant), with - * `interval.civil_start_time` (a civil string / `{year,month,day}`) as the - * fallback. This is Google's anchor for the INTERVAL types; the prior `date` - * read returned undefined for them and forced the fetch-time fallback. - * - `date` → `date` (a civil string or `{year,month,day}` object). - * Falls back to `fallback` only when nothing parses, so a row is never dropped - * for a missing anchor. + * Map a list of daily-summary rows pulling `value[leaf]` (or an alt leaf) into + * one strictly-positive Measurement per day, day-keyed externalId. */ -function resolveMeasuredAt( - point: FitbitDataPoint, - dataType: FitbitDataType, - fallback: Date, -): Date { - if (dataType.timeField === "sample") { - const t = readPath(point, `${dataType.filter}.sample_time.physical_time`); - if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; - } - } else if (dataType.timeField === "interval") { - const t = readPath(point, `${dataType.filter}.interval.start_time`); - if (typeof t === "string") { - const d = new Date(t); - if (!Number.isNaN(d.getTime())) return d; - } - const civil = readPath( - point, - `${dataType.filter}.interval.civil_start_time`, - ); - if (typeof civil === "string") { - const d = new Date(civil); - if (!Number.isNaN(d.getTime())) return d; - } - const civilObj = parseCivilDateObject(civil); - if (civilObj) return civilObj; - } else { - const dateVal = readPath(point, `${dataType.filter}.date`); - if (typeof dateVal === "string") { - const d = new Date(dateVal); - if (!Number.isNaN(d.getTime())) return d; - } - const civilObj = parseCivilDateObject(dateVal); - if (civilObj) return civilObj; - } - return fallback; -} - -/** - * Stable anchor for a `DataPoint`'s externalId. A spot reading anchors on its - * sample time; a daily summary on its civil date. Combined with a type-specific - * field-tag this makes the upsert key idempotent across re-fetches (a re-sync of - * the same window overwrites in place rather than minting a duplicate). - */ -function externalAnchor( - point: FitbitDataPoint, - dataType: FitbitDataType, -): string { - const at = resolveMeasuredAt(point, dataType, new Date(0)); - // The mapper-driven interval types (steps/distance/calories/floors) are daily - // totals, so they share the civil-day externalId grain with `date` summaries — - // a re-fetched day overwrites in place. (Sleep/exercise are interval too but - // mint their own per-session anchors and never reach this helper.) - if (dataType.timeField === "date" || dataType.timeField === "interval") { - return at.toISOString().slice(0, 10); - } - return at.toISOString(); -} - -/** - * Map one data point of a simple single-value metric into a Measurement reading. - * `valuePaths` lists the candidate value shapes; the first finite-positive hit - * wins. Returns an empty array when no value parses (an empty/garbage point). - */ -function mapSimple( - point: FitbitDataPoint, - dataType: FitbitDataType, +function mapDailySummary( + rows: FitbitDailyRow[], spec: { type: string; unit: string; fieldTag: string; - valuePaths: string[]; + leaves: string[]; factor?: number; }, ): FitbitMappedMeasurement[] { - let value = firstNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - return [ - { + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(row.dateTime); + if (!day) continue; + const value = firstPositiveLeaf(row.value, spec.leaves); + if (value === null) continue; + const scaled = spec.factor ? value * spec.factor : value; + out.push({ type: spec.type, - value: round2(value), + value: round2(scaled), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), - fieldTag: `${externalAnchor(point, dataType)}:${spec.fieldTag}`, - }, - ]; + measuredAt: day, + fieldTag: `${fitbitDate(day)}:${spec.fieldTag}`, + }); + } + return out; } -/** Candidate value-field shapes a Google `DataPoint` is likely to carry. */ -function valuePaths(filterKey: string, leaf: string): string[] { - return [ - `${filterKey}.${leaf}`, - `${filterKey}.value.${leaf}`, - `${filterKey}.value`, - `value.${leaf}`, - `value`, - ]; +/** First strictly-positive `obj[leaf]` across candidate leaves, or null. */ +function firstPositiveLeaf(obj: unknown, leaves: string[]): number | null { + if (!obj || typeof obj !== "object") return null; + const o = obj as Record; + for (const leaf of leaves) { + if (positive(o[leaf])) return o[leaf] as number; + } + return null; } -export function mapWeight(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.weight; - return mapSimple(point, dt, { - type: "WEIGHT", - unit: "kg", - fieldTag: "weight", - valuePaths: valuePaths(dt.filter, "kilograms"), - }); -} - -export function mapBodyFat(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.bodyFat; - return mapSimple(point, dt, { - type: "BODY_FAT", - unit: "%", - fieldTag: "body_fat", - valuePaths: valuePaths(dt.filter, "percentage"), - }); -} - -export function mapOxygenSaturation( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.oxygenSaturation; - return mapSimple(point, dt, { +export function mapOxygenSaturation(body: unknown): FitbitMappedMeasurement[] { + // The SpO2 summary endpoint returns a BARE ARRAY of daily rows (not wrapped). + const rows = Array.isArray(body) ? (body as FitbitDailyRow[]) : []; + return mapDailySummary(rows, { type: "OXYGEN_SATURATION", unit: "%", fieldTag: "spo2", - valuePaths: [ - ...valuePaths(dt.filter, "average_percentage"), - ...valuePaths(dt.filter, "percentage"), - ], + leaves: ["avg"], }); } export function mapHeartRateVariability( - point: FitbitDataPoint, + body: unknown, ): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.heartRateVariability; - // Fitbit reports a nightly RMSSD-style HRV. Per the design decision it lands - // in the SDNN-lineage `HEART_RATE_VARIABILITY` slot (Apple-comparable), NOT - // WHOOP's `HRV_RMSSD` (reserved for the WHOOP-native estimator). Re-confirm - // the estimator against a live account and revisit if it warrants `HRV_RMSSD`. - return mapSimple(point, dt, { + // Fitbit's HRV summary is an RMSSD estimator (`value.dailyRmssd`). It lands in + // the canonical `HEART_RATE_VARIABILITY` slot that FITBIT occupies in the + // source-priority `hrv` ladder (alongside Apple / Oura), keeping cross-source + // comparison on one axis. (`HRV_RMSSD` is reserved for the WHOOP-native slot.) + const rows = readArray(body, "hrv") as FitbitDailyRow[]; + return mapDailySummary(rows, { type: "HEART_RATE_VARIABILITY", unit: "ms", fieldTag: "hrv", - valuePaths: [ - ...valuePaths(dt.filter, "rmssd_milliseconds"), - ...valuePaths(dt.filter, "milliseconds"), - ], + leaves: ["dailyRmssd"], }); } -export function mapRestingHeartRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.restingHeartRate; - return mapSimple(point, dt, { - type: "RESTING_HEART_RATE", - unit: "bpm", - fieldTag: "rhr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), - }); -} - -export function mapRespiratoryRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.respiratoryRate; - return mapSimple(point, dt, { +export function mapRespiratoryRate(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "br") as FitbitDailyRow[]; + return mapDailySummary(rows, { type: "RESPIRATORY_RATE", unit: "breaths/min", fieldTag: "resp_rate", - valuePaths: [ - ...valuePaths(dt.filter, "breaths_per_minute"), - ...valuePaths(dt.filter, "average_breaths_per_minute"), - ], - }); -} - -export function mapHeartRate( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.heartRate; - return mapSimple(point, dt, { - type: "PULSE", - unit: "bpm", - fieldTag: "hr", - valuePaths: valuePaths(dt.filter, "beats_per_minute"), - }); -} - -export function mapSleepTemperature( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.sleepTemperature; - // Google surfaces a sleeping skin/wrist temperature derivation. `WRIST_TEMPERATURE` - // is the closest semantic slot (Apple sleeping-wrist-temp). Confirm absolute-vs- - // baseline at build; the guard rejects a non-positive (baseline-delta) reading. - return mapSimple(point, dt, { - type: "WRIST_TEMPERATURE", - unit: "celsius", - fieldTag: "wrist_temp", - valuePaths: [ - ...valuePaths(dt.filter, "nightly_temperature_celsius"), - ...valuePaths(dt.filter, "celsius"), - ], + leaves: ["breathingRate"], }); } /** - * Extract the profile height (in cm) from a Fitbit `height` data point, or null - * when nothing parses. Height is a one-time `User.heightCm` profile seed (written - * only when the user has no height yet) — NOT a Measurement, mirroring WHOOP's - * `mapBody` height handling. Returns cm. + * Resting heart rate lives inside the heart-rate time series: + * GET /1/user/-/activities/heart/date/{start}/{end}.json + * → { "activities-heart": [{ dateTime, value: { restingHeartRate } }] } */ -export function mapHeightCm(point: FitbitDataPoint): number | null { - const dt = FITBIT_DATA_TYPES.height; - // Google may report height in metres or centimetres; try cm-direct first, then - // metres × 100. Both pass through the positive guard. - const cm = firstNumber(point, valuePaths(dt.filter, "centimeters")); - if (cm !== null) return round2(cm); - const m = firstNumber(point, valuePaths(dt.filter, "meters")); - if (m !== null) return round2(m * M_TO_CM); - return null; -} - -/** - * Field→Measurement mapping table (mirror of `mapping.md`). Documents which - * Fitbit/Google-Health source field becomes which MeasurementType + unit, and - * pins the kebab-path / snake-filter pair for each. Used as the single-glance - * reference and by the mapper tests; the mappers above are the executable form. - */ -export const FITBIT_FIELD_MAP: Record< - string, - { type: string; unit: string; path: string; filter: string; note?: string } -> = { - weight: { - type: "WEIGHT", - unit: "kg", - path: "weight", - filter: "weight", - note: "picker ranks a real Withings scale above Fitbit", - }, - bodyFat: { - type: "BODY_FAT", - unit: "%", - path: "body-fat", - filter: "body_fat", - }, - oxygenSaturation: { - type: "OXYGEN_SATURATION", - unit: "%", - path: "daily-oxygen-saturation", - filter: "daily_oxygen_saturation", - }, - heartRateVariability: { - type: "HEART_RATE_VARIABILITY", - unit: "ms", - path: "daily-heart-rate-variability", - filter: "daily_heart_rate_variability", - note: "SDNN slot (Apple-comparable), NOT HRV_RMSSD; confirm estimator at build", - }, - restingHeartRate: { +export function mapRestingHeartRate(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "activities-heart") as FitbitDailyRow[]; + return mapDailySummary(rows, { type: "RESTING_HEART_RATE", unit: "bpm", - path: "daily-resting-heart-rate", - filter: "daily_resting_heart_rate", - }, - respiratoryRate: { - type: "RESPIRATORY_RATE", - unit: "breaths/min", - path: "daily-respiratory-rate", - filter: "daily_respiratory_rate", - }, - heartRate: { - type: "PULSE", - unit: "bpm", - path: "heart-rate", - filter: "heart_rate", - note: "intraday spot HR", - }, - height: { - type: "User.heightCm", - unit: "cm", - path: "height", - filter: "height", - note: "profile seed — written to User.heightCm only when null, never as a Measurement", - }, - sleepTemperature: { - type: "WRIST_TEMPERATURE", - unit: "celsius", - path: "daily-sleep-temperature-derivations", - filter: "daily_sleep_temperature_derivations", - note: "overnight wrist-temp; confirm absolute-vs-baseline at build", - }, - // ── Activity bundle (W5) — daily cumulative ───────────────────── - steps: { - type: "ACTIVITY_STEPS", - unit: "steps", - path: "steps", - filter: "steps", - note: "daily total; stats: externalId overwrites on re-fetch; 0 is a valid rest day", - }, - distance: { - type: "WALKING_RUNNING_DISTANCE", - unit: "m", - path: "distance", - filter: "distance", - note: "daily total metres; stats: externalId overwrites on re-fetch", - }, - activeCalories: { - type: "ACTIVE_ENERGY_BURNED", - unit: "kcal", - path: "active-calories", - filter: "active_calories", - note: "ACTIVE portion only (NOT total caloriesOut); stats: externalId overwrites on re-fetch", - }, - floors: { - type: "FLIGHTS_CLIMBED", - unit: "flights", - path: "floors", - filter: "floors", - note: "daily total floors; stats: externalId overwrites on re-fetch", - }, - vo2Max: { - type: "VO2_MAX", - unit: "mL/(kg·min)", - path: "vo2-max", - filter: "vo2_max", - note: "daily latest-wins; daily-anchor externalId overwrites on re-fetch", - }, - sleep: { - type: "SLEEP_DURATION", - unit: "minutes", - path: "sleep", - filter: "sleep", - note: "per-stage rows (IN_BED/AWAKE/REM/CORE/DEEP); measuredAt = stage END", - }, - exercise: { - type: "Workout", - unit: "—", - path: "exercise", - filter: "exercise", - note: "exercise session → Workout row (NOT a Measurement); cross-source dedup at read time", - }, -}; - -// ─── W5 mappers: activity (cumulative), sleep stages, workouts ── - -/** Finite + non-negative guard — cumulative metrics admit a legitimate 0. */ -function nonNegative(n: unknown): n is number { - return typeof n === "number" && Number.isFinite(n) && n >= 0; + fieldTag: "rhr", + leaves: ["restingHeartRate"], + }); } /** - * Pull the first finite NON-negative number out of a list of candidate value - * paths. Unlike `firstNumber` (strictly positive), this admits a legitimate - * zero — a day of rest still records 0 steps / 0 floors / 0 active kcal, and - * dropping the zero would leave a hole the chart misreads as missing data. + * VO2 max (cardio fitness score): + * GET /1/user/-/cardioscore/date/{start}/{end}.json + * → { cardioScore: [{ dateTime, value: { vo2Max: "44-48" | "45" } }] } + * The value is a STRING that may be a single number or a range; a range resolves + * to its midpoint. Daily latest-wins, day-keyed externalId. */ -function firstNonNegativeNumber( - point: FitbitDataPoint, - paths: string[], -): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (nonNegative(v)) return v; +export function mapVo2Max(body: unknown): FitbitMappedMeasurement[] { + const rows = readArray(body, "cardioScore") as FitbitDailyRow[]; + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(row.dateTime); + if (!day) continue; + const v = parseVo2Max( + (row.value as Record | undefined)?.vo2Max, + ); + if (v === null || !positive(v)) continue; + out.push({ + type: "VO2_MAX", + value: round2(v), + unit: "mL/(kg·min)", + measuredAt: day, + fieldTag: `vo2_max:${fitbitDate(day)}`, + cumulativeDaily: true, + }); } - return null; + return out; } -/** - * Map one daily cumulative-activity data point into a single Measurement - * reading. The externalId is the `stats:`-prefixed daily-total shape so a - * re-fetched day overwrites in place rather than minting a duplicate — the same - * overwrite contract the Apple-Health `stats::` daily totals - * use. A zero is preserved (a rest day is real data, not a gap). `latestWins` - * (VO2 max) carries the same daily anchor; it is daily latest-wins rather than a - * running sum, but the per-day overwrite key is identical. - */ -function mapDailyCumulative( - point: FitbitDataPoint, - dataType: FitbitDataType, +/** Parse a Fitbit VO2max value: a single number, or a "lo-hi" range midpoint. */ +export function parseVo2Max(raw: unknown): number | null { + if (typeof raw === "number" && Number.isFinite(raw)) return raw; + if (typeof raw !== "string") return null; + const s = raw.trim(); + const range = /^(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)$/.exec(s); + if (range) { + const lo = Number(range[1]); + const hi = Number(range[2]); + if (Number.isFinite(lo) && Number.isFinite(hi)) return (lo + hi) / 2; + return null; + } + const n = Number(s); + return Number.isFinite(n) ? n : null; +} + +// ── Daily cumulative activity (activities time series) ───────── +// +// GET /1/user/-/activities/{steps,distance,floors,activityCalories}/date/{s}/{e}.json +// → { "activities-{resource}": [{ dateTime, value: "" }] } +// One daily total per civil day. The externalId carries the `stats:` daily-total +// prefix (assembled in the sync layer) so a re-fetched day overwrites in place, +// matching the Apple-Health `stats::` contract. A rest day of 0 +// is preserved (dropping it would leave a chart gap misread as missing data). +// distance is reported in km under the metric locale → ×1000 to metres. + +/** Map an activity time-series body for one cumulative metric. */ +function mapActivitySeries( + body: unknown, spec: { + arrayKey: string; type: string; unit: string; fieldTag: string; - valuePaths: string[]; factor?: number; - /** VO2 max is daily latest-wins, not a running total — still one row/day. */ - latestWins?: boolean; }, ): FitbitMappedMeasurement[] { - // VO2 max is strictly positive; the running totals admit a legitimate zero. - let value = spec.latestWins - ? firstNumber(point, spec.valuePaths) - : firstNonNegativeNumber(point, spec.valuePaths); - if (value === null) return []; - if (spec.factor) value = value * spec.factor; - const dayKey = externalAnchor(point, dataType); // YYYY-MM-DD for a daily type - return [ - { + const rows = readArray(body, spec.arrayKey) as FitbitDailyRow[]; + const out: FitbitMappedMeasurement[] = []; + for (const row of rows) { + const day = parseCivilDate(row.dateTime); + if (!day) continue; + const n = toFiniteNumber(row.value); + if (n === null || !nonNegative(n)) continue; + const scaled = spec.factor ? n * spec.factor : n; + out.push({ type: spec.type, - value: round2(value), + value: round2(scaled), unit: spec.unit, - measuredAt: resolveMeasuredAt(point, dataType, new Date()), - // `stats::` — the sync layer reads `cumulativeDaily` - // to assemble the externalId, matching the Apple-Health daily-total shape. - fieldTag: `${spec.fieldTag}:${dayKey}`, + measuredAt: day, + fieldTag: `${spec.fieldTag}:${fitbitDate(day)}`, cumulativeDaily: true, - }, - ]; + }); + } + return out; } -export function mapSteps(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.steps; - return mapDailyCumulative(point, dt, { +export function mapSteps(body: unknown): FitbitMappedMeasurement[] { + return mapActivitySeries(body, { + arrayKey: "activities-steps", type: "ACTIVITY_STEPS", unit: "steps", fieldTag: "steps", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "steps"), - ], }); } -export function mapDistance(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.distance; - // Google may report metres or kilometres; prefer the explicit-metres field, - // else convert km → m. Both pass the non-negative guard. - const meters = firstNonNegativeNumber(point, valuePaths(dt.filter, "meters")); - if (meters !== null) { - return [ - { - type: "WALKING_RUNNING_DISTANCE", - value: round2(meters), - unit: "m", - measuredAt: resolveMeasuredAt(point, dt, new Date()), - fieldTag: `distance:${externalAnchor(point, dt)}`, - cumulativeDaily: true, - }, - ]; - } - return mapDailyCumulative(point, dt, { +export function mapDistance(body: unknown): FitbitMappedMeasurement[] { + // The metric-locale distance series reports kilometres → metres. + return mapActivitySeries(body, { + arrayKey: "activities-distance", type: "WALKING_RUNNING_DISTANCE", unit: "m", fieldTag: "distance", - valuePaths: valuePaths(dt.filter, "kilometers"), factor: 1000, }); } -export function mapActiveCalories( - point: FitbitDataPoint, -): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.activeCalories; - // ACTIVE energy only — NOT total caloriesOut (which folds in BMR). The - // candidate paths target the active-portion field explicitly. - return mapDailyCumulative(point, dt, { +export function mapActiveCalories(body: unknown): FitbitMappedMeasurement[] { + // `activityCalories` is the ACTIVE portion (excludes BMR), unlike the total + // `calories` resource. + return mapActivitySeries(body, { + arrayKey: "activities-activityCalories", type: "ACTIVE_ENERGY_BURNED", unit: "kcal", fieldTag: "active_calories", - valuePaths: [ - ...valuePaths(dt.filter, "active_kilocalories"), - ...valuePaths(dt.filter, "kilocalories"), - ...valuePaths(dt.filter, "calories"), - ], }); } -export function mapFloors(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.floors; - return mapDailyCumulative(point, dt, { +export function mapFloors(body: unknown): FitbitMappedMeasurement[] { + return mapActivitySeries(body, { + arrayKey: "activities-floors", type: "FLIGHTS_CLIMBED", unit: "flights", fieldTag: "floors", - valuePaths: [ - ...valuePaths(dt.filter, "count"), - ...valuePaths(dt.filter, "floors"), - ], }); } -export function mapVo2Max(point: FitbitDataPoint): FitbitMappedMeasurement[] { - const dt = FITBIT_DATA_TYPES.vo2Max; - return mapDailyCumulative(point, dt, { - type: "VO2_MAX", - unit: "mL/(kg·min)", - fieldTag: "vo2_max", - valuePaths: [ - ...valuePaths(dt.filter, "milliliters_per_kilogram_per_minute"), - ...valuePaths(dt.filter, "value"), - ], - latestWins: true, // strictly positive, one daily reading; not a running sum - }); -} - -// ── Sleep ────────────────────────────────────────────────────── -// -// A Google Health sleep session carries a list of per-stage segments, each -// with a stage label + a start + end. HealthLog stores one SLEEP_DURATION row -// per stage with `measuredAt = stage END` (so the night-total + hypnogram -// readers consume the same enum WHOOP / Apple write). The stage labels are -// harmonised onto the shared `SleepStage` enum. - -/** Google Health sleep stage label → HealthLog `SleepStage`. */ -const FITBIT_SLEEP_STAGE_MAP: Record = { - // Canonical Google Health stage names (snake / lower variants accepted). - in_bed: "IN_BED", - inbed: "IN_BED", - awake: "AWAKE", - wake: "AWAKE", - light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) - core: "CORE", - rem: "REM", - deep: "DEEP", - // Fitbit's classic (non-stages) sleep log uses asleep/restless/wake. - asleep: "ASLEEP", - restless: "AWAKE", -} as const; - /** - * Normalise a raw Google sleep-stage label to a `SleepStage`, or null for an - * unknown label (skipped rather than mis-bucketed). + * Read a named array off a `{ key: [...] }` envelope; [] on any miss. Tolerates + * the endpoint returning a bare array (some endpoints do) by returning it + * directly when the key is absent but the body is itself an array. */ +function readArray(body: unknown, key: string): unknown[] { + if (body && typeof body === "object" && !Array.isArray(body)) { + const v = (body as Record)[key]; + if (Array.isArray(v)) return v; + } + if (Array.isArray(body)) return body; + return []; +} + +// ─── Field→Measurement mapping table (mirror of mapping.md) ───── + +export const FITBIT_FIELD_MAP: Record< + string, + { type: string; unit: string; note?: string } +> = { + weight: { + type: "WEIGHT", + unit: "kg", + note: "body/log/weight; picker ranks a real Withings scale above Fitbit", + }, + bodyFat: { type: "BODY_FAT", unit: "%", note: "body/log/fat" }, + oxygenSaturation: { + type: "OXYGEN_SATURATION", + unit: "%", + note: "spo2 summary value.avg", + }, + heartRateVariability: { + type: "HEART_RATE_VARIABILITY", + unit: "ms", + note: "hrv summary value.dailyRmssd (RMSSD estimator); canonical HRV slot", + }, + restingHeartRate: { + type: "RESTING_HEART_RATE", + unit: "bpm", + note: "activities/heart value.restingHeartRate", + }, + respiratoryRate: { + type: "RESPIRATORY_RATE", + unit: "breaths/min", + note: "br summary value.breathingRate", + }, + steps: { + type: "ACTIVITY_STEPS", + unit: "steps", + note: "activities/steps daily total; stats: externalId overwrites; 0 valid", + }, + distance: { + type: "WALKING_RUNNING_DISTANCE", + unit: "m", + note: "activities/distance (km → m); stats: externalId overwrites", + }, + activeCalories: { + type: "ACTIVE_ENERGY_BURNED", + unit: "kcal", + note: "activities/activityCalories (ACTIVE portion only); stats: overwrites", + }, + floors: { + type: "FLIGHTS_CLIMBED", + unit: "flights", + note: "activities/floors daily total; stats: externalId overwrites", + }, + vo2Max: { + type: "VO2_MAX", + unit: "mL/(kg·min)", + note: "cardioscore value.vo2Max (range → midpoint); daily latest-wins", + }, + sleep: { + type: "SLEEP_DURATION", + unit: "minutes", + note: "1.2 sleep levels.data per-segment rows; measuredAt = segment END", + }, + exercise: { + type: "Workout", + unit: "—", + note: "activities list → Workout row (NOT a Measurement); read-time dedup", + }, +}; + +// ── Sleep (1.2 sleep log) ────────────────────────────────────── +// +// GET /1.2/user/-/sleep/date/{start}/{end}.json +// → { sleep: [{ logId, startTime, endTime, levels: { data: [ +// { dateTime: "", level: "wake|light|deep|rem|asleep|restless|awake", seconds } ] } }] } +// One SLEEP_DURATION row per segment, `measuredAt = segment START + seconds` +// (the segment END), value = seconds → minutes. Stage labels harmonise onto the +// shared SleepStage enum. The timeline is MEASURED (real onsets), so rows are +// NOT flagged reconstructed. + +/** Classic Fitbit sleep `level` → HealthLog `SleepStage`. */ +const FITBIT_SLEEP_STAGE_MAP: Record = { + // Stages logs. + wake: "AWAKE", + awake: "AWAKE", + light: "CORE", // Fitbit "light" ↔ Apple "core" (same shallow-NREM band) + core: "CORE", + deep: "DEEP", + rem: "REM", + // Classic (non-stages) logs. + asleep: "ASLEEP", + restless: "AWAKE", + in_bed: "IN_BED", + inbed: "IN_BED", +} as const; + +/** Normalise a raw Fitbit sleep `level` to a `SleepStage`, or null if unknown. */ export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null { if (typeof raw !== "string") return null; const key = raw @@ -1188,127 +912,76 @@ export function mapFitbitSleepStage(raw: unknown): FitbitSleepStage | null { ); } -/** One sleep-stage segment pulled defensively off a Google sleep session. */ -interface FitbitSleepSegment { - stage: string; +interface FitbitSleepLevelEntry { + dateTime?: string; + level?: string; + seconds?: number; +} + +interface FitbitSleepSession { + logId?: number; startTime?: string; endTime?: string; -} - -/** Minutes between two ISO instants, or null if either is unparseable. */ -function minutesBetween(startIso?: string, endIso?: string): number | null { - if (!startIso || !endIso) return null; - const s = new Date(startIso).getTime(); - const e = new Date(endIso).getTime(); - if (Number.isNaN(s) || Number.isNaN(e) || e <= s) return null; - return (e - s) / 60_000; + levels?: { data?: FitbitSleepLevelEntry[] }; } /** - * Read the per-stage segments off a Google sleep `DataPoint`, tolerating the - * undocumented JSON shape. Candidate locations: `sleep.segments`, - * `sleep.stages`, `sleep.levels.data`, or a bare top-level `segments`/`stages`. + * The stable session anchor for a sleep externalId — the logId, else the end (or + * start) instant. A re-scored night reuses the same logId → overwrites in place. */ -function readSleepSegments(point: FitbitDataPoint): FitbitSleepSegment[] { - const candidates = [ - readPath(point, "sleep.segments"), - readPath(point, "sleep.stages"), - readPath(point, "sleep.levels.data"), - readPath(point, "segments"), - readPath(point, "stages"), - readPath(point, "levels.data"), - ]; - for (const c of candidates) { - if (!Array.isArray(c)) continue; - const out: FitbitSleepSegment[] = []; - for (const raw of c) { - if (!raw || typeof raw !== "object") continue; - const o = raw as Record; - const stage = - (typeof o.stage === "string" && o.stage) || - (typeof o.level === "string" && o.level) || - (typeof o.type === "string" && o.type) || - ""; - const startTime = - (typeof o.startTime === "string" && o.startTime) || - (typeof o.start_time === "string" && o.start_time) || - (typeof o.dateTime === "string" && o.dateTime) || - undefined; - const endTime = - (typeof o.endTime === "string" && o.endTime) || - (typeof o.end_time === "string" && o.end_time) || - undefined; - if (stage) out.push({ stage, startTime, endTime }); +function sleepSessionAnchor(s: FitbitSleepSession): string { + if (typeof s.logId === "number" && Number.isFinite(s.logId)) { + return String(s.logId); + } + for (const t of [s.endTime, s.startTime]) { + if (typeof t === "string") { + const d = parseLocalInstant(t); + if (d) return d.toISOString(); } - if (out.length > 0) return out; - } - return []; -} - -/** - * The stable session anchor for a sleep `DataPoint`'s externalId. The session - * end (or start) ISO instant is unique per night, so per-stage rows key as - * `:sleep_` — a re-scored night overwrites in place. - */ -function sleepSessionAnchor(point: FitbitDataPoint): string { - const end = - readPath(point, "sleep.interval.end_time") ?? - readPath(point, "interval.end_time") ?? - readPath(point, "sleep.endTime") ?? - readPath(point, "sleep.end_time") ?? - readPath(point, "endTime") ?? - readPath(point, "end_time"); - if (typeof end === "string") { - const d = new Date(end); - if (!Number.isNaN(d.getTime())) return d.toISOString(); - } - const start = - readPath(point, "sleep.interval.start_time") ?? - readPath(point, "interval.start_time") ?? - readPath(point, "sleep.startTime") ?? - readPath(point, "sleep.start_time") ?? - readPath(point, "startTime"); - if (typeof start === "string") { - const d = new Date(start); - if (!Number.isNaN(d.getTime())) return d.toISOString(); } return new Date(0).toISOString(); } /** - * Map one Google sleep session into per-SEGMENT `SLEEP_DURATION` rows. The - * Google sleep payload carries a real per-stage segment series (each with its - * own start/end), so one row is emitted PER SEGMENT — `measuredAt = that - * segment's END` — rather than collapsing a stage's segments onto a single - * lastEnd instant. The collapsed shape stamped every stage total on its last - * end, so the hypnogram reconstructed all of a stage's time as one block ending - * at the night's right edge (the stacked-bar artefact); per-segment rows lay - * each block at its true clock time. The timeline is MEASURED (real onsets), so - * these rows are NOT flagged reconstructed — unlike WHOOP, which has no onsets. - * + * Parse a Fitbit sleep timestamp. The 1.2 sleep log emits LOCAL wall-clock ISO + * strings WITHOUT an offset (e.g. `2020-01-26T03:02:30.000`); `new Date(...)` + * treats an offset-less ISO string as local time, which is the user's intent + * here (the night belongs to the user's local clock). Returns null on a miss. + */ +function parseLocalInstant(iso: string): Date | null { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; +} + +/** + * Map one Fitbit sleep session into per-SEGMENT `SLEEP_DURATION` rows. Each + * segment's `dateTime` is its START; END = START + seconds. value = minutes. * Each segment carries a stage-scoped, INDEXED fieldTag so the several segments * of one stage stay distinct under the `(userId, type, source, externalId)` - * dedup key. Unknown stage labels are skipped; a session with no parseable - * segment yields nothing. + * dedup key. Unknown stage labels are skipped. */ export function mapSleepSession( - point: FitbitDataPoint, + session: FitbitSleepSession, ): FitbitMappedMeasurement[] { - const segments = readSleepSegments(point); - if (segments.length === 0) return []; + const data = session.levels?.data; + if (!Array.isArray(data) || data.length === 0) return []; - const anchor = sleepSessionAnchor(point); + const anchor = sleepSessionAnchor(session); const out: FitbitMappedMeasurement[] = []; let segIndex = 0; - for (const seg of segments) { - const stage = mapFitbitSleepStage(seg.stage); + for (const seg of data) { + const stage = mapFitbitSleepStage(seg.level); if (!stage) continue; - const mins = minutesBetween(seg.startTime, seg.endTime); - if (mins === null || !(mins > 0)) continue; - const end = new Date(seg.endTime as string); + if (!positive(seg.seconds)) continue; + const startStr = typeof seg.dateTime === "string" ? seg.dateTime : null; + if (!startStr) continue; + const start = parseLocalInstant(startStr); + if (!start) continue; + const minutes = seg.seconds / 60; + const end = new Date(start.getTime() + seg.seconds * 1000); out.push({ type: "SLEEP_DURATION", - value: round2(mins), + value: round2(minutes), unit: "minutes", measuredAt: end, fieldTag: `${anchor}:sleep_${stage.toLowerCase()}:${segIndex}`, @@ -1319,13 +992,30 @@ export function mapSleepSession( return out; } -// ── Workouts (exercise sessions) ─────────────────────────────── +/** Read the sleep-session array off the 1.2 sleep-log envelope. */ +export function readSleepSessions(body: unknown): FitbitSleepSession[] { + return readArray(body, "sleep") as FitbitSleepSession[]; +} -/** - * Google Health exercise-activity-type → HealthLog `WorkoutSportType`. Unknown - * types fall through to a generic label; the column is free-text so an unmapped - * type still persists (just not under a canonical sport bucket). - */ +// ── Workouts (activities list) ───────────────────────────────── +// +// GET /1/user/-/activities/list.json?afterDate=...&sort=asc&offset=0&limit=100 +// → { activities: [{ logId, activityName/activityTypeId, startTime, +// duration(ms), calories, distance(km), averageHeartRate, ... }] } +// Each becomes a Workout row keyed on `(userId, source, externalId=logId)`. + +interface FitbitActivityLogEntry { + logId?: number; + activityName?: string; + activityTypeId?: number; + startTime?: string; + duration?: number; // milliseconds + calories?: number; + distance?: number; // km (metric locale) + averageHeartRate?: number; +} + +/** Classic Fitbit activity name → HealthLog `WorkoutSportType` label. */ const FITBIT_EXERCISE_TYPE_MAP: Record = { walk: "walking", walking: "walking", @@ -1360,7 +1050,7 @@ const FITBIT_EXERCISE_TYPE_MAP: Record = { sport: "mixedCardio", } as const; -/** Resolve a Google exercise activity type to a canonical sport label. */ +/** Resolve a Fitbit activity name to a canonical sport label. */ export function mapFitbitSportType(raw: unknown): string { if (typeof raw !== "string" || raw.trim() === "") return "other"; const key = raw @@ -1374,7 +1064,7 @@ export function mapFitbitSportType(raw: unknown): string { ); } -/** One mapped Fitbit exercise session destined for a `Workout` row. */ +/** One mapped Fitbit activity destined for a `Workout` row. */ export interface FitbitMappedWorkout { externalId: string; sportType: string; @@ -1388,112 +1078,192 @@ export interface FitbitMappedWorkout { minHeartRate: number | null; } -/** Read a finite number off a list of candidate paths (any sign), or null. */ -function readNumber(point: FitbitDataPoint, paths: string[]): number | null { - for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "number" && Number.isFinite(v)) return v; - } - return null; -} - -/** Read an ISO/string instant off a list of candidate paths, or null. */ -function readInstant(point: FitbitDataPoint, paths: string[]): Date | null { - for (const path of paths) { - const v = readPath(point, path); - if (typeof v === "string") { - const d = new Date(v); - if (!Number.isNaN(d.getTime())) return d; - } - } - return null; -} - /** - * Map one Google exercise session `DataPoint` into a `Workout` shape. Returns - * null when there is no usable start/end (a session with no time span is not a - * workout). The externalId anchors on the session id when present, else on the - * start instant, so a re-fetch overwrites the same `Workout` row in place. - * Energy is the active session energy in kcal; HR fields are optional. + * Map one Fitbit activity-list entry into a `Workout` shape. Returns null when + * there is no usable start + positive duration. The externalId anchors on the + * `logId` (stable) so a re-fetch overwrites the same row. The classic + * activities-list endpoint does not surface min/max HR, so those stay null. */ -export function mapWorkout(point: FitbitDataPoint): FitbitMappedWorkout | null { - const f = FITBIT_DATA_TYPES.exercise.filter; - const startedAt = readInstant(point, [ - `${f}.interval.start_time`, - `${f}.startTime`, - `${f}.start_time`, - `${f}.sample_time.physical_time`, - "interval.start_time", - "startTime", - "start_time", - ]); - const endedAt = readInstant(point, [ - `${f}.interval.end_time`, - `${f}.endTime`, - `${f}.end_time`, - "interval.end_time", - "endTime", - "end_time", - ]); - if (!startedAt || !endedAt || endedAt <= startedAt) return null; +export function mapWorkout( + entry: FitbitActivityLogEntry, +): FitbitMappedWorkout | null { + const start = + typeof entry.startTime === "string" + ? parseLocalInstant(entry.startTime) + : null; + if (!start) return null; + if (!positive(entry.duration)) return null; - const durationSec = Math.round( - (endedAt.getTime() - startedAt.getTime()) / 1000, - ); + const durationSec = Math.round(entry.duration / 1000); + const endedAt = new Date(start.getTime() + entry.duration); - const sessionId = - (typeof readPath(point, `${f}.session_id`) === "string" && - (readPath(point, `${f}.session_id`) as string)) || - (typeof readPath(point, `${f}.id`) === "string" && - (readPath(point, `${f}.id`) as string)) || - (typeof readPath(point, "name") === "string" && - (readPath(point, "name") as string)) || - null; - const externalId = sessionId ?? `exercise:${startedAt.toISOString()}`; + const externalId = + typeof entry.logId === "number" && Number.isFinite(entry.logId) + ? String(entry.logId) + : `exercise:${start.toISOString()}`; - const sportRaw = - readPath(point, `${f}.activity_type`) ?? - readPath(point, `${f}.exercise_type`) ?? - readPath(point, `${f}.type`) ?? - readPath(point, "activityType"); - - const energyKcal = readNumber(point, [ - `${f}.active_kilocalories`, - `${f}.calories`, - `${f}.energy.kilocalories`, - ]); - const distanceM = readNumber(point, [ - `${f}.distance.meters`, - `${f}.distance_meters`, - `${f}.distance`, - ]); - const avgHr = readNumber(point, [ - `${f}.average_heart_rate.beats_per_minute`, - `${f}.average_heart_rate`, - `${f}.heart_rate.average`, - ]); - const maxHr = readNumber(point, [ - `${f}.maximum_heart_rate.beats_per_minute`, - `${f}.max_heart_rate`, - `${f}.heart_rate.maximum`, - ]); - const minHr = readNumber(point, [ - `${f}.minimum_heart_rate.beats_per_minute`, - `${f}.min_heart_rate`, - `${f}.heart_rate.minimum`, - ]); + const energyKcal = positive(entry.calories) + ? Math.round(entry.calories) + : null; + // distance is km under the metric locale → metres. + const distanceM = + typeof entry.distance === "number" && entry.distance >= 0 + ? round2(entry.distance * 1000) + : null; + const avgHr = positive(entry.averageHeartRate) + ? Math.round(entry.averageHeartRate) + : null; return { externalId, - sportType: mapFitbitSportType(sportRaw), - startedAt, + sportType: mapFitbitSportType(entry.activityName), + startedAt: start, endedAt, durationSec, - totalEnergyKcal: energyKcal !== null ? Math.round(energyKcal) : null, - totalDistanceM: - distanceM !== null && distanceM >= 0 ? round2(distanceM) : null, - avgHeartRate: avgHr !== null && avgHr > 0 ? Math.round(avgHr) : null, - maxHeartRate: maxHr !== null && maxHr > 0 ? Math.round(maxHr) : null, - minHeartRate: minHr !== null && minHr > 0 ? Math.round(minHr) : null, + totalEnergyKcal: energyKcal, + totalDistanceM: distanceM, + avgHeartRate: avgHr, + maxHeartRate: null, + minHeartRate: null, }; } + +/** Read the activity-list array off the activities/list envelope. */ +export function readActivityList(body: unknown): FitbitActivityLogEntry[] { + return readArray(body, "activities") as FitbitActivityLogEntry[]; +} + +// ─── Per-endpoint fetchers ───────────────────────────────────── +// +// Each returns the raw parsed body for its date-range window; the per-resource +// sync layer maps it. A single request per endpoint per chunk keeps the 150/h +// budget healthy. + +export async function fetchWeightRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/body/log/weight/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchWeight", + ); +} + +export async function fetchBodyFatRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/body/log/fat/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchBodyFat", + ); +} + +export async function fetchSpo2Range( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/spo2/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchSpo2", + ); +} + +export async function fetchHrvRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/hrv/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchHrv", + ); +} + +export async function fetchRestingHeartRateRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/activities/heart/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchRhr", + ); +} + +export async function fetchRespiratoryRateRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/br/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchRespiratoryRate", + ); +} + +export async function fetchVo2MaxRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/cardioscore/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchVo2Max", + ); +} + +/** Activity time-series for one cumulative resource (steps/distance/...). */ +export async function fetchActivitySeries( + resource: "steps" | "distance" | "floors" | "activityCalories", + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1/user/-/activities/${resource}/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + `fetchActivity:${resource}`, + ); +} + +export async function fetchSleepRange( + accessToken: string, + start: Date, + end: Date, +): Promise { + return fitbitGet( + `/1.2/user/-/sleep/date/${fitbitDate(start)}/${fitbitDate(end)}.json`, + accessToken, + "fetchSleep", + ); +} + +/** + * Activity log list since a date. Uses the offset/limit list endpoint with + * `afterDate` + `sort=asc`. One page of up to `limit` activities; the sync layer + * caps the page count to stay within the rate budget. + */ +export async function fetchActivityList( + accessToken: string, + afterDate: Date, + opts: { limit?: number; offset?: number } = {}, +): Promise { + const limit = opts.limit ?? 100; + const offset = opts.offset ?? 0; + return fitbitGet( + `/1/user/-/activities/list.json?afterDate=${fitbitDate(afterDate)}&sort=asc&offset=${offset}&limit=${limit}`, + accessToken, + "fetchExercise", + ); +} diff --git a/src/lib/fitbit/mapping.md b/src/lib/fitbit/mapping.md index 804db05bc..5f73a452c 100644 --- a/src/lib/fitbit/mapping.md +++ b/src/lib/fitbit/mapping.md @@ -1,111 +1,117 @@ -# Fitbit / Google Health field → Measurement mapping +# Fitbit Web API field → Measurement mapping -Single source of truth for the Fitbit (Google Health API v4) data-type → +Single source of truth for the classic Fitbit Web API (`api.fitbit.com`) → HealthLog `Measurement` mapping. Keep this in sync with `FITBIT_FIELD_MAP` and the per-type mappers in `client.ts`. Every Fitbit row ingests server-side with -`source = FITBIT` and `externalId = :` (the anchor is the data -point's sample time for spot readings, its civil date for daily summaries, or its -`interval.start_time` for INTERVAL types — steps/distance/calories/floors, sleep, -exercise; the field-tag disambiguates the metric). +`source = FITBIT`; the `externalId` is a stable per-reading key so a re-fetch of +the same window overwrites in place rather than minting a duplicate. -## Time anchor by grain (design §A.1) +Transport: v1.20.0 retargeted Fitbit from the Google Health API (Restricted +scopes behind brand-verification + CASA — a self-hoster adoption wall) onto the +classic Web API. A self-hoster registers an app at **dev.fitbit.com** in minutes +(no CASA). Auth: Authorization Code + **PKCE (S256)**; +`www.fitbit.com/oauth2/authorize` + `api.fitbit.com/oauth2/token`. Refresh tokens +**rotate** (one-time use) — the new token must be persisted every refresh. -Google Health exposes three time-anchor shapes. The incremental `filter` and the -read-time `measuredAt`/externalId resolution must target the right one or the -incremental sync stalls (a `sample_time` filter 400s/empties for an INTERVAL type): +Deprecation note: the classic Web API is announced for deprecation in September +2026, with migration directed back at the Google Health API. Until a self-serve +Google path exists, the classic API is the only viable transport for +self-hosters; the settings card keeps its experimental badge. -- **sample** — spot reading; anchor `{filter}.sample_time.physical_time`. -- **date** — daily-summary metric (SpO2, HRV, RHR, respiratory rate, wrist temp, - VO2 max); anchor `{filter}.date` (a civil date / `{year,month,day}`). -- **interval** — INTERVAL type (steps/distance/calories/floors daily totals, plus - sleep + exercise sessions); anchor `{filter}.interval.start_time` (a physical - instant) with `{filter}.interval.civil_start_time` as the civil fallback. +All data reads send `Accept-Language: en_GB` so weight + distance arrive in +metric (kg / km); the US default is imperial. -Transport docs: `developers.google.com/health` (v4, post-Fitbit-Web-API). The -per-type value-field JSON is **NOT fully published** — the mappers read a small -set of candidate value paths defensively (`firstNumber` walks each shape and -takes the first finite-positive hit). **Re-verify against a live test account at -build** and tighten the extractors. Design: `.planning/v1.12.0-google-health-design.md`. +## Health-metrics endpoints -## Casing gotcha (design §A.1) +Scope: `weight`, `oxygen_saturation`, `heartrate`, `respiratory_rate`. Verified +against the dev.fitbit.com reference (response shapes quoted there). -The data-type id is **kebab-case in the request path** (`body-fat`) and -**snake_case in the `filter` predicate** (`body_fat`). `FITBIT_DATA_TYPES` pins -both forms per type so a fetcher can never encode the wrong one. +| Endpoint | MeasurementType | Unit | Source field | Grain | Note | +| ---------------------------------------------- | ------------------------ | ----------- | ------------------------------------------- | ----- | ----------------------------------------------------------- | +| `/1/user/-/body/log/weight/date/{s}/{e}.json` | `WEIGHT` | kg | `weight[].weight` | log | externalId anchors on `logId`. Picker ranks Withings above. | +| `/1/user/-/body/log/fat/date/{s}/{e}.json` | `BODY_FAT` | % | `fat[].fat` | log | externalId anchors on `logId`. | +| `/1/user/-/spo2/date/{s}/{e}.json` | `OXYGEN_SATURATION` | % | `[].value.avg` (bare array) | daily | day-keyed externalId. | +| `/1/user/-/hrv/date/{s}/{e}.json` | `HEART_RATE_VARIABILITY` | ms | `hrv[].value.dailyRmssd` | daily | RMSSD estimator → canonical HRV slot (not `HRV_RMSSD`). | +| `/1/user/-/activities/heart/date/{s}/{e}.json` | `RESTING_HEART_RATE` | bpm | `activities-heart[].value.restingHeartRate` | daily | rows with no resting HR for the day are skipped. | +| `/1/user/-/br/date/{s}/{e}.json` | `RESPIRATORY_RATE` | breaths/min | `br[].value.breathingRate` | daily | | -## Health-metrics bundle (W3) +Each daily-summary value passes a finite + strictly-positive guard (a 0/NaN is a +garbage/empty reading and is dropped). Each endpoint caps its own date range +(30–31 days for these), so the sync chunks `[start, end]` into ≤30-day windows. -Scope: `googlehealth.health_metrics_and_measurements.readonly`. +## Activity + VO2 max endpoints -| Data type (path / filter) | MeasurementType | Unit | fieldTag | Grain | Note | -| ----------------------------------------------------------------------------- | ------------------------ | ----------- | ------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `weight` / `weight` | `WEIGHT` | kg | `weight` | sample | Picker ranks a real Withings scale above Fitbit. | -| `body-fat` / `body_fat` | `BODY_FAT` | % | `body_fat` | sample | | -| `daily-oxygen-saturation` / `daily_oxygen_saturation` | `OXYGEN_SATURATION` | % | `spo2` | daily | Already 0–100. | -| `daily-heart-rate-variability` / `daily_heart_rate_variability` | `HEART_RATE_VARIABILITY` | ms | `hrv` | daily | **Decision:** SDNN slot (Apple-comparable), NOT `HRV_RMSSD`. Fitbit reports an RMSSD-style nightly HRV — confirm the estimator at build and reconsider `HRV_RMSSD` if warranted. | -| `daily-resting-heart-rate` / `daily_resting_heart_rate` | `RESTING_HEART_RATE` | bpm | `rhr` | daily | | -| `daily-respiratory-rate` / `daily_respiratory_rate` | `RESPIRATORY_RATE` | breaths/min | `resp_rate` | daily | | -| `heart-rate` / `heart_rate` | `PULSE` | bpm | `hr` | sample | Intraday spot HR. | -| `daily-sleep-temperature-derivations` / `daily_sleep_temperature_derivations` | `WRIST_TEMPERATURE` | celsius | `wrist_temp` | daily | Closest semantic slot to Apple sleeping-wrist-temp. Confirm absolute-vs-baseline at build; the positive guard rejects a baseline-delta reading. | -| `height` / `height` | `User.heightCm` | cm | — | sample | Profile seed — written to `User.heightCm` ONLY when null; never minted as a Measurement (WHOOP `mapBody` pattern). m→cm when reported in metres. | +Scope: `activity`, `cardio_fitness`. Per-day summaries; the externalId carries +the `stats:` daily-total prefix — `stats::` — so a +re-fetched day **overwrites** in place (Apple-Health `stats::` +contract). The running totals **preserve a 0** (a rest day is real data); VO2 max +stays strictly positive. -Every value passes a finite + strictly-positive guard (the launch metrics are -all positive — a zero/NaN is a garbage/empty reading and is dropped). +| Endpoint | MeasurementType | Unit | Source field | Note | +| --------------------------------------------------------- | -------------------------- | ----------- | ------------------------------------- | ------------------------------------------------------------ | +| `/1/user/-/activities/steps/date/{s}/{e}.json` | `ACTIVITY_STEPS` | steps | `activities-steps[].value` (string) | daily total; 0 valid | +| `/1/user/-/activities/distance/date/{s}/{e}.json` | `WALKING_RUNNING_DISTANCE` | m | `activities-distance[].value` | km → m (×1000) under the metric locale | +| `/1/user/-/activities/activityCalories/date/{s}/{e}.json` | `ACTIVE_ENERGY_BURNED` | kcal | `activities-activityCalories[].value` | ACTIVE portion only (excludes BMR) | +| `/1/user/-/activities/floors/date/{s}/{e}.json` | `FLIGHTS_CLIMBED` | flights | `activities-floors[].value` | daily total; 0 valid | +| `/1/user/-/cardioscore/date/{s}/{e}.json` | `VO2_MAX` | mL/(kg·min) | `cardioScore[].value.vo2Max` | **string** — a single number or a `"lo-hi"` range → midpoint | -## Deferred (Google Health Q3-2026 roadmap — slots exist, NOT in launch set) +Activity time-series values arrive as numeric **strings** (`"2504"`). VO2 max +arrives as a string that may be a single value (`"45"`) or a range (`"44-48"`, +when no GPS run data exists) — the range resolves to its midpoint. -`blood-glucose` → `BLOOD_GLUCOSE`, `blood-pressure` → `BLOOD_PRESSURE_SYS`+`_DIA`, -`basal-body-temperature` → `BODY_TEMPERATURE`. These light up for free when -Google ships the data types. +## Sleep endpoint -## Activity bundle (W5) — daily cumulative +Scope: `sleep`. `/1.2/user/-/sleep/date/{s}/{e}.json` → +`sleep[].levels.data[]` (`{ dateTime, level, seconds }`). One `SLEEP_DURATION` +row **per segment**, `measuredAt = segment START + seconds = segment END`, value = +seconds → minutes. The 1.2 log carries a real per-segment series, so the rows lay +each block at its true clock time (a MEASURED timeline — rows are NOT flagged +reconstructed). externalId = `:sleep_:` (indexed so several +segments of one stage stay distinct), so a re-scored night overwrites in place. +The segment `dateTime` is local wall-clock ISO without an offset (the night +belongs to the user's local clock) — parsed as local time. -Scope: `googlehealth.activity_and_fitness.readonly`. Each is a per-day summary -(one value per calendar day). The externalId carries the `stats:` daily-total -prefix — `stats::` — so a re-fetched day **overwrites** in -place rather than minting a duplicate, matching the Apple-Health -`stats::` daily-total overwrite contract. The running totals -(steps/distance/floors/active-energy) **preserve a 0** (a rest day is real data, -not a gap); VO2 max stays strictly positive (daily latest-wins). +Stage map (stages logs + classic logs): `light → CORE` (Fitbit "light" ↔ Apple +"core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `wake`/`awake`/`restless → +AWAKE`, classic `asleep → ASLEEP`, `in_bed → IN_BED`. Unknown labels are skipped, +not mis-bucketed. -| Data type (path / filter) | MeasurementType | Unit | fieldTag | Note | -| ------------------------------------- | -------------------------- | ----------- | ----------------- | -------------------------------------------------------------------- | -| `steps` / `steps` | `ACTIVITY_STEPS` | steps | `steps` | daily total; 0 valid | -| `distance` / `distance` | `WALKING_RUNNING_DISTANCE` | m | `distance` | metres (km → m when reported in km) | -| `active-calories` / `active_calories` | `ACTIVE_ENERGY_BURNED` | kcal | `active_calories` | **ACTIVE portion only** — NOT total caloriesOut (which folds in BMR) | -| `floors` / `floors` | `FLIGHTS_CLIMBED` | flights | `floors` | daily total; 0 valid | -| `vo2-max` / `vo2_max` | `VO2_MAX` | mL/(kg·min) | `vo2_max` | daily latest-wins; strictly positive | +## Workouts (activity log list) -## Sleep bundle (W5) +Scope: `activity`. `/1/user/-/activities/list.json?afterDate=...&sort=asc&offset&limit` +→ `activities[]`. Each → one `Workout` row (NOT a Measurement), keyed +`(userId, source: "FITBIT", externalId=logId)`. Fields: `sportType` +(`activityName` → canonical `WorkoutSportType`, `other` fallback), +`startedAt`/`endedAt` (`startTime` + `duration` ms), `durationSec`, +`totalEnergyKcal` (`calories`), `totalDistanceM` (`distance` km → m), +`avgHeartRate` (`averageHeartRate`). The classic list endpoint does NOT surface +min/max HR, so those stay null. A Fitbit run and the same run via Apple Health / +WHOOP stay distinct rows; the read-time `pickCanonicalWorkoutRows` picker +collapses the cross-source twin (FITBIT ranks just below WHOOP in the default +ladder). -Scope: `googlehealth.sleep.readonly`. A sleep session carries per-stage segments -(stage label + start + end). HealthLog stores one `SLEEP_DURATION` row per stage -(summed minutes), `measuredAt = the stage's latest END instant`, harmonised onto -the shared `SleepStage` enum the night-total + hypnogram readers consume. -externalId = `:sleep_` (session end ISO instant), so a -re-scored night overwrites in place. Stage map: `light → CORE` (Fitbit "light" ↔ -Apple "core" shallow-NREM band), `deep → DEEP`, `rem → REM`, `awake`/`wake`/ -`restless → AWAKE`, `in_bed → IN_BED`, classic `asleep → ASLEEP`. Unknown stage -labels are skipped, not mis-bucketed. +## Deliberately skipped -## Exercise bundle (W5) — Workouts - -Scope: `googlehealth.activity_and_fitness.readonly`. Each exercise session → one -`Workout` row (NOT a Measurement), keyed `(userId, source: "FITBIT", -externalId)` where externalId is the session id (or `exercise:` when -absent). Fields: sportType (Google activity type → canonical `WorkoutSportType`, -`other` fallback), startedAt/endedAt, durationSec, totalEnergyKcal (active -session energy), totalDistanceM, avg/max/min HR (optional). A Fitbit run and the -same run via Apple Health / WHOOP stay distinct rows; the read-time -`pickCanonicalWorkoutRows` picker collapses the cross-source twin (FITBIT ranks -just below WHOOP in the default ladder). `active-zone-minutes` is **skipped** at -launch (no slot; not blocking). +- **Intraday heart rate → `PULSE`.** The classic API gates intraday HR + step + series behind a Personal-app type or a per-app intraday access request at + dev.fitbit.com that most self-hosters do not have. Daily resting HR is always + available and is synced; intraday is left out so a missing grant never parks + the connection. (A per-endpoint 403 also soft-skips, as a safety net.) +- **Skin temperature.** `/1/user/-/temp/skin/...` returns + `value.nightlyRelative` — a **delta from the user's personal baseline**, not an + absolute temperature. Mapping a baseline delta into the absolute + `WRIST_TEMPERATURE`/`BODY_TEMPERATURE` slot would write misleading values, so + the metric is **not mapped** (and `temperature` is not requested). Revisit only + if a relative-temperature slot is added. +- **Blood glucose / blood pressure.** No classic self-serve endpoint maps cleanly + to the HealthLog slots at launch. ## Idempotency -`(userId, type, source: "FITBIT", externalId)` unique. Spot/daily metric rows: -`externalId = :`. Daily cumulative activity rows: -`externalId = stats::` (Apple-Health overwrite shape). -Workouts: `(userId, source: "FITBIT", externalId)` on the `Workout` table. A -re-fetch of the same window (daily summaries re-roll after the fact, so the -incremental overlap is 24 h) overwrites in place. +`(userId, type, source: "FITBIT", externalId)` unique. Log-anchored metric rows: +`externalId = :`. Daily-summary rows: `externalId = +:`. Daily cumulative activity rows: `externalId = +stats::` (Apple-Health overwrite shape). Sleep: +`:sleep_:`. Workouts: `(userId, source: "FITBIT", externalId)` +on the `Workout` table. A re-fetch of the same window (daily summaries settle +after the fact, so the incremental overlap is 24 h) overwrites in place. diff --git a/src/lib/fitbit/oauth-state.ts b/src/lib/fitbit/oauth-state.ts index 2985c722f..215a49d7b 100644 --- a/src/lib/fitbit/oauth-state.ts +++ b/src/lib/fitbit/oauth-state.ts @@ -1,6 +1,8 @@ /** - * v1.12.0 — shared constants + mint helper for the Fitbit / Google Health OAuth - * state nonce ledger. Mirrors `src/lib/whoop/oauth-state.ts`. + * Shared constants + mint helper for the Fitbit OAuth state nonce ledger. + * Mirrors `src/lib/whoop/oauth-state.ts`. (v1.20.0 — the PKCE `code_verifier` + * lives on the `FitbitOAuthState` row; the connect route mints it via the + * client's `generatePkcePair`.) * * The nonce is random + opaque; a short-lived `FitbitOAuthState` row carries * the `(nonce → userId)` mapping so the user id never leaks into request logs @@ -19,7 +21,7 @@ export const FITBIT_OAUTH_STATE_COOKIE = "fitbit_state" as const; /** * 10-minute TTL on the state row. Matches the cookie `maxAge`. Long enough to - * cover a user approving the Google consent prompt; short enough that an + * cover a user approving the Fitbit consent prompt; short enough that an * abandoned handshake doesn't strand a row for hours. */ export const FITBIT_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; diff --git a/src/lib/fitbit/sync-activity.ts b/src/lib/fitbit/sync-activity.ts index 7806f252d..d9dec20db 100644 --- a/src/lib/fitbit/sync-activity.ts +++ b/src/lib/fitbit/sync-activity.ts @@ -1,32 +1,29 @@ /** - * Fitbit / Google Health activity-bundle sync (v1.12.0, W5). + * Fitbit Web API activity-bundle sync. * - * Reads the daily cumulative-activity data types from the - * `activity_and_fitness.readonly` Restricted bundle and upserts each mapped - * daily total as `source = FITBIT`: + * Reads the daily cumulative-activity time series + VO2 max from the classic + * Fitbit Web API and upserts each mapped daily total as `source = FITBIT`: * - * - steps → ACTIVITY_STEPS (count) - * - distance → WALKING_RUNNING_DISTANCE (metres) - * - active-calories → ACTIVE_ENERGY_BURNED (kcal — ACTIVE portion only) - * - floors → FLIGHTS_CLIMBED (count) - * - vo2-max → VO2_MAX (mL/(kg·min); daily latest-wins) + * - activities/steps → ACTIVITY_STEPS (count) + * - activities/distance → WALKING_RUNNING_DISTANCE (metres, km→m) + * - activities/activityCalories → ACTIVE_ENERGY_BURNED (kcal, ACTIVE only) + * - activities/floors → FLIGHTS_CLIMBED (count) + * - cardioscore → VO2_MAX (mL/(kg·min)) * * These are per-day summaries (one value per calendar day). The externalId is * minted with the `stats:` daily-total prefix — `stats::` - * — so a re-fetched day OVERWRITES the existing row rather than minting a - * duplicate, matching the Apple-Health `stats::` overwrite - * contract. A day of rest legitimately records 0 steps / 0 floors / 0 active - * kcal, so the cumulative mappers preserve a zero (dropping it would leave a - * hole the chart misreads as missing data); VO2 max stays strictly positive. + * — so a re-fetched day OVERWRITES the existing row, matching the Apple-Health + * `stats::` overwrite contract. A rest day records a real 0 + * (dropping it would leave a chart gap); VO2 max stays strictly positive. * - * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection - * connected) — the six Restricted bundles are granted independently. + * Each endpoint is a date-RANGE call chunked into ≤30-day windows. A per-endpoint + * 403 soft-skips THAT metric only. */ import { - FITBIT_DATA_TYPES, - type FitbitDataType, + FITBIT_RANGE_DAYS, type FitbitMappedMeasurement, - fetchDataPoints, + fetchActivitySeries, + fetchVo2MaxRange, mapActiveCalories, mapDistance, mapFloors, @@ -34,6 +31,7 @@ import { mapVo2Max, } from "./client"; import { + chunkDateRanges, getValidToken, handleCollectionFetchError, upsertFitbitMeasurements, @@ -42,34 +40,42 @@ import { } from "./sync"; import { annotate } from "@/lib/logging/context"; -/** One mappable activity metric: its data-type encoding + the per-point mapper + a verb. */ +/** One mappable activity metric: a range fetcher + a body mapper + a verb. */ interface ActivityResource { - dataType: FitbitDataType; - map: (point: Record) => FitbitMappedMeasurement[]; + fetch: (accessToken: string, start: Date, end: Date) => Promise; + map: (body: unknown) => FitbitMappedMeasurement[]; verb: string; } const ACTIVITY_RESOURCES: ActivityResource[] = [ - { dataType: FITBIT_DATA_TYPES.steps, map: mapSteps, verb: "fetchSteps" }, { - dataType: FITBIT_DATA_TYPES.distance, + fetch: (t, s, e) => fetchActivitySeries("steps", t, s, e), + map: mapSteps, + verb: "fetchSteps", + }, + { + fetch: (t, s, e) => fetchActivitySeries("distance", t, s, e), map: mapDistance, verb: "fetchDistance", }, { - dataType: FITBIT_DATA_TYPES.activeCalories, + fetch: (t, s, e) => fetchActivitySeries("activityCalories", t, s, e), map: mapActiveCalories, verb: "fetchActiveCalories", }, - { dataType: FITBIT_DATA_TYPES.floors, map: mapFloors, verb: "fetchFloors" }, - { dataType: FITBIT_DATA_TYPES.vo2Max, map: mapVo2Max, verb: "fetchVo2Max" }, + { + fetch: (t, s, e) => fetchActivitySeries("floors", t, s, e), + map: mapFloors, + verb: "fetchFloors", + }, + { fetch: fetchVo2MaxRange, map: mapVo2Max, verb: "fetchVo2Max" }, ]; /** * Assemble the stored externalId for a mapped activity reading. A cumulative - * daily metric carries the `cumulativeDaily` flag from its mapper — its - * externalId gets the `stats:` daily-total prefix so a re-fetched day overwrites - * in place (the mapper's `fieldTag` is already the `:` form). + * daily metric carries the `cumulativeDaily` flag — its externalId gets the + * `stats:` daily-total prefix so a re-fetched day overwrites in place (the + * mapper's `fieldTag` is already the `:` form). */ function externalIdFor(m: FitbitMappedMeasurement): string { return m.cumulativeDaily ? `stats:${m.fieldTag}` : m.fieldTag; @@ -82,28 +88,28 @@ export async function syncUserActivity( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; - // Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a - // full/backfill run. - const start = opts.start; + const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = opts.end ?? new Date(); + const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS); let imported = 0; for (const resource of ACTIVITY_RESOURCES) { - let points: Record[]; - try { - points = await fetchDataPoints( - resource.dataType, - tokenInfo.accessToken, - resource.verb, - { start }, - ); - } catch (err) { - imported += await handleCollectionFetchError(resource.verb, userId, err); - continue; - } - const readings: FitbitMeasurementUpsert[] = []; - for (const point of points) { - for (const m of resource.map(point)) { + let forbidden = false; + for (const w of windows) { + let body: unknown; + try { + body = await resource.fetch(tokenInfo.accessToken, w.start, w.end); + } catch (err) { + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; + } + for (const m of resource.map(body)) { readings.push({ type: m.type, value: m.value, @@ -113,6 +119,7 @@ export async function syncUserActivity( }); } } + if (forbidden) continue; imported += ( await upsertFitbitMeasurements(userId, readings, { deferRollup: opts.deferRollup, diff --git a/src/lib/fitbit/sync-metrics.ts b/src/lib/fitbit/sync-metrics.ts index c772c360a..f05ae2fb8 100644 --- a/src/lib/fitbit/sync-metrics.ts +++ b/src/lib/fitbit/sync-metrics.ts @@ -1,100 +1,74 @@ /** - * Fitbit / Google Health "health metrics & measurements" bundle sync (v1.12.0). + * Fitbit Web API health-metrics sync. * - * Reads the launch metric data types from the - * `health_metrics_and_measurements.readonly` Restricted bundle and upserts each - * mapped reading as `source = FITBIT`: + * Reads the launch metric endpoints from the classic Fitbit Web API and upserts + * each mapped reading as `source = FITBIT`: * - * - weight → WEIGHT - * - body-fat → BODY_FAT - * - daily-oxygen-saturation→ OXYGEN_SATURATION - * - daily-HRV → HEART_RATE_VARIABILITY (SDNN slot; see mapper) - * - daily-resting-HR → RESTING_HEART_RATE - * - daily-respiratory-rate → RESPIRATORY_RATE - * - heart-rate (intraday) → PULSE - * - daily sleep-temp deriv.→ WRIST_TEMPERATURE - * - height → User.heightCm (profile seed, NOT a Measurement) + * - body/log/weight → WEIGHT (kg) + * - body/log/fat → BODY_FAT (%) + * - spo2 summary → OXYGEN_SATURATION (%) + * - hrv summary → HEART_RATE_VARIABILITY (ms, dailyRmssd) + * - activities/heart → RESTING_HEART_RATE (bpm) + * - br summary → RESPIRATORY_RATE (breaths/min) * - * Each data point yields at most one Measurement row, disambiguated by the - * per-point anchor + field-tag in the externalId (`:`) so a - * re-fetch of the same window overwrites in place rather than duplicating. + * Each endpoint is a date-RANGE call (one request per ≤30-day window), so the + * sync chunks `[start, end]` into per-endpoint windows to respect the API range + * caps and the 150 req/h budget. Each metric is fetched independently so a + * per-endpoint 403 (a scope the user did not grant) soft-skips only that metric. * - * A per-data-class 403 soft-skips THAT class (returns 0, leaves the connection - * connected) rather than parking the whole integration — the six Restricted - * bundles can be granted independently in the Google consent flow. - * - * Activity / sleep / workout land in W5; this is the first (and W3-only) resource. + * NOTE: intraday heart rate (`PULSE`) is intentionally NOT synced — the classic + * API gates intraday series behind a Personal-app / explicit per-app grant most + * self-hosters do not have, and the daily resting HR is always available. Skin + * temperature is also skipped: the classic reading is a baseline DELTA, not an + * absolute value, so it has no honest canonical slot (see mapping.md). */ import { - FITBIT_DATA_TYPES, - type FitbitDataType, + FITBIT_RANGE_DAYS, type FitbitMappedMeasurement, - fetchDataPoints, + fetchBodyFatRange, + fetchHrvRange, + fetchRespiratoryRateRange, + fetchRestingHeartRateRange, + fetchSpo2Range, + fetchWeightRange, mapBodyFat, - mapHeartRate, mapHeartRateVariability, - mapHeightCm, mapOxygenSaturation, mapRespiratoryRate, mapRestingHeartRate, - mapSleepTemperature, mapWeight, } from "./client"; import { + chunkDateRanges, getValidToken, handleCollectionFetchError, upsertFitbitMeasurements, type FitbitMeasurementUpsert, type FitbitResourceSyncOptions, } from "./sync"; -import { getEvent } from "@/lib/logging/context"; -import { prisma } from "@/lib/db"; -/** One mappable metric: its data-type encoding + the per-point mapper + a verb. */ +/** One mappable metric: a range fetcher + a body mapper + a verb. */ interface MetricResource { - dataType: FitbitDataType; - map: (point: Record) => FitbitMappedMeasurement[]; + fetch: (accessToken: string, start: Date, end: Date) => Promise; + map: (body: unknown) => FitbitMappedMeasurement[]; verb: string; } -/** The launch metric resources (Measurement-producing). Height is handled separately. */ const METRIC_RESOURCES: MetricResource[] = [ - { dataType: FITBIT_DATA_TYPES.weight, map: mapWeight, verb: "fetchWeight" }, + { fetch: fetchWeightRange, map: mapWeight, verb: "fetchWeight" }, + { fetch: fetchBodyFatRange, map: mapBodyFat, verb: "fetchBodyFat" }, + { fetch: fetchSpo2Range, map: mapOxygenSaturation, verb: "fetchSpo2" }, + { fetch: fetchHrvRange, map: mapHeartRateVariability, verb: "fetchHrv" }, { - dataType: FITBIT_DATA_TYPES.bodyFat, - map: mapBodyFat, - verb: "fetchBodyFat", - }, - { - dataType: FITBIT_DATA_TYPES.oxygenSaturation, - map: mapOxygenSaturation, - verb: "fetchSpo2", - }, - { - dataType: FITBIT_DATA_TYPES.heartRateVariability, - map: mapHeartRateVariability, - verb: "fetchHrv", - }, - { - dataType: FITBIT_DATA_TYPES.restingHeartRate, + fetch: fetchRestingHeartRateRange, map: mapRestingHeartRate, verb: "fetchRhr", }, { - dataType: FITBIT_DATA_TYPES.respiratoryRate, + fetch: fetchRespiratoryRateRange, map: mapRespiratoryRate, verb: "fetchRespiratoryRate", }, - { - dataType: FITBIT_DATA_TYPES.heartRate, - map: mapHeartRate, - verb: "fetchHeartRate", - }, - { - dataType: FITBIT_DATA_TYPES.sleepTemperature, - map: mapSleepTemperature, - verb: "fetchSleepTemperature", - }, ]; export async function syncUserMetrics( @@ -104,32 +78,32 @@ export async function syncUserMetrics( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; - // The incremental lower bound is the cycle-wide watermark snapshotted once by - // `syncUserFitbit` — never re-read here, so a sibling resource's stamp can't - // shrink this one's window. Undefined on a full/backfill run. - const start = opts.start; + const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = opts.end ?? new Date(); + const windows = chunkDateRanges(start, end, FITBIT_RANGE_DAYS); let imported = 0; - // Each metric data type is fetched + mapped independently so a per-class 403 - // soft-skips only that class. + // Each metric is fetched + mapped independently so a per-endpoint 403 + // soft-skips only that metric. A 403 short-circuits THAT metric (skips its + // remaining windows) without aborting the others. for (const resource of METRIC_RESOURCES) { - let points: Record[]; - try { - points = await fetchDataPoints( - resource.dataType, - tokenInfo.accessToken, - resource.verb, - { start }, - ); - } catch (err) { - imported += await handleCollectionFetchError(resource.verb, userId, err); - continue; - } - const readings: FitbitMeasurementUpsert[] = []; - for (const point of points) { - for (const m of resource.map(point)) { + let forbidden = false; + for (const w of windows) { + let body: unknown; + try { + body = await resource.fetch(tokenInfo.accessToken, w.start, w.end); + } catch (err) { + imported += await handleCollectionFetchError( + resource.verb, + userId, + err, + ); + forbidden = true; + break; + } + for (const m of resource.map(body)) { readings.push({ type: m.type, value: m.value, @@ -139,6 +113,7 @@ export async function syncUserMetrics( }); } } + if (forbidden) continue; imported += ( await upsertFitbitMeasurements(userId, readings, { deferRollup: opts.deferRollup, @@ -146,47 +121,6 @@ export async function syncUserMetrics( ).imported; } - // Height → User.heightCm, only when the user has no height yet. Never mint a - // Measurement; never overwrite a user-set value. Mirrors WHOOP's mapBody seed. - try { - let heightPoints: Record[]; - try { - heightPoints = await fetchDataPoints( - FITBIT_DATA_TYPES.height, - tokenInfo.accessToken, - "fetchHeight", - { start }, - ); - } catch (err) { - heightPoints = []; - await handleCollectionFetchError("fetchHeight", userId, err); - } - - // Latest non-null parsed height wins (the points are time-ordered ascending). - let heightCm: number | null = null; - for (const point of heightPoints) { - const cm = mapHeightCm(point); - if (cm !== null) heightCm = cm; - } - if (heightCm !== null) { - const user = await prisma.user.findUnique({ - where: { id: userId }, - select: { heightCm: true }, - }); - if (user && user.heightCm === null) { - await prisma.user.update({ - where: { id: userId }, - data: { heightCm }, - }); - } - } - } catch (err) { - getEvent()?.addWarning( - `fitbit: failed to seed heightCm for ${userId}: ${err}`, - ); - } - - // `markSynced` is owned by the orchestrator (`syncUserFitbit`), stamped once - // at the end of the cycle — never here, so the watermark can't move mid-cycle. + // `markSynced` is owned by the orchestrator (`syncUserFitbit`). return imported; } diff --git a/src/lib/fitbit/sync-sleep.ts b/src/lib/fitbit/sync-sleep.ts index d7ac50f1f..2215d4ef8 100644 --- a/src/lib/fitbit/sync-sleep.ts +++ b/src/lib/fitbit/sync-sleep.ts @@ -1,31 +1,30 @@ /** - * Fitbit / Google Health sleep-bundle sync (v1.12.0, W5). + * Fitbit Web API sleep sync. * - * Reads sleep sessions from the `sleep.readonly` Restricted bundle and maps - * each into per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row - * per stage segment; `measuredAt = that segment's END instant; harmonised onto - * the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP that the - * night-total + hypnogram readers already consume for WHOOP / Apple). Google - * carries a real per-segment series, so the rows lay each block at its true - * clock time (a MEASURED timeline, not reconstructed). Upserts as - * `source = FITBIT`. + * Reads sleep sessions from the classic `1.2/sleep/date` log and maps each into + * per-SEGMENT `SLEEP_DURATION` rows via `mapSleepSession` (one row per stage + * segment; `measuredAt = segment START + seconds = segment END`; harmonised onto + * the shared `SleepStage` enum IN_BED/AWAKE/ASLEEP/REM/CORE/DEEP the night-total + * + hypnogram readers already consume for WHOOP / Apple). The 1.2 log carries a + * real per-segment series, so the rows lay each block at its true clock time (a + * MEASURED timeline, not reconstructed). Upserts as `source = FITBIT`. * * Each segment carries the `sleepStage` axis and an indexed fieldTag so the - * several segments of one stage stay distinct under the - * `(userId, type, source, externalId)` dedup key. externalId = - * `:sleep_:` — a re-scored night overwrites in place. - * A 24 h overlap covers Google's after-the-fact re-score. + * several segments of one stage stay distinct under the dedup key. externalId = + * `:sleep_:` — a re-scored night overwrites in place. A 24 h + * overlap covers Fitbit's after-the-fact re-score. * - * A per-data-class 403 soft-skips the resource — the sleep bundle is granted - * independently of activity / metrics in the Google consent flow. + * A per-endpoint 403 soft-skips the resource — the `sleep` scope is granted + * independently in the consent flow. */ import { - FITBIT_ACTIVITY_PAGE_SIZE, - FITBIT_DATA_TYPES, - fetchDataPoints, + FITBIT_SLEEP_RANGE_DAYS, + fetchSleepRange, mapSleepSession, + readSleepSessions, } from "./client"; import { + chunkDateRanges, getValidToken, handleCollectionFetchError, upsertFitbitMeasurements, @@ -41,33 +40,29 @@ export async function syncUserSleep( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; - // Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a - // full/backfill run. - const start = opts.start; - - let points: Record[]; - try { - points = await fetchDataPoints( - FITBIT_DATA_TYPES.sleep, - tokenInfo.accessToken, - "fetchSleep", - { start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE }, - ); - } catch (err) { - return handleCollectionFetchError("fetchSleep", userId, err); - } + const start = opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const end = opts.end ?? new Date(); + const windows = chunkDateRanges(start, end, FITBIT_SLEEP_RANGE_DAYS); const readings: FitbitMeasurementUpsert[] = []; - for (const point of points) { - for (const m of mapSleepSession(point)) { - readings.push({ - type: m.type, - value: m.value, - unit: m.unit, - measuredAt: m.measuredAt, - externalId: m.fieldTag, - sleepStage: m.sleepStage ?? null, - }); + for (const w of windows) { + let body: unknown; + try { + body = await fetchSleepRange(tokenInfo.accessToken, w.start, w.end); + } catch (err) { + return handleCollectionFetchError("fetchSleep", userId, err); + } + for (const session of readSleepSessions(body)) { + for (const m of mapSleepSession(session)) { + readings.push({ + type: m.type, + value: m.value, + unit: m.unit, + measuredAt: m.measuredAt, + externalId: m.fieldTag, + sleepStage: m.sleepStage ?? null, + }); + } } } diff --git a/src/lib/fitbit/sync-workout.ts b/src/lib/fitbit/sync-workout.ts index 7fa923084..7e38a3872 100644 --- a/src/lib/fitbit/sync-workout.ts +++ b/src/lib/fitbit/sync-workout.ts @@ -1,24 +1,25 @@ /** - * Fitbit / Google Health exercise-session sync (v1.12.0, W5). + * Fitbit Web API exercise-session sync. * - * Reads exercise sessions from the `activity_and_fitness.readonly` Restricted - * bundle and upserts each into the `Workout` table as `source = FITBIT`, keyed - * on `(userId, source, externalId)` so a re-fetch overwrites in place. + * Reads exercise sessions from the classic `activities/list` endpoint + * (`afterDate` + ascending offset/limit pagination) and upserts each into the + * `Workout` table as `source = FITBIT`, keyed on `(userId, source, externalId)` + * (externalId = the Fitbit `logId`) so a re-fetch overwrites in place. * * A Fitbit run and the same run via Apple Health (or WHOOP) stay distinct * `Workout` rows (different `source`); the read-time `pickCanonicalWorkoutRows` - * picker collapses the cross-source twin at read time per the user's source - * ladder (FITBIT is already in the default ladder, ranked just below WHOOP). - * There is no ingest-time collapse for a server-owned source pair. + * picker collapses the cross-source twin per the user's source ladder (FITBIT is + * already in the default ladder, ranked just below WHOOP). No ingest-time + * collapse for a server-owned source pair. * - * A per-data-class 403 soft-skips the resource — the activity/fitness bundle is - * granted independently in the Google consent flow. + * The page-walk is bounded (the 150 req/h budget is tight): a small page cap + * covers the incremental window comfortably; a deep backfill is bounded by the + * backfill horizon `afterDate`. A per-endpoint 403 soft-skips the resource. */ import { - FITBIT_ACTIVITY_PAGE_SIZE, - FITBIT_DATA_TYPES, - fetchDataPoints, + fetchActivityList, mapWorkout, + readActivityList, type FitbitMappedWorkout, } from "./client"; import { @@ -29,6 +30,11 @@ import { import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; +/** Activities per page on the `activities/list` walk. */ +const WORKOUT_PAGE_SIZE = 100; +/** Hard ceiling on pages walked per cycle (rate-budget guard). */ +const WORKOUT_MAX_PAGES = 12; + export async function syncUserWorkout( userId: string, opts: FitbitResourceSyncOptions = {}, @@ -36,25 +42,32 @@ export async function syncUserWorkout( const tokenInfo = await getValidToken(userId); if (!tokenInfo) return 0; - // Cycle-wide watermark snapshotted once by `syncUserFitbit`; undefined on a - // full/backfill run. - const start = opts.start; + const afterDate = + opts.start ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); - let points: Record[]; - try { - points = await fetchDataPoints( - FITBIT_DATA_TYPES.exercise, - tokenInfo.accessToken, - "fetchExercise", - { start, pageSize: FITBIT_ACTIVITY_PAGE_SIZE }, - ); - } catch (err) { - return handleCollectionFetchError("fetchExercise", userId, err); + const entries: ReturnType = []; + let offset = 0; + let pages = 0; + while (pages < WORKOUT_MAX_PAGES) { + let body: unknown; + try { + body = await fetchActivityList(tokenInfo.accessToken, afterDate, { + limit: WORKOUT_PAGE_SIZE, + offset, + }); + } catch (err) { + return handleCollectionFetchError("fetchExercise", userId, err); + } + const page = readActivityList(body); + entries.push(...page); + pages += 1; + if (page.length < WORKOUT_PAGE_SIZE) break; + offset += WORKOUT_PAGE_SIZE; } let imported = 0; - for (const point of points) { - const w: FitbitMappedWorkout | null = mapWorkout(point); + for (const entry of entries) { + const w: FitbitMappedWorkout | null = mapWorkout(entry); if (!w) continue; // no usable time span — not a workout try { diff --git a/src/lib/fitbit/sync.ts b/src/lib/fitbit/sync.ts index e8e4437df..f85eda2ad 100644 --- a/src/lib/fitbit/sync.ts +++ b/src/lib/fitbit/sync.ts @@ -1,17 +1,19 @@ /** - * Fitbit / Google Health sync service (v1.12.0). + * Fitbit Web API sync service. * - * This wave (OAuth + credentials) lands the token-management half: * - `getValidToken` decrypts the stored token, refreshes at - * `tokenExpiresAt - 5 min`, persists the new access token + expiry, and — - * UNLIKE WHOOP — only overwrites the stored refresh token when the response - * carries a fresh one (Google does not rotate refresh tokens). + * `tokenExpiresAt - 5 min`, and persists the new access token + expiry AND + * the rotated refresh token. Classic Fitbit ROTATES refresh tokens + * (one-time use), so the new `refresh_token` MUST replace the stored one — + * the inverse of the prior Google transport, which did not rotate. * - `recordFitbitSyncFailure` / `classificationToFailureKind` map a * classified API error onto the shared integration-status ledger. - * - * The per-resource data sync (`upsertFitbitMeasurements`, `syncUserFitbit`, the - * collection walkers, and the 403 soft-skip orchestration) lands in a later - * wave and extends this file. + * - `upsertFitbitMeasurements` + `syncUserFitbit` own the write tail (dedup, + * overwrite contract, rollup fold) — reused unchanged across the transport + * fork; only the per-resource fetch/map differs. + * - `chunkDateRanges` slices a deep backfill into the per-endpoint date-range + * windows the classic Web API caps each call at (30 days), so the per-resource + * syncs issue one valid request per window. */ import { AsyncLocalStorage } from "node:async_hooks"; import pLimit from "p-limit"; @@ -43,13 +45,22 @@ import { const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; /** - * Overlap window for the incremental sync, in ms. Google re-rolls daily - * summaries after the fact (a daily SpO2 / HRV / RHR can finalise hours after - * the night), so the default overlap is a full 24 h to make sure the re-rolled - * row is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap. + * Overlap window for the incremental sync, in ms. Fitbit finalises daily + * summaries after the fact (a daily SpO2 / HRV / RHR can settle hours after the + * night), so the default overlap is a full 24 h to make sure the re-rolled row + * is re-fetched on the next tick. Mirrors WHOOP's recovery/sleep overlap. */ export const FITBIT_DEFAULT_OVERLAP_MS = 24 * 60 * 60 * 1000; +/** + * How far back a `fullSync` backfill walks. The classic Web API per-endpoint + * range cap (30 days) means a deep backfill issues many requests against the + * tight 150/h budget, so the backfill horizon is bounded to keep one user's + * backfill inside a couple of hourly windows (~12 chunks/resource). One year of + * daily summaries is the launch horizon. + */ +export const FITBIT_BACKFILL_DAYS = 365; + export interface FitbitTokenInfo { accessToken: string; connection: { id: string; fitbitUserId: string }; @@ -61,10 +72,12 @@ export interface FitbitTokenInfo { * credentials, or the refresh fails (the failure is recorded so scheduled syncs * back off). * - * KEY DELTA vs WHOOP: Google does not rotate refresh tokens. On refresh, persist - * the new access token + expiry and overwrite the stored `refreshToken` ONLY - * when the response carries a fresh one — otherwise keep the existing refresh - * token so the next refresh still authenticates. + * Classic Fitbit ROTATES refresh tokens (one-time use): every refresh response + * carries a fresh `refresh_token` that invalidates the old one. Persist the new + * access token + expiry AND the new refresh token unconditionally. If a refresh + * response ever omits the new refresh token (a malformed reply), keep the stored + * one rather than wiping it — a blank refresh token would brick every future + * refresh. */ export async function getValidToken( userId: string, @@ -100,10 +113,11 @@ export async function getValidToken( const newTokens = await refreshAccessToken(refreshToken, creds); const expiresAt = new Date(Date.now() + newTokens.expires_in * 1000); - // Google does NOT rotate refresh tokens: a routine refresh returns a new - // access token but usually omits `refresh_token`. Persist the new access - // token + expiry and only overwrite the stored refresh token when the - // response actually carries a fresh one — otherwise keep the existing one. + // Classic Fitbit ROTATES refresh tokens: the refresh response carries a + // fresh `refresh_token` that replaces the (now-invalid) stored one. Persist + // it unconditionally. Guard the malformed-reply case: if the response omits + // the new refresh token, keep the existing one rather than writing a blank, + // which would brick every future refresh. await prisma.fitbitConnection.update({ where: { id: connection.id }, data: { @@ -141,14 +155,14 @@ export async function getValidToken( } /** - * True when a caught error is a per-data-class 403 (forbidden). The six Google - * Health Restricted bundles are granted independently in the consent flow, so a - * 403 on ONE data class is a scope gate on THAT class — soft-skip it and keep - * the connection connected rather than parking the whole integration at - * `error_reauth`. Connection-wide reauth is reserved for a 401 (token rejected) - * and for a 403 on the token-refresh / profile path (a genuine grant revoke), - * which run outside the per-resource catch blocks. Matters more here than for - * WHOOP because partial grants are likely with six independent bundles. + * True when a caught error is a per-endpoint 403 (forbidden). The classic Fitbit + * scopes are granted independently in the consent flow (and intraday needs an + * extra per-app grant), so a 403 on ONE endpoint is a scope gate on THAT metric + * — soft-skip it and keep the connection connected rather than parking the whole + * integration at `error_reauth`. Connection-wide reauth is reserved for a 401 + * (token rejected) and a 403 on the token-refresh / profile path (a genuine + * grant revoke), which run outside the per-resource catch blocks. Matters here + * because partial grants are likely across the independent scopes. */ export function isCollectionForbidden(err: unknown): boolean { return err instanceof FitbitApiError && err.httpStatus === 403; @@ -203,16 +217,18 @@ export async function handleCollectionFetchError( /** * Options threaded from `syncUserFitbit` to each per-resource sync. The `start` - * watermark is snapshotted ONCE by the orchestrator (a single `lastSyncedAt` - * read) so every resource fetches from the same lower bound; no resource re-reads - * `lastSyncedAt` or stamps `markSynced` itself. On a full/backfill run `start` - * is undefined (no lower bound). The orchestrator owns the single end-of-cycle - * `markSynced`. + * + `end` window is snapshotted ONCE by the orchestrator (a single `lastSyncedAt` + * read + a single `now`) so every resource fetches the same window; no resource + * re-reads `lastSyncedAt` or stamps `markSynced` itself. The per-resource sync + * chunks `[start, end]` into the per-endpoint date-range cap. The orchestrator + * owns the single end-of-cycle `markSynced`. */ export interface FitbitResourceSyncOptions { fullSync?: boolean; - /** The incremental lower bound, snapshotted once by the orchestrator. */ + /** The lower bound of the fetch window, snapshotted once by the orchestrator. */ start?: Date; + /** The upper bound of the fetch window (now), snapshotted once. */ + end?: Date; /** * When true, `upsertFitbitMeasurements` writes the rows but SKIPS the inline * per-(type,day) DAY-rollup recompute + status-insight invalidate, returning @@ -227,19 +243,53 @@ export interface FitbitResourceSyncOptions { } /** - * Compute the incremental `start` for a resource sync. `fullSync` returns - * undefined (the backfill walks deep history without a lower bound). Otherwise - * start from `lastSyncedAt - overlap`, or 30 days back on the very first - * incremental tick. Mirrors WHOOP's `incrementalStart`. + * Compute the incremental `start` for a resource sync. `fullSync` walks back the + * bounded backfill horizon (the classic Web API caps each call's date range, so + * an unbounded backfill would blow the 150/h budget). Otherwise start from + * `lastSyncedAt - overlap`, or 30 days back on the very first incremental tick. */ export function incrementalStart( lastSyncedAt: Date | null, - opts: { fullSync?: boolean; overlapMs?: number } = {}, -): Date | undefined { - if (opts.fullSync) return undefined; + opts: { fullSync?: boolean; overlapMs?: number; now?: Date } = {}, +): Date { + const now = opts.now ?? new Date(); + if (opts.fullSync) { + return new Date(now.getTime() - FITBIT_BACKFILL_DAYS * 24 * 60 * 60 * 1000); + } const overlap = opts.overlapMs ?? FITBIT_DEFAULT_OVERLAP_MS; if (lastSyncedAt) return new Date(lastSyncedAt.getTime() - overlap); - return new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); +} + +/** + * Slice `[start, end]` into consecutive inclusive windows no wider than + * `maxDays` calendar days, the per-endpoint range cap the classic Web API + * enforces. Each window is `{ start, end }` with `end` no later than the overall + * `end`; the per-resource syncs issue one request per window. Returns a single + * window when the span already fits. + */ +export function chunkDateRanges( + start: Date, + end: Date, + maxDays: number, +): Array<{ start: Date; end: Date }> { + const windows: Array<{ start: Date; end: Date }> = []; + if (end.getTime() < start.getTime()) return windows; + const stepMs = maxDays * 24 * 60 * 60 * 1000; + let cursor = start.getTime(); + const endMs = end.getTime(); + // Guard against a pathological span minting an unbounded loop. + let guard = 0; + const MAX_WINDOWS = 1000; + while (cursor <= endMs && guard < MAX_WINDOWS) { + // Each window spans up to `maxDays` days inclusive, so subtract one day from + // the step to keep the inclusive end inside the cap. + const windowEnd = Math.min(cursor + stepMs - 24 * 60 * 60 * 1000, endMs); + windows.push({ start: new Date(cursor), end: new Date(windowEnd) }); + cursor = windowEnd + 24 * 60 * 60 * 1000; + guard += 1; + } + return windows; } /** @@ -486,14 +536,13 @@ export async function markSynced(userId: string): Promise { * and exercise workouts (W5). Each runs independently so a per-resource 403 * (a Restricted bundle the user did not grant) soft-skips only that resource. * - * WATERMARK: the incremental `start` is snapshotted ONCE here (from the single - * `lastSyncedAt` read at the top of the cycle) and threaded to every resource, - * and `markSynced` is stamped ONCE at the end. A per-resource read+stamp would - * let the first resource move `lastSyncedAt` to now() so later resources only - * see the last overlap window — silently dropping the gap after an outage longer - * than the overlap. The full/backfill path passes `fullSync: true`, which makes - * `incrementalStart` return undefined regardless of the snapshot, so the deep - * history walk is unaffected. + * WATERMARK: the incremental `[start, end]` window is snapshotted ONCE here + * (from the single `lastSyncedAt` read + a single `now` at the top of the cycle) + * and threaded to every resource, and `markSynced` is stamped ONCE at the end. A + * per-resource read+stamp would let the first resource move `lastSyncedAt` to + * now() so later resources only see the last overlap window — silently dropping + * the gap after an outage longer than the overlap. The full/backfill path passes + * `fullSync: true`, which widens `start` back to the bounded backfill horizon. */ export async function syncUserFitbit( userId: string, @@ -506,21 +555,23 @@ export async function syncUserFitbit( return 0; } - // Snapshot the incremental watermark ONCE for the whole cycle. Every resource - // sees the same `start`; no resource re-reads `lastSyncedAt` mid-cycle. On a - // full/backfill run `incrementalStart` ignores the snapshot and returns - // undefined (no lower bound). + // Snapshot the incremental window ONCE for the whole cycle. Every resource + // sees the same `[start, end]`; no resource re-reads `lastSyncedAt` mid-cycle. + // A full/backfill run widens `start` back to the bounded backfill horizon. const connection = await prisma.fitbitConnection.findUnique({ where: { userId }, select: { lastSyncedAt: true }, }); if (!connection) return 0; + const end = new Date(); const start = incrementalStart(connection.lastSyncedAt, { fullSync: opts.fullSync, + now: end, }); const resourceOpts: FitbitResourceSyncOptions = { fullSync: opts.fullSync, start, + end, // A backfill walks years of daily summaries — defer the per-(type,day) // rollup hook on every write and run ONE range-recompute at the end of the // cycle. The hourly incremental keeps the inline hook (small touched set). @@ -656,12 +707,17 @@ export function classificationToFailureKind( /** * Bounded fan-out width for the hourly Fitbit cohort poll. The cron tick carries * no `userId`, so the worker resolves every connection and hands the cohort here. - * A small pool (rather than a strict serial loop) means one slow Google response + * A small pool (rather than a strict serial loop) means one slow Fitbit response * can't stall the whole cohort, while the cap keeps the burst of per-user - * resource fetches + rollup writes from crowding the worker's DB pool — matching - * the `p-limit` ceilings the analytics fan-out + insight warm pass use elsewhere. + * resource fetches + rollup writes from crowding the worker's DB pool. + * + * Tuned to 2 for the classic Web API: each per-user cycle issues ~a dozen + * date-range requests against a tight 150 req/h-per-user budget, so a narrower + * pool keeps the concurrent outbound burst (and the DB-write tail behind it) + * modest. The per-user budget is independent, so the cap is about worker + * pressure, not the API ceiling — but 2 keeps both comfortable. */ -export const FITBIT_POLL_CONCURRENCY = 4; +export const FITBIT_POLL_CONCURRENCY = 2; /** * Run a Fitbit hourly-poll cohort with bounded concurrency + per-user error