diff --git a/messages/de.json b/messages/de.json index d98ec822d..c25559632 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Mit WHOOP verbinden", "whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.", "whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…", + "whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.", + "whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen", + "whoopOauthError": { + "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", + "state": "Die Antwort von WHOOP 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": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.", + "nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.", + "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", "fitbitTag": "Fitbit & Pixel", "fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.", @@ -4494,6 +4506,18 @@ "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", + "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.", + "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.", + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + }, "integrations": { "withings": { "reconnect": { diff --git a/messages/en.json b/messages/en.json index e8113d837..2d774166a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Connect with WHOOP", "whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.", "whoopBackfillInProgress": "Importing your WHOOP history in the background…", + "whoopOauthConnected": "WHOOP connected — the first sync starts in the background.", + "whoopOauthFailed": "WHOOP connection failed", + "whoopOauthError": { + "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", + "state": "The response from WHOOP 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": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.", + "nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.", + "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", "fitbitTag": "Fitbit & Pixel", "fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.", @@ -4494,6 +4506,18 @@ "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", + "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.", + "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.", + "generic": "Unknown error while connecting. Please start the connection again." + }, "integrations": { "withings": { "reconnect": { diff --git a/messages/es.json b/messages/es.json index 329cd2efe..21d3c6f22 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Conectar con WHOOP", "whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.", "whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…", + "whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.", + "whoopOauthFailed": "Error al conectar con WHOOP", + "whoopOauthError": { + "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 WHOOP 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": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.", + "nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.", + "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", "fitbitTag": "Fitbit y Pixel", "fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.", @@ -4494,6 +4506,18 @@ "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", + "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.", + "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.", + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + }, "integrations": { "withings": { "reconnect": { diff --git a/messages/fr.json b/messages/fr.json index 6e8777654..e75419797 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Se connecter avec WHOOP", "whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.", "whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…", + "whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.", + "whoopOauthFailed": "Échec de la connexion à WHOOP", + "whoopOauthError": { + "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 WHOOP 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": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.", + "nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.", + "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", "fitbitTag": "Fitbit et Pixel", "fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.", @@ -4494,6 +4506,18 @@ "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", + "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.", + "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.", + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + }, "integrations": { "withings": { "reconnect": { diff --git a/messages/it.json b/messages/it.json index bb9d81337..ca7636642 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Connetti con WHOOP", "whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.", "whoopBackfillInProgress": "Importazione dello storico WHOOP in background…", + "whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.", + "whoopOauthFailed": "Connessione a WHOOP non riuscita", + "whoopOauthError": { + "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", + "state": "La risposta di WHOOP 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": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.", + "nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.", + "token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.", + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + }, "fitbit": "Google Health", "fitbitTag": "Fitbit e Pixel", "fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.", @@ -4494,6 +4506,18 @@ "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", + "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.", + "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.", + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + }, "integrations": { "withings": { "reconnect": { diff --git a/messages/pl.json b/messages/pl.json index 09b493d64..e9c9ca571 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4467,6 +4467,18 @@ "whoopConnect": "Połącz z WHOOP", "whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.", "whoopBackfillInProgress": "Importowanie historii WHOOP w tle…", + "whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.", + "whoopOauthFailed": "Połączenie z WHOOP nie powiodło się", + "whoopOauthError": { + "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 WHOOP 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": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.", + "nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.", + "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", "fitbitTag": "Fitbit i Pixel", "fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.", @@ -4494,6 +4506,18 @@ "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ę", + "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.", + "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.", + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + }, "integrations": { "withings": { "reconnect": { diff --git a/src/app/api/fitbit/credentials/__tests__/route.test.ts b/src/app/api/fitbit/credentials/__tests__/route.test.ts new file mode 100644 index 000000000..44160d2ec --- /dev/null +++ b/src/app/api/fitbit/credentials/__tests__/route.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + fitbitConnection: { delete: vi.fn() }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), + apiError: (error: string, status: number) => ({ data: null, error, status }), + safeJson: async (req: NextRequest) => { + try { + return { data: await req.json(), error: null }; + } catch { + return { data: null, error: { status: 400 } }; + } + }, +})); + +import { PUT, DELETE } from "../route"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; + +const userUpdate = prisma.user.update as ReturnType; + +function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/fitbit/credentials", { + method: "PUT", + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + }); +} + +describe("/api/fitbit/credentials", () => { + beforeEach(() => vi.clearAllMocks()); + + it("PUT 422s on missing fields", async () => { + const res = (await ( + PUT as unknown as (r: NextRequest) => Promise<{ status: number }> + )(req({ clientId: "" }))) as { status: number }; + expect(res.status).toBe(422); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("DELETE clears credentials, audits the teardown, and parks the ledger (04-L1 parity)", async () => { + userUpdate.mockResolvedValue({}); + ( + prisma.fitbitConnection.delete as ReturnType + ).mockResolvedValue({}); + const res = (await (DELETE as unknown as () => Promise<{ data: unknown }>)()) + .data as { deleted: boolean }; + expect(res.deleted).toBe(true); + expect(userUpdate).toHaveBeenCalledWith({ + where: { id: "u1" }, + data: { + fitbitClientIdEncrypted: null, + fitbitClientSecretEncrypted: null, + }, + }); + expect(auditLog).toHaveBeenCalledWith("fitbit.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "fitbit"); + }); +}); diff --git a/src/app/api/fitbit/credentials/route.ts b/src/app/api/fitbit/credentials/route.ts index 1a57ae5fe..9ecb39a6e 100644 --- a/src/app/api/fitbit/credentials/route.ts +++ b/src/app/api/fitbit/credentials/route.ts @@ -1,8 +1,10 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; +import { markDisconnected } from "@/lib/integrations/status"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -60,6 +62,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete Fitbit credentials and the active connection. + * + * Audits the teardown and parks the integration ledger at `disconnected` for + * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a + * uniform audit trail and the status snapshot does not linger at a stale state. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); @@ -76,6 +82,8 @@ export const DELETE = apiHandler(async () => { fitbitClientSecretEncrypted: null, }, }); + await auditLog("fitbit.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "fitbit"); return apiSuccess({ deleted: true }); }); diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts new file mode 100644 index 000000000..2f378f5d3 --- /dev/null +++ b/src/app/api/integrations/status/__tests__/route.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + withingsConnection: { findUnique: vi.fn(async () => null) }, + whoopConnection: { findUnique: vi.fn(async () => null) }, + fitbitConnection: { findUnique: vi.fn(async () => null) }, + moodEntry: { count: vi.fn(async () => 0) }, + }, +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +vi.mock("@/lib/api-response", () => ({ + apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), +})); + +const ledger: Record = { + state: "connected", + lastSuccessAt: null, + lastAttemptAt: null, + lastError: null, +}; +vi.mock("@/lib/integrations/status", () => ({ + getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({ + integration, + ...ledger, + })), + getPersistentFailureThreshold: () => 5, +})); + +vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false })); +vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null })); + +const polarAvailable = vi.fn(async () => true); +const ouraAvailable = vi.fn(async () => false); +vi.mock("@/lib/polar/credentials", () => ({ + getPolarClientCredentials: () => polarAvailable(), +})); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: () => ouraAvailable(), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; + +const userFind = prisma.user.findUnique as ReturnType; + +type Entry = { + integration: string; + connected?: boolean; + configured?: boolean; + available?: boolean; + hasOwnCredentials?: boolean; +}; + +async function fetchEntries(): Promise { + const res = (await (GET as unknown as () => Promise<{ data: unknown }>)()) + .data as { integrations: Entry[] }; + return res.integrations; +} + +describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => { + beforeEach(() => { + vi.clearAllMocks(); + polarAvailable.mockResolvedValue(true); + ouraAvailable.mockResolvedValue(false); + }); + + it("includes polar + oura entries on the consolidated envelope", async () => { + userFind.mockResolvedValue({ + polarAccessTokenEncrypted: "tok", + polarClientIdEncrypted: "id", + polarClientSecretEncrypted: "sec", + ouraAccessTokenEncrypted: null, + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }); + + const entries = await fetchEntries(); + const keys = entries.map((e) => e.integration); + expect(keys).toEqual( + expect.arrayContaining(["polar", "oura"]), + ); + + const polar = entries.find((e) => e.integration === "polar")!; + expect(polar.connected).toBe(true); + expect(polar.configured).toBe(true); + expect(polar.available).toBe(true); + expect(polar.hasOwnCredentials).toBe(true); + + const oura = entries.find((e) => e.integration === "oura")!; + expect(oura.connected).toBe(false); + expect(oura.available).toBe(false); + expect(oura.hasOwnCredentials).toBe(false); + }); + + it("reports polar disconnected when no access token is stored", async () => { + userFind.mockResolvedValue({ + polarAccessTokenEncrypted: null, + polarClientIdEncrypted: null, + polarClientSecretEncrypted: null, + }); + const entries = await fetchEntries(); + const polar = entries.find((e) => e.integration === "polar")!; + expect(polar.connected).toBe(false); + expect(polar.configured).toBe(false); + }); +}); diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index 9b3d768f8..8c3b5eec9 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -24,6 +24,8 @@ import { getPersistentFailureThreshold, type IntegrationKey, } from "@/lib/integrations/status"; +import { getOuraClientCredentials } from "@/lib/oura/credentials"; +import { getPolarClientCredentials } from "@/lib/polar/credentials"; import { hasActivityScope } from "@/lib/withings/client"; import { readMoodLogSecret } from "@/lib/moodlog-secret"; @@ -38,16 +40,25 @@ export const GET = apiHandler(async () => { moodLogStatus, whoopStatus, fitbitStatus, + polarStatus, + ouraStatus, dbUser, withingsConn, whoopConn, fitbitConn, moodLogEntryCount, + // v1.17.1 — `available` reports whether usable OAuth credentials resolve + // (per-user BYO first, then the shared env app), mirroring the per-card + // /api//status the consolidated envelope now replaces. + polarAvailable, + ouraAvailable, ] = await Promise.all([ getIntegrationStatus(user.id, "withings"), getIntegrationStatus(user.id, "moodlog"), getIntegrationStatus(user.id, "whoop"), getIntegrationStatus(user.id, "fitbit"), + getIntegrationStatus(user.id, "polar"), + getIntegrationStatus(user.id, "oura"), prisma.user.findUnique({ where: { id: user.id }, select: { @@ -57,6 +68,12 @@ export const GET = apiHandler(async () => { whoopClientSecretEncrypted: true, fitbitClientIdEncrypted: true, fitbitClientSecretEncrypted: true, + polarAccessTokenEncrypted: true, + polarClientIdEncrypted: true, + polarClientSecretEncrypted: true, + ouraAccessTokenEncrypted: true, + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, moodLogUrlEncrypted: true, moodLogApiKeyEncrypted: true, moodLogEnabled: true, @@ -95,6 +112,8 @@ export const GET = apiHandler(async () => { prisma.moodEntry.count({ where: { userId: user.id, deletedAt: null }, }), + getPolarClientCredentials(user.id).then((c) => !!c), + getOuraClientCredentials(user.id).then((c) => !!c), ]); const now = Date.now(); @@ -164,6 +183,28 @@ export const GET = apiHandler(async () => { : null, backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null, } satisfies IntegrationViewModel & FitbitExtras, + { + ...polarStatus, + // `connected` = a stored access token; `configured` mirrors it (the + // OAuth card has no separate "credentials saved but disconnected" view + // beyond `hasOwnCredentials`). The card greys out the connect button + // when no usable credentials resolve (`available`). + connected: !!dbUser?.polarAccessTokenEncrypted, + configured: !!dbUser?.polarAccessTokenEncrypted, + available: polarAvailable, + hasOwnCredentials: + !!dbUser?.polarClientIdEncrypted && + !!dbUser?.polarClientSecretEncrypted, + } satisfies IntegrationViewModel & OAuthProviderExtras, + { + ...ouraStatus, + connected: !!dbUser?.ouraAccessTokenEncrypted, + configured: !!dbUser?.ouraAccessTokenEncrypted, + available: ouraAvailable, + hasOwnCredentials: + !!dbUser?.ouraClientIdEncrypted && + !!dbUser?.ouraClientSecretEncrypted, + } satisfies IntegrationViewModel & OAuthProviderExtras, ], }); }); @@ -214,3 +255,13 @@ interface FitbitExtras { tokenExpired: boolean | null; backfillCompleted: boolean | null; } + +// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the +// per-user BYO-key flags the shared OAuth card reads instead of the dedicated +// /api//status round-trip the page used to fire per card. +interface OAuthProviderExtras { + connected: boolean; + configured: boolean; + available: boolean; + hasOwnCredentials: boolean; +} diff --git a/src/app/api/whoop/credentials/__tests__/route.test.ts b/src/app/api/whoop/credentials/__tests__/route.test.ts index 0816df18c..ec79e0cd1 100644 --- a/src/app/api/whoop/credentials/__tests__/route.test.ts +++ b/src/app/api/whoop/credentials/__tests__/route.test.ts @@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), @@ -30,6 +32,8 @@ vi.mock("@/lib/api-response", () => ({ import { GET, PUT, DELETE } from "../route"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { markDisconnected } from "@/lib/integrations/status"; const userFind = prisma.user.findUnique as ReturnType; const userUpdate = prisma.user.update as ReturnType; @@ -91,4 +95,16 @@ describe("/api/whoop/credentials", () => { }, }); }); + + it("DELETE audits the teardown and parks the ledger (04-L1 parity)", async () => { + userUpdate.mockResolvedValue({}); + (prisma.whoopConnection.delete as ReturnType).mockResolvedValue( + {}, + ); + await (DELETE as unknown as () => Promise)(); + expect(auditLog).toHaveBeenCalledWith("whoop.credentials.delete", { + userId: "u1", + }); + expect(markDisconnected).toHaveBeenCalledWith("u1", "whoop"); + }); }); diff --git a/src/app/api/whoop/credentials/route.ts b/src/app/api/whoop/credentials/route.ts index 618d87405..6675fa11e 100644 --- a/src/app/api/whoop/credentials/route.ts +++ b/src/app/api/whoop/credentials/route.ts @@ -1,8 +1,10 @@ import { prisma } from "@/lib/db"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { encrypt } from "@/lib/crypto"; +import { markDisconnected } from "@/lib/integrations/status"; import { whoopCredentialsSchema } from "@/lib/validations/whoop"; import { NextRequest } from "next/server"; import { z } from "zod/v4"; @@ -58,6 +60,10 @@ export const PUT = apiHandler(async (request: NextRequest) => { /** * Delete WHOOP credentials and the active connection. + * + * Audits the teardown and parks the integration ledger at `disconnected` for + * parity with the Polar / Oura credential-DELETE so a sensitive op leaves a + * uniform audit trail and the status snapshot does not linger at a stale state. */ export const DELETE = apiHandler(async () => { const { user } = await requireAuth(); @@ -74,6 +80,8 @@ export const DELETE = apiHandler(async () => { whoopClientSecretEncrypted: null, }, }); + await auditLog("whoop.credentials.delete", { userId: user.id }); + await markDisconnected(user.id, "whoop"); return apiSuccess({ deleted: true }); }); diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts new file mode 100644 index 000000000..a4c2f25ae --- /dev/null +++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts @@ -0,0 +1,67 @@ +/** + * v1.17.1 (04-M1) — the WHOOP / Fitbit OAuth callbacks redirect back with + * `?whoop=connected|error` / `?fitbit=connected|error&reason=`, but the + * settings page only read the param for Withings/Polar/Oura — so a user + * returning from a WHOOP or Fitbit round-trip landed on a silently unchanged + * page. These pin the four-provider outcome parsing + reason-key resolution. + */ +import { describe, it, expect } from "vitest"; + +import { + parseOAuthOutcome, + oauthReasonKey, +} from "../integrations-section"; + +describe("parseOAuthOutcome", () => { + it("reads a connected outcome for every OAuth provider", () => { + for (const p of ["polar", "oura", "whoop", "fitbit"] as const) { + expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({ + provider: p, + kind: "connected", + }); + } + }); + + it("reads WHOOP + Fitbit error outcomes with their reason tag (the gap fix)", () => { + expect(parseOAuthOutcome("?whoop=error&reason=token")).toEqual({ + provider: "whoop", + kind: "error", + reason: "token", + }); + expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({ + provider: "fitbit", + kind: "error", + reason: "expired", + }); + }); + + it("defaults a missing reason to 'unknown'", () => { + expect(parseOAuthOutcome("?whoop=error")).toEqual({ + provider: "whoop", + kind: "error", + reason: "unknown", + }); + }); + + it("returns null when no provider param is present", () => { + expect(parseOAuthOutcome("?foo=bar")).toBeNull(); + expect(parseOAuthOutcome("")).toBeNull(); + }); +}); + +describe("oauthReasonKey", () => { + it("maps a known tag to the provider-specific key", () => { + expect(oauthReasonKey("whoop", "token")).toBe( + "settings.whoopOauthError.token", + ); + expect(oauthReasonKey("fitbit", "expired")).toBe( + "settings.fitbitOauthError.expired", + ); + }); + + it("falls back to generic for an unknown tag", () => { + expect(oauthReasonKey("whoop", "totally_unknown")).toBe( + "settings.whoopOauthError.generic", + ); + }); +}); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 148a806ca..7484da40f 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -8,11 +8,13 @@ import { toast } from "sonner"; import { FitbitCard } from "@/components/settings/integrations/fitbit-card"; import { NightscoutCard } from "@/components/settings/integrations/nightscout-card"; +import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card"; import { OuraCard } from "@/components/settings/integrations/oura-card"; import { PolarCard } from "@/components/settings/integrations/polar-card"; import { pickStatus, useIntegrationStatuses, + type IntegrationStatusViewModel, } from "@/components/settings/integrations/shared"; import { WhoopCard } from "@/components/settings/integrations/whoop-card"; import { WithingsCard } from "@/components/settings/integrations/withings-card"; @@ -43,6 +45,97 @@ type WithingsOauthOutcome = | { kind: "connected" } | { kind: "error"; reason: string }; +/** + * The OAuth providers whose callbacks redirect back to the settings page with a + * `?=connected|error&reason=` outcome param. Polar/Oura own a + * per-card status query; WHOOP/Fitbit read off the consolidated envelope — both + * are invalidated on a successful return so the card repaints either way. + */ +const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const; +type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number]; + +const OAUTH_OUTCOME_KEYS: Record< + OAuthOutcomeProvider, + () => readonly unknown[] +> = { + polar: queryKeys.polar, + oura: queryKeys.oura, + whoop: queryKeys.whoop, + fitbit: queryKeys.fitbit, +}; + +/** + * Reason tags the four OAuth callbacks emit. Known tags resolve to a specific + * message; anything else falls back to the provider's `generic` copy. Union of + * the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`). + */ +const OAUTH_OUTCOME_REASONS = new Set([ + "csrf1", + "state", + "cross_user", + "nocode", + "nocreds", + "token", + "rate_limited", + "expired", +]); + +type OAuthOutcome = + | { provider: OAuthOutcomeProvider; kind: "connected" } + | { provider: OAuthOutcomeProvider; kind: "error"; reason: string }; + +/** + * Parse the OAuth-return outcome from a URL query string. Reads the first + * provider whose `?=connected|error` param is present, in + * `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider + * coverage is unit-testable without a browser. + */ +export function parseOAuthOutcome(search: string): OAuthOutcome | null { + const params = new URLSearchParams(search); + for (const provider of OAUTH_OUTCOME_PROVIDERS) { + const v = params.get(provider); + if (v === "connected") return { provider, kind: "connected" }; + if (v === "error") { + return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; + } + } + return null; +} + +/** + * Resolve the i18n key for an error reason tag. Known tags map to the + * provider-specific message; anything else falls back to `generic`. + */ +export function oauthReasonKey( + provider: OAuthOutcomeProvider, + reason: string, +): string { + return OAUTH_OUTCOME_REASONS.has(reason) + ? `settings.${provider}OauthError.${reason}` + : `settings.${provider}OauthError.generic`; +} + +/** + * Adapt the consolidated-envelope view-model into the OAuth card's status + * shape. Returns `undefined` when the envelope hasn't loaded so the card + * renders its loading/disconnected default rather than a half-populated state. + */ +function toOAuthStatus( + vm: IntegrationStatusViewModel | undefined, +): OAuthProviderStatus | undefined { + if (!vm) return undefined; + return { + connected: vm.connected ?? false, + configured: vm.configured ?? false, + available: vm.available ?? false, + hasOwnCredentials: vm.hasOwnCredentials, + state: vm.state, + lastSuccessAt: vm.lastSuccessAt, + lastAttemptAt: vm.lastAttemptAt, + lastError: vm.lastError, + }; +} + export function IntegrationsSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); @@ -92,23 +185,16 @@ export function IntegrationsSection() { } }, [withingsOauthOutcome, router, queryClient, t]); - // v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers. - // The Polar / Oura callbacks redirect back with `?=connected` or - // `?=error&reason=`; surface the outcome as a toast and scrub - // the one-shot params so a reload doesn't replay it. - const [oauthOutcome] = useState< - { provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null - >(() => { + // v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers. + // The Polar / Oura / WHOOP / Fitbit callbacks redirect back with + // `?=connected` or `?=error&reason=`; surface the + // outcome as a toast and scrub the one-shot params so a reload doesn't replay + // it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a + // WHOOP or Fitbit round-trip landed on a silently unchanged settings page, + // the same gap the Withings handler was written to close. + const [oauthOutcome] = useState(() => { if (typeof window === "undefined") return null; - const params = new URLSearchParams(window.location.search); - for (const provider of ["polar", "oura"] as const) { - const v = params.get(provider); - if (v === "connected") return { provider, kind: "connected" }; - if (v === "error") { - return { provider, kind: "error", reason: params.get("reason") ?? "unknown" }; - } - } - return null; + return parseOAuthOutcome(window.location.search); }); useEffect(() => { @@ -118,27 +204,19 @@ export function IntegrationsSection() { url.searchParams.delete(provider); url.searchParams.delete("reason"); router.replace(`${url.pathname}${url.search}`, { scroll: false }); - const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura(); if (oauthOutcome.kind === "connected") { toast.success(t(`settings.${provider}OauthConnected`)); - queryClient.invalidateQueries({ queryKey: key }); + // Polar/Oura own a per-card status query; WHOOP/Fitbit read off the + // consolidated envelope — invalidate both so the card repaints either way. + queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); } else { // Known reason tags resolve to a specific message; anything else falls // back to the generic copy (matching the Withings handler). - const knownReasons = new Set([ - "csrf1", - "state", - "cross_user", - "nocode", - "nocreds", - "token", - "rate_limited", - ]); - const reasonKey = knownReasons.has(oauthOutcome.reason) - ? `settings.${provider}OauthError.${oauthOutcome.reason}` - : `settings.${provider}OauthError.generic`; toast.error(t(`settings.${provider}OauthFailed`), { - description: t(reasonKey), + description: t(oauthReasonKey(provider, oauthOutcome.reason)), duration: 10_000, }); } @@ -147,6 +225,10 @@ export function IntegrationsSection() { const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); + // v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards + // no longer fire their own /api//status round-trip. + const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar")); + const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura")); return (
- - + +
); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 830904429..80672df84 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -23,7 +23,13 @@ vi.mock("@tanstack/react-query", () => ({ import { I18nProvider } from "@/lib/i18n/context"; import { OAuthProviderCard } from "../oauth-provider-card"; -function render({ credentials = false }: { credentials?: boolean } = {}) { +function render({ + credentials = false, + viewModel, +}: { + credentials?: boolean; + viewModel?: Parameters[0]["viewModel"]; +} = {}) { return renderToStaticMarkup( , ); @@ -74,6 +81,25 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => { expect(html).toContain('href="/insights/sleep"'); }); + it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => { + // The per-card useQuery mock returns null; the card must render the + // connected state from the supplied envelope view-model alone, proving the + // /api//status round-trip is no longer the source. + statusPayload = null; + const html = render({ + viewModel: { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + }, + }); + expect(html).toContain('data-testid="polar-data-link"'); + expect(html).toContain("Test connection"); + }); + it("does not render the data link or test button when disconnected", () => { statusPayload = { connected: false, diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index d7642ecab..1b0e5aa41 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -36,7 +36,11 @@ import { import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; -import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; import { IntegrationSetupGuideLink } from "./setup-guide-link"; export interface OAuthProviderStatus { @@ -85,6 +89,13 @@ export interface OAuthProviderCardProps { * button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */ credentials?: boolean; enabled?: boolean; + /** + * When provided, the card reads its status from this view-model (sourced off + * the consolidated `/api/integrations/status` envelope) instead of firing its + * own `/api//status` round-trip. v1.17.1 folds Polar/Oura onto the + * same envelope WHOOP/Fitbit already use, so the page reads from one source. + */ + viewModel?: OAuthProviderStatus; } export function OAuthProviderCard({ @@ -95,6 +106,7 @@ export function OAuthProviderCard({ dataHref, credentials = false, enabled = true, + viewModel, }: OAuthProviderCardProps) { const { t } = useTranslations(); const queryClient = useQueryClient(); @@ -107,12 +119,17 @@ export function OAuthProviderCard({ null, ); - const { data: status } = useQuery({ + // Read off the consolidated envelope when a view-model is passed; otherwise + // fall back to the per-card status fetch (still used by any caller that has + // not been migrated onto the envelope). The fetch is disabled once a + // view-model is supplied so the page makes one request, not one-per-card. + const { data: fetchedStatus } = useQuery({ queryKey: statusQueryKey, queryFn: async () => apiGet(`/api/${provider}/status`), - enabled, + enabled: enabled && !viewModel, refetchOnWindowFocus: true, }); + const status = viewModel ?? fetchedStatus; async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); @@ -134,6 +151,11 @@ export function OAuthProviderCard({ setClientId(""); setClientSecret(""); queryClient.invalidateQueries({ queryKey: statusQueryKey }); + // The card may read off the consolidated envelope — invalidate it too + // so the saved-credentials state repaints regardless of the source. + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); } else { try { const json = await res.json(); @@ -156,6 +178,9 @@ export function OAuthProviderCard({ }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: statusQueryKey }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); }, }); diff --git a/src/components/settings/integrations/oura-card.tsx b/src/components/settings/integrations/oura-card.tsx index a3ce82e78..6d9243a8c 100644 --- a/src/components/settings/integrations/oura-card.tsx +++ b/src/components/settings/integrations/oura-card.tsx @@ -6,10 +6,19 @@ import { CircleDot } from "lucide-react"; -import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card"; +import { + OAuthProviderCard, + type OAuthProviderStatus, +} from "@/components/settings/integrations/oauth-provider-card"; import { queryKeys } from "@/lib/query-keys"; -export function OuraCard({ enabled = true }: { enabled?: boolean }) { +export function OuraCard({ + enabled = true, + viewModel, +}: { + enabled?: boolean; + viewModel?: OAuthProviderStatus; +}) { return ( ); } diff --git a/src/components/settings/integrations/polar-card.tsx b/src/components/settings/integrations/polar-card.tsx index 56a310212..3ee6ff5cd 100644 --- a/src/components/settings/integrations/polar-card.tsx +++ b/src/components/settings/integrations/polar-card.tsx @@ -6,10 +6,19 @@ import { Watch } from "lucide-react"; -import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card"; +import { + OAuthProviderCard, + type OAuthProviderStatus, +} from "@/components/settings/integrations/oauth-provider-card"; import { queryKeys } from "@/lib/query-keys"; -export function PolarCard({ enabled = true }: { enabled?: boolean }) { +export function PolarCard({ + enabled = true, + viewModel, +}: { + enabled?: boolean; + viewModel?: OAuthProviderStatus; +}) { return ( ); } diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 8990d6652..3213a02bf 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -18,7 +18,13 @@ import { queryKeys } from "@/lib/query-keys"; // v1.4.19 Phase A5: the redundant in-card status banner is gone — the // IntegrationStatusPill now owns state + last-sync presentation, and // the actionable error message is shown inline above the action row. -export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog"; +export type IntegrationKey = + | "withings" + | "whoop" + | "fitbit" + | "moodlog" + | "polar" + | "oura"; export type IntegrationState = | "connected" | "error_transient" @@ -52,6 +58,11 @@ export interface IntegrationStatusViewModel { // moodLog webhook secret + entry count. webhookSecret?: string | null; entryCount?: number; + // Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available` + // greys out the connect button when no credentials resolve; `hasOwnCredentials` + // drives the saved-placeholder UI. + available?: boolean; + hasOwnCredentials?: boolean; } export interface IntegrationStatusEnvelope { diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 00221cea0..b10ce131c 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -399,6 +399,22 @@ describe("reconstructSleepSessions", () => { expect(new Set(ends).size).toBe(ends.length); }); + it("flags a Polar-won night as reconstructed (synthetic stage order)", () => { + // Polar exposes only per-stage duration totals, so its mapper reconstructs + // a contiguous CORE→DEEP→REM order exactly like WHOOP. The reader must label + // such a night reconstructed so the UI never presents the synthetic order as + // measured timing. + const rows: SleepStageRow[] = [ + srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "POLAR"), + srcRow("2026-06-04T03:00:00.000Z", "DEEP", 120, "POLAR"), + srcRow("2026-06-04T05:00:00.000Z", "REM", 120, "POLAR"), + ]; + const sessions = reconstructSleepSessions(rows, "UTC"); + expect(sessions).toHaveLength(1); + expect(sessions[0].source).toBe("POLAR"); + expect(sessions[0].reconstructed).toBe(true); + }); + it("does NOT flag an Apple-Health night as reconstructed (measured timeline)", () => { const rows: SleepStageRow[] = [ srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "APPLE_HEALTH"), diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 88024e6c1..138d94e20 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -285,13 +285,15 @@ const NO_SOURCE = "__none__"; * Sources whose per-segment stage ORDER is synthesised, not measured. WHOOP's * v2 API exposes only per-stage duration totals (no onset timestamps), so the * ingest mapper reconstructs a contiguous timeline in a fixed physiological - * order (`whoop/client.ts` `mapSleep`). A session won by such a source carries - * `reconstructed: true` so the UI labels it as an approximate layout. Apple - * Health / Withings / Fitbit all store a real per-segment series and stay off - * this set. + * order (`whoop/client.ts` + `polar/client.ts` `mapSleep`, both via the shared + * `reconstructContiguousSleepTimeline` builder). A session won by such a source + * carries `reconstructed: true` so the UI labels it as an approximate layout. + * Apple Health / Oura / Withings / Fitbit all store a real per-segment series + * and stay off this set. */ const RECONSTRUCTED_TIMELINE_SOURCES: ReadonlySet = new Set([ "WHOOP", + "POLAR", ]); /** diff --git a/src/lib/polar/__tests__/client.test.ts b/src/lib/polar/__tests__/client.test.ts index d68ae79f6..81ad2ff96 100644 --- a/src/lib/polar/__tests__/client.test.ts +++ b/src/lib/polar/__tests__/client.test.ts @@ -265,6 +265,53 @@ describe("mapSleep", () => { ).toBe(endMs); }); + it("emits an AWAKE segment from total_interruption_duration", () => { + const start = "2026-06-09T23:00:00+02:00"; // 21:00 UTC + const s: PolarSleep = { + date: "2026-06-10", + sleep_start_time: start, + sleep_end_time: "2026-06-10T07:00:00+02:00", + total_interruption_duration: 900, // 15 min awake + light_sleep: 3600, // 60 min + deep_sleep: 1800, // 30 min + rem_sleep: 5400, // 90 min + sleep_score: 82, + }; + const mapped = mapSleep(s); + const onset = Date.parse(start); + + // AWAKE is laid first (leading settling-in block), so the asleep stages + // partition the rest of the window and the night reader can surface real + // awake time + efficiency rather than a fully consolidated night. + const awake = mapped.find((m) => m.sleepStage === "AWAKE")!; + expect(awake).toBeDefined(); + expect(awake.value).toBe(15); + expect(awake.reconstructed).toBe(true); + expect(awake.externalId).toBe("sleep:2026-06-10:seg:sleep_awake:0"); + // AWAKE ends at +15m; CORE then starts there and is index 1. + expect(awake.measuredAt.getTime()).toBe(onset + 15 * 60_000); + + const core = mapped.find((m) => m.sleepStage === "CORE")!; + expect(core.externalId).toBe("sleep:2026-06-10:seg:sleep_core:1"); + expect(core.measuredAt.getTime()).toBe(onset + (15 + 60) * 60_000); + }); + + it("omits AWAKE when no interruption time is reported", () => { + const mapped = mapSleep({ + date: "2026-06-10", + sleep_start_time: "2026-06-09T23:00:00+02:00", + sleep_end_time: "2026-06-10T07:00:00+02:00", + light_sleep: 3600, + deep_sleep: 1800, + rem_sleep: 5400, + }); + expect(mapped.find((m) => m.sleepStage === "AWAKE")).toBeUndefined(); + // CORE stays index 0 when AWAKE is absent. + expect(mapped.find((m) => m.sleepStage === "CORE")!.externalId).toBe( + "sleep:2026-06-10:seg:sleep_core:0", + ); + }); + it("falls back to a midnight-UTC anchor when the window is missing", () => { const mapped = mapSleep({ date: "2026-06-10", diff --git a/src/lib/polar/client.ts b/src/lib/polar/client.ts index 1de24b073..7a8395d25 100644 --- a/src/lib/polar/client.ts +++ b/src/lib/polar/client.ts @@ -22,6 +22,7 @@ */ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { PolarApiError, classifyPolarResponse } from "./response-classifier"; const POLAR_API_BASE = "https://www.polaraccesslink.com"; @@ -481,51 +482,47 @@ export function mapSleep(s: PolarSleep): MappedMeasurement[] { Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs; const stages: Array< - [number | null | undefined, NonNullable, string] + [number | null | undefined, "CORE" | "DEEP" | "REM" | "AWAKE", string] > = [ + // AWAKE first — Polar reports the night's total interruption time, but no + // per-stage onset. Lay it as a leading settling-in/awake block so the asleep + // stages partition the remaining window and the night reader can surface a + // real awakeMinutes / efficiency rather than reading the night as fully + // consolidated. Matches WHOOP's leading-AWAKE ordering. + [s.total_interruption_duration, "AWAKE", "sleep_awake"], [s.light_sleep, "CORE", "sleep_core"], [s.deep_sleep, "DEEP", "sleep_deep"], [s.rem_sleep, "REM", "sleep_rem"], ]; if (haveWindow) { - // Reconstructed timeline: lay each asleep stage contiguously from onset, one - // timed row per segment ending at the running cursor. The order is - // synthetic (Polar gives no per-stage onset), so flag every segment. - let cursor = startMs; - let segIndex = 0; - for (const [sec, stage, fieldTag] of stages) { - if (typeof sec !== "number" || sec <= 0) continue; - const segEnd = cursor + sec * 1000; - out.push({ - type: "SLEEP_DURATION", - value: round2(sec * SEC_TO_MIN), - unit: "minutes", - measuredAt: new Date(segEnd), - fieldTag, + // Reconstructed timeline: lay each stage contiguously from onset, one timed + // row per segment ending at the running cursor. The order is synthetic + // (Polar gives no per-stage onset), so the shared builder flags every + // segment reconstructed; the same algorithm backs WHOOP's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs, + stages: stages.map(([sec, stage, fieldTag]) => ({ + durationMs: + typeof sec === "number" && Number.isFinite(sec) ? sec * 1000 : sec, + stage, + fieldTag, + })), + // IN_BED — single envelope row over the whole sleep window, stamped at + // the sleep END so the in-bed reader resolves the span back to + // [start, end]. + inBed: { + durationMs: endMs - startMs, + measuredAt: new Date(endMs), + fieldTag: "sleep_in_bed", + }, // Indexed externalId keeps the several segment rows of one night // distinct under userId_type_source_externalId. - externalId: `sleep:${s.date}:seg:${fieldTag}:${segIndex}`, - sleepStage: stage, - reconstructed: true, - }); - cursor = segEnd; - segIndex += 1; - } - - // IN_BED — single envelope row over the whole sleep window, stamped at the - // sleep END so the in-bed reader resolves the span back to [start, end]. - const inBedSec = (endMs - startMs) / 1000; - if (inBedSec > 0) { - out.push({ - type: "SLEEP_DURATION", - value: round2(inBedSec * SEC_TO_MIN), - unit: "minutes", - measuredAt: new Date(endMs), - fieldTag: "sleep_in_bed", - sleepStage: "IN_BED", - }); - } + externalIdFor: (fieldTag, index) => + `sleep:${s.date}:seg:${fieldTag}:${index}`, + }), + ); if (typeof s.sleep_score === "number") { out.push({ diff --git a/src/lib/sleep/__tests__/reconstruct-timeline.test.ts b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts new file mode 100644 index 000000000..121eb22a6 --- /dev/null +++ b/src/lib/sleep/__tests__/reconstruct-timeline.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "vitest"; + +import { reconstructContiguousSleepTimeline } from "../reconstruct-timeline"; + +describe("reconstructContiguousSleepTimeline", () => { + const onset = Date.parse("2026-06-09T21:00:00.000Z"); + const end = Date.parse("2026-06-10T05:00:00.000Z"); + + it("lays stages contiguously from onset, each ending at its own instant", () => { + const rows = reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }, + { durationMs: 30 * 60_000, stage: "DEEP", fieldTag: "sleep_deep" }, + { durationMs: 90 * 60_000, stage: "REM", fieldTag: "sleep_rem" }, + ], + externalIdFor: (tag, i) => `night:seg:${tag}:${i}`, + }); + const core = rows.find((r) => r.sleepStage === "CORE")!; + const deep = rows.find((r) => r.sleepStage === "DEEP")!; + const rem = rows.find((r) => r.sleepStage === "REM")!; + expect(core.value).toBe(60); + expect(core.measuredAt.getTime()).toBe(onset + 60 * 60_000); + expect(deep.measuredAt.getTime()).toBe(onset + 90 * 60_000); + expect(rem.measuredAt.getTime()).toBe(onset + 180 * 60_000); + // Distinct instants → ordered hypnogram, not a shared right edge. + expect(new Set(rows.map((r) => r.measuredAt.getTime())).size).toBe(3); + }); + + it("flags every laid segment reconstructed and keys an indexed externalId", () => { + const rows = reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { durationMs: 15 * 60_000, stage: "AWAKE", fieldTag: "sleep_awake" }, + { durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }, + ], + externalIdFor: (tag, i) => `night:seg:${tag}:${i}`, + }); + expect(rows.every((r) => r.reconstructed === true)).toBe(true); + expect(rows[0].externalId).toBe("night:seg:sleep_awake:0"); + expect(rows[1].externalId).toBe("night:seg:sleep_core:1"); + }); + + it("skips non-positive, null, undefined and non-finite durations", () => { + const rows = reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { durationMs: 0, stage: "AWAKE", fieldTag: "sleep_awake" }, + { durationMs: null, stage: "CORE", fieldTag: "sleep_core" }, + { durationMs: undefined, stage: "DEEP", fieldTag: "sleep_deep" }, + { durationMs: Number.NaN, stage: "REM", fieldTag: "sleep_rem" }, + { durationMs: 30 * 60_000, stage: "CORE", fieldTag: "sleep_core" }, + ], + externalIdFor: (tag, i) => `night:seg:${tag}:${i}`, + }); + expect(rows).toHaveLength(1); + // The only laid stage keeps index 0 (skipped stages do not consume indices). + expect(rows[0].externalId).toBe("night:seg:sleep_core:0"); + expect(rows[0].measuredAt.getTime()).toBe(onset + 30 * 60_000); + }); + + it("appends a single IN_BED envelope at the given END instant", () => { + const rows = reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }], + inBed: { + durationMs: end - onset, + measuredAt: new Date(end), + fieldTag: "sleep_in_bed", + }, + externalIdFor: (tag, i) => `night:seg:${tag}:${i}`, + }); + const inBed = rows.find((r) => r.sleepStage === "IN_BED")!; + expect(inBed.measuredAt.getTime()).toBe(end); + expect(inBed.value).toBe(480); // 8 h + // IN_BED is an envelope, not a placed segment — no reconstructed flag / id. + expect(inBed.reconstructed).toBeUndefined(); + expect(inBed.externalId).toBeUndefined(); + }); + + it("omits IN_BED when its duration is missing or non-positive", () => { + const rows = reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [{ durationMs: 60 * 60_000, stage: "CORE", fieldTag: "sleep_core" }], + inBed: { durationMs: 0, measuredAt: new Date(end), fieldTag: "sleep_in_bed" }, + externalIdFor: (tag, i) => `night:seg:${tag}:${i}`, + }); + expect(rows.find((r) => r.sleepStage === "IN_BED")).toBeUndefined(); + }); +}); diff --git a/src/lib/sleep/reconstruct-timeline.ts b/src/lib/sleep/reconstruct-timeline.ts new file mode 100644 index 000000000..cc0bfa335 --- /dev/null +++ b/src/lib/sleep/reconstruct-timeline.ts @@ -0,0 +1,129 @@ +/** + * Shared reconstructed-sleep-timeline builder. + * + * Some wearables (WHOOP v2, Polar) expose only per-stage DURATION totals for a + * night — no per-stage onset timestamps and no hypnogram endpoint. Stamping + * every stage total on the one sleep-END instant collapses the hypnogram into + * overlapping spans that all touch the night's right edge. Since the API never + * carries an order, both vendors RECONSTRUCT an ordered, contiguous timeline: + * lay the stages back-to-back from sleep ONSET in a fixed physiological order, + * emitting one timed row per segment with `measuredAt = that segment's END`. + * + * The ORDER is synthetic, so every laid segment is flagged `reconstructed: + * true`; the night DTO advertises it and the UI labels such a night as an + * approximate layout, never as measured stage timing. The reader's honesty + * flag (`analytics/sleep-night.ts` `RECONSTRUCTED_TIMELINE_SOURCES`) carries + * the same contract on the read side. + * + * This helper is the single source of the algorithm; each vendor `mapSleep` + * keeps only its field-name → tuple normalisation (ms vs sec, source field + * names) so the segment shape and externalId scheme can never drift between the + * two synthetic-timeline vendors. Oura's measured 5-min hypnogram is a + * different algorithm and does NOT use this helper. + */ + +/** The sleep-row shape both vendor mappers emit. Kept structurally identical to + * each vendor's local `MappedMeasurement`; the helper returns rows assignable to + * either. */ +export interface ReconstructedSleepRow { + type: "SLEEP_DURATION"; + value: number; + unit: "minutes"; + measuredAt: Date; + fieldTag: string; + externalId?: string; + sleepStage: "CORE" | "DEEP" | "REM" | "AWAKE" | "IN_BED"; + reconstructed?: boolean; +} + +/** One asleep/awake stage to lay onto the reconstructed timeline. `durationMs` + * is the stage total in milliseconds; non-positive or non-finite durations are + * skipped. */ +export interface ReconstructedStage { + durationMs: number | null | undefined; + stage: "CORE" | "DEEP" | "REM" | "AWAKE"; + fieldTag: string; +} + +/** Optional single IN_BED envelope row over the whole sleep window. The in-bed + * reader consumes the union envelope, so this stays one row stamped at the + * sleep END (`measuredAt`) — NOT a placed segment. */ +export interface ReconstructedInBed { + durationMs: number | null | undefined; + /** Sleep END instant; the in-bed reader resolves the span back to its window. */ + measuredAt: Date; + fieldTag: string; +} + +export interface ReconstructTimelineOptions { + /** Sleep ONSET instant; the contiguous walk starts here. */ + startMs: number; + /** Stages in physiological lay-out order (e.g. AWAKE → CORE → DEEP → REM). */ + stages: ReadonlyArray; + /** Optional IN_BED envelope row. */ + inBed?: ReconstructedInBed; + /** + * Builds the indexed externalId for a laid segment so the several rows of one + * night stay distinct under `userId_type_source_externalId`. Called with the + * segment's fieldTag and its running index. + */ + externalIdFor: (fieldTag: string, index: number) => string; +} + +const MS_TO_MIN = 1 / 60_000; + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +/** + * Lay the asleep/awake stages contiguously from `startMs` in the given order, + * emitting one timed `SLEEP_DURATION` row per stage (`measuredAt` = that + * segment's END), each flagged `reconstructed: true` and keyed by an indexed + * externalId. Appends a single IN_BED envelope row when `inBed` is provided. + */ +export function reconstructContiguousSleepTimeline( + opts: ReconstructTimelineOptions, +): ReconstructedSleepRow[] { + const { startMs, stages, inBed, externalIdFor } = opts; + const out: ReconstructedSleepRow[] = []; + + let cursor = startMs; + let segIndex = 0; + for (const { durationMs, stage, fieldTag } of stages) { + if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) { + continue; + } + const segEnd = cursor + durationMs; + out.push({ + type: "SLEEP_DURATION", + value: round2(durationMs * MS_TO_MIN), + unit: "minutes", + measuredAt: new Date(segEnd), + fieldTag, + externalId: externalIdFor(fieldTag, segIndex), + sleepStage: stage, + reconstructed: true, + }); + cursor = segEnd; + segIndex += 1; + } + + if ( + inBed && + typeof inBed.durationMs === "number" && + Number.isFinite(inBed.durationMs) && + inBed.durationMs > 0 + ) { + out.push({ + type: "SLEEP_DURATION", + value: round2(inBed.durationMs * MS_TO_MIN), + unit: "minutes", + measuredAt: inBed.measuredAt, + fieldTag: inBed.fieldTag, + sleepStage: "IN_BED", + }); + } + + return out; +} diff --git a/src/lib/whoop/client.ts b/src/lib/whoop/client.ts index 19c30ddc7..84d0a01ad 100644 --- a/src/lib/whoop/client.ts +++ b/src/lib/whoop/client.ts @@ -17,6 +17,7 @@ */ import { getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; +import { reconstructContiguousSleepTimeline } from "@/lib/sleep/reconstruct-timeline"; import { WhoopApiError, classifyWhoopResponse } from "./response-classifier"; const WHOOP_API_BASE = "https://api.prod.whoop.com/developer"; @@ -611,55 +612,38 @@ export function mapSleep(s: WhoopSleep): MappedMeasurement[] { // the remaining window. const onset = new Date(s.start).getTime(); // Order the laid-out stages: a brief AWAKE settling-in block, then the asleep - // stages CORE → DEEP → REM. Each carries its own duration; the cursor walks - // forward so the spans are contiguous and non-overlapping. - const TIMELINE_ORDER: ReadonlyArray<{ - key: keyof typeof stages; - stage: NonNullable; - tag: string; - }> = [ - { key: "total_awake_time_milli", stage: "AWAKE", tag: "sleep_awake" }, - { key: "total_light_sleep_time_milli", stage: "CORE", tag: "sleep_core" }, - { key: "total_slow_wave_sleep_time_milli", stage: "DEEP", tag: "sleep_deep" }, - { key: "total_rem_sleep_time_milli", stage: "REM", tag: "sleep_rem" }, - ]; - let cursor = onset; - let segIndex = 0; - for (const { key, stage, tag } of TIMELINE_ORDER) { - const ms = stages[key]; - if (typeof ms !== "number" || ms <= 0) continue; - const segEnd = cursor + ms; - out.push({ - type: "SLEEP_DURATION", - value: round2(ms * MS_TO_MIN), - unit: "minutes", - measuredAt: new Date(segEnd), + // stages CORE → DEEP → REM. Each carries its own duration; the shared builder + // walks the cursor forward so the spans are contiguous and non-overlapping, + // and lays the IN_BED envelope over the whole window. The order is synthetic + // (WHOOP gives no per-stage onset), so every laid segment is flagged + // reconstructed; the same algorithm backs Polar's `mapSleep`. + out.push( + ...reconstructContiguousSleepTimeline({ + startMs: onset, + stages: [ + { durationMs: stages.total_awake_time_milli, stage: "AWAKE", fieldTag: "sleep_awake" }, + { durationMs: stages.total_light_sleep_time_milli, stage: "CORE", fieldTag: "sleep_core" }, + { + durationMs: stages.total_slow_wave_sleep_time_milli, + stage: "DEEP", + fieldTag: "sleep_deep", + }, + { durationMs: stages.total_rem_sleep_time_milli, stage: "REM", fieldTag: "sleep_rem" }, + ], + // IN_BED — single envelope row over the whole sleep window. `measuredAt` + // is the sleep END so `segmentOf` resolves the span back to [start, end]; + // the in-bed union-envelope reader consumes the full window. + inBed: { + durationMs: stages.total_in_bed_time_milli, + measuredAt, + fieldTag: "sleep_in_bed", + }, // The sync layer keys reconstructed rows by this externalId verbatim; the // index keeps the several rows of one night distinct under // userId_type_source_externalId. - fieldTag: tag, - externalId: `${s.id}:seg:${tag}:${segIndex}`, - sleepStage: stage, - reconstructed: true, - }); - cursor = segEnd; - segIndex += 1; - } - - // IN_BED — single envelope row over the whole sleep window. `measuredAt` is - // the sleep END so `segmentOf` resolves the span back to [start, end]; the - // in-bed union-envelope reader consumes the full window. - const inBedMs = stages.total_in_bed_time_milli; - if (typeof inBedMs === "number" && inBedMs > 0) { - out.push({ - type: "SLEEP_DURATION", - value: round2(inBedMs * MS_TO_MIN), - unit: "minutes", - measuredAt, - fieldTag: "sleep_in_bed", - sleepStage: "IN_BED", - }); - } + externalIdFor: (tag, index) => `${s.id}:seg:${tag}:${index}`, + }), + ); const need = s.score.sleep_needed; const totalNeedMilli =