From 95739369e142023028ee46f14414c19e309e0f2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:11:05 +0200 Subject: [PATCH 1/3] fix(insights): point sleep empty-state CTA at a real settings slug --- .../sleep/__tests__/empty-state-href.test.ts | 35 +++++++++++++++++++ src/app/insights/sleep/page.tsx | 4 +-- 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 src/app/insights/sleep/__tests__/empty-state-href.test.ts diff --git a/src/app/insights/sleep/__tests__/empty-state-href.test.ts b/src/app/insights/sleep/__tests__/empty-state-href.test.ts new file mode 100644 index 000000000..546cfacfe --- /dev/null +++ b/src/app/insights/sleep/__tests__/empty-state-href.test.ts @@ -0,0 +1,35 @@ +/** + * v1.17.0 — the sleep empty-state CTA must point at a REAL settings slug. + * It previously linked to `/settings/data-sources`, which is not in + * `SETTINGS_SECTION_SLUGS`, so the dynamic `[section]` route calls + * `notFound()` → a 404 dead-end. This pins the href to the `integrations` + * slug and guards against a regression to any non-existent slug. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { isSettingsSectionSlug } from "@/components/settings/section-slugs"; + +describe("sleep empty-state CTA href", () => { + it("links to /settings/integrations (a real settings slug)", () => { + const source = readFileSync( + join(process.cwd(), "src/app/insights/sleep/page.tsx"), + "utf8", + ); + const match = source.match(/href="\/settings\/([a-z-]+)"/); + expect(match, "expected a /settings/ href in the sleep page").not.toBeNull(); + const slug = match![1]; + expect(slug).toBe("integrations"); + expect(isSettingsSectionSlug(slug)).toBe(true); + }); + + it("never links to the removed /settings/data-sources slug", () => { + const source = readFileSync( + join(process.cwd(), "src/app/insights/sleep/page.tsx"), + "utf8", + ); + expect(source).not.toContain("/settings/data-sources"); + }); +}); diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index 514ac15a1..1cac47cd7 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -25,7 +25,7 @@ import { SubPageShell } from "@/components/insights/sub-page-shell"; * * v1.4.27 F17 — when `summaries.SLEEP_DURATION.count === 0` (no * Apple-Health / Withings sleep rows yet), the page short-circuits - * to an empty-state CTA pointing at `/settings/data-sources` so the + * to an empty-state CTA pointing at `/settings/integrations` so the * user can connect a sleep source. * * v1.4.28 R3d (BK-F-H1 + BK-F-M1) — analytics fetch + empty-state @@ -50,7 +50,7 @@ export default function InsightsSchlafPage() { description={t("insights.emptyState.sleep.description")} cta={ From e5fad221a009b35c58ab5ac933e7b9d6df8447f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:11:09 +0200 Subject: [PATCH 2/3] feat(integrations): add test-connection endpoints for Nightscout, Polar, Oura --- src/app/api/nightscout/test/route.ts | 90 ++++++++++++++++++++++++++++ src/app/api/oura/test/route.ts | 82 +++++++++++++++++++++++++ src/app/api/polar/test/route.ts | 81 +++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 src/app/api/nightscout/test/route.ts create mode 100644 src/app/api/oura/test/route.ts create mode 100644 src/app/api/polar/test/route.ts diff --git a/src/app/api/nightscout/test/route.ts b/src/app/api/nightscout/test/route.ts new file mode 100644 index 000000000..854e5ea32 --- /dev/null +++ b/src/app/api/nightscout/test/route.ts @@ -0,0 +1,90 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { getUserNightscoutCredentials } from "@/lib/nightscout/credentials"; +import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; +import { SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Cheap live probe — re-validate the stored URL + token by pulling a single +// SGV entry off the user's instance via the same client the sync uses. No new +// credentials path: it reuses `getUserNightscoutCredentials` + `fetchSgvEntries`. +const TIMEOUT_MS = 5_000; + +function categoriseStatus(status: number | null): { + code: string; + message: string; +} { + if (status === 401 || status === 403) { + return { + code: "credentials_rejected", + message: "Nightscout rejected the token", + }; + } + if (status === 429) { + return { + code: "rate_limited", + message: "Nightscout rate-limited the request", + }; + } + if (status !== null && status >= 500) { + return { code: "upstream_error", message: "Nightscout returned an error" }; + } + return { code: "connection_failed", message: "Nightscout connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "nightscout.test" } }); + + const rl = await checkRateLimit(`nightscout-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const creds = await getUserNightscoutCredentials(user.id); + if (!creds) { + return apiError("Nightscout not connected", 422, { + errorCode: "not_configured", + }); + } + + const start = performance.now(); + + try { + await fetchSgvEntries({ + baseUrl: creds.baseUrl, + token: creds.token, + count: 1, + allowPrivateHost: creds.allowPrivateHost, + timeoutMs: TIMEOUT_MS, + }); + const latencyMs = Math.round(performance.now() - start); + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + if (e instanceof NightscoutApiError) { + const cat = categoriseStatus(e.status); + annotate({ + meta: { nightscout_test_status: e.status, nightscout_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const isPrivate = e instanceof SafeFetchError && e.kind === "private_host"; + const code = isTimeout + ? "timeout" + : isPrivate + ? "url_not_public" + : "connection_failed"; + annotate({ meta: { nightscout_test_code: code } }); + return apiError( + isTimeout ? "Nightscout request timed out" : "Nightscout connection failed", + 502, + { errorCode: code }, + ); + } +}); diff --git a/src/app/api/oura/test/route.ts b/src/app/api/oura/test/route.ts new file mode 100644 index 000000000..7e659115f --- /dev/null +++ b/src/app/api/oura/test/route.ts @@ -0,0 +1,82 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { getOuraConnection } from "@/lib/oura/credentials"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Lightweight token probe — fetch the personal-info record, the cheapest +// authenticated Oura call that confirms the stored grant is still valid. +// Reuses `getOuraConnection`; no new credential path. +// Base from `src/lib/oura/client.ts` (`OURA_API_BASE`). +const OURA_PERSONAL_INFO_URL = + "https://api.ouraring.com/v2/usercollection/personal_info"; +const TIMEOUT_MS = 5_000; + +function categoriseHttpStatus(status: number): { code: string; message: string } { + if (status === 401 || status === 403) { + return { code: "credentials_rejected", message: "Oura rejected the token" }; + } + if (status === 429) { + return { code: "rate_limited", message: "Oura rate-limited the request" }; + } + if (status >= 500) { + return { code: "upstream_error", message: "Oura returned a server error" }; + } + return { code: "connection_failed", message: "Oura connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "oura.test" } }); + + const rl = await checkRateLimit(`oura-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const connection = await getOuraConnection(user.id); + if (!connection) { + return apiError("Oura not connected", 422, { errorCode: "not_configured" }); + } + + const start = performance.now(); + + try { + const res = await safeFetch( + OURA_PERSONAL_INFO_URL, + { + method: "GET", + headers: { + Authorization: `Bearer ${connection.accessToken}`, + Accept: "application/json", + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { oura_test_status: res.status, oura_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const code = isTimeout ? "timeout" : "connection_failed"; + annotate({ meta: { oura_test_code: code } }); + return apiError( + isTimeout ? "Oura request timed out" : "Oura connection failed", + 502, + { errorCode: code }, + ); + } +}); diff --git a/src/app/api/polar/test/route.ts b/src/app/api/polar/test/route.ts new file mode 100644 index 000000000..ebd809626 --- /dev/null +++ b/src/app/api/polar/test/route.ts @@ -0,0 +1,81 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess, apiError } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; +import { getPolarConnection } from "@/lib/polar/credentials"; +import { safeFetch, SafeFetchError } from "@/lib/safe-fetch"; + +export const dynamic = "force-dynamic"; + +// Lightweight token probe — fetch the registered AccessLink user record, the +// cheapest authenticated Polar call that confirms the stored grant is still +// valid. Reuses `getPolarConnection`; no new credential path. +// Base from `src/lib/polar/client.ts` (`POLAR_API_BASE`). +const POLAR_USERS_BASE = "https://www.polaraccesslink.com/v3/users"; +const TIMEOUT_MS = 5_000; + +function categoriseHttpStatus(status: number): { code: string; message: string } { + if (status === 401 || status === 403) { + return { code: "credentials_rejected", message: "Polar rejected the token" }; + } + if (status === 429) { + return { code: "rate_limited", message: "Polar rate-limited the request" }; + } + if (status >= 500) { + return { code: "upstream_error", message: "Polar returned a server error" }; + } + return { code: "connection_failed", message: "Polar connection failed" }; +} + +export const POST = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "polar.test" } }); + + const rl = await checkRateLimit(`polar-test:${user.id}`, 5, 60_000); + if (!rl.allowed) { + return apiError("Too many test requests", 429, { + errorCode: "rate_limited_self", + }); + } + + const connection = await getPolarConnection(user.id); + if (!connection) { + return apiError("Polar not connected", 422, { errorCode: "not_configured" }); + } + + const start = performance.now(); + + try { + const res = await safeFetch( + `${POLAR_USERS_BASE}/${encodeURIComponent(connection.polarUserId)}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${connection.accessToken}`, + Accept: "application/json", + }, + }, + { timeoutMs: TIMEOUT_MS }, + ); + const latencyMs = Math.round(performance.now() - start); + + if (!res.ok) { + const cat = categoriseHttpStatus(res.status); + annotate({ + meta: { polar_test_status: res.status, polar_test_code: cat.code }, + }); + return apiError(cat.message, 502, { errorCode: cat.code }); + } + + return apiSuccess({ ok: true, latencyMs }); + } catch (e) { + const isTimeout = e instanceof SafeFetchError && e.kind === "timeout"; + const code = isTimeout ? "timeout" : "connection_failed"; + annotate({ meta: { polar_test_code: code } }); + return apiError( + isTimeout ? "Polar request timed out" : "Polar connection failed", + 502, + { errorCode: code }, + ); + } +}); From 633a451c1656daa57927e89f138dd044580b2268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 14 Jun 2026 21:11:15 +0200 Subject: [PATCH 3/3] feat(integrations): match parked, test, and data-link treatment on new cards --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../__tests__/nightscout-card.test.tsx | 85 ++++++++++ .../__tests__/oauth-provider-card.test.tsx | 88 +++++++++++ .../settings/integrations/nightscout-card.tsx | 145 +++++++++++++----- .../integrations/oauth-provider-card.tsx | 123 +++++++++++---- .../settings/integrations/oura-card.tsx | 4 + .../settings/integrations/polar-card.tsx | 4 + 12 files changed, 396 insertions(+), 71 deletions(-) create mode 100644 src/components/settings/integrations/__tests__/nightscout-card.test.tsx create mode 100644 src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx diff --git a/messages/de.json b/messages/de.json index da188c5c9..0e16cf538 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.", + "nightscoutViewData": "Glukose ansehen", "nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.", "nightscoutUrl": "Nightscout-URL", "nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.", "polar": "Polar", "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.", + "polarViewData": "Schlaf & Erholung ansehen", "polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", "polarConnect": "Polar verbinden", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.", + "ouraViewData": "Schlaf & Erholung ansehen", "ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.", "ouraConnect": "Oura verbinden", diff --git a/messages/en.json b/messages/en.json index 162ac5504..68afa4acc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.", + "nightscoutViewData": "View your glucose", "nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.", "nightscoutUrl": "Nightscout URL", "nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.", "polar": "Polar", "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.", + "polarViewData": "View your sleep & recovery", "polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.", "polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", "polarConnect": "Connect Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.", + "ouraViewData": "View your sleep & recovery", "ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.", "ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.", "ouraConnect": "Connect Oura", diff --git a/messages/es.json b/messages/es.json index ac43c3815..ac2bf14e3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.", + "nightscoutViewData": "Ver tu glucosa", "nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.", "nightscoutUrl": "URL de Nightscout", "nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.", "polar": "Polar", "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.", + "polarViewData": "Ver tu sueño y recuperación", "polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", "polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", "polarConnect": "Conectar Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.", + "ouraViewData": "Ver tu sueño y recuperación", "ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", "ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.", "ouraConnect": "Conectar Oura", diff --git a/messages/fr.json b/messages/fr.json index 1f8eaa98e..182ec080b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.", + "nightscoutViewData": "Voir votre glycémie", "nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.", "nightscoutUrl": "URL Nightscout", "nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.", "polar": "Polar", "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.", + "polarViewData": "Voir votre sommeil et récupération", "polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", "polarConnect": "Connecter Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.", + "ouraViewData": "Voir votre sommeil et récupération", "ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.", "ouraConnect": "Connecter Oura", diff --git a/messages/it.json b/messages/it.json index bc5a81c1c..4d833acfb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.", + "nightscoutViewData": "Vedi la tua glicemia", "nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.", "nightscoutUrl": "URL Nightscout", "nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.", "polar": "Polar", "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.", + "polarViewData": "Vedi sonno e recupero", "polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", "polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", "polarConnect": "Collega Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.", + "ouraViewData": "Vedi sonno e recupero", "ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", "ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.", "ouraConnect": "Collega Oura", diff --git a/messages/pl.json b/messages/pl.json index e66363b37..13147adea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", + "nightscoutViewData": "Zobacz swoją glukozę", "nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.", "nightscoutUrl": "Adres URL Nightscout", "nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.", "polar": "Polar", "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.", + "polarViewData": "Zobacz sen i regenerację", "polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", "polarConnect": "Połącz Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.", + "ouraViewData": "Zobacz sen i regenerację", "ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.", "ouraConnect": "Połącz Oura", diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx new file mode 100644 index 000000000..6b5a6b0ac --- /dev/null +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -0,0 +1,85 @@ +/** + * v1.17.0 — interaction-parity + correctness checks for the Nightscout card. + * + * 1. A `parked` status renders the warning banner + reconnect button + * (matching the WHOOP card's parked treatment, byte-for-byte classes). + * 2. A `connected` status renders the shared TestConnectionButton AND the + * connect→data link to /insights/blood-glucose. + * 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the + * status query actually reads — so the pill refreshes after disconnect. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { queryKeys } from "@/lib/query-keys"; + +// Capture the disconnect mutation's onSuccess so the test can assert which +// query keys it invalidates. Status is driven off a per-test payload. +let statusPayload: unknown = null; +const invalidateSpy = vi.fn(); +type OnSuccess = () => void; +const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null }; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: statusPayload, isLoading: false }), + useMutation: ({ onSuccess }: { onSuccess?: () => void }) => { + // The Nightscout card declares exactly one mutation (disconnect). + capturedDisconnectOnSuccess.fn = onSuccess ?? null; + return { mutate: vi.fn(), isPending: false }; + }, + useQueryClient: () => ({ invalidateQueries: invalidateSpy }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NightscoutCard } from "../nightscout-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("NightscoutCard — parked + test + data-link + invalidation", () => { + it("renders the parked banner + reconnect button when state is parked", () => { + statusPayload = { + connected: true, + configured: true, + state: "parked", + lastSuccessAt: null, + lastError: "Nightscout unreachable", + }; + const html = render(); + expect(html).toContain('data-state="parked"'); + expect(html).toContain('data-testid="nightscout-parked-banner"'); + expect(html).toContain('data-testid="nightscout-resume-button"'); + expect(html).toContain("border-warning/30 bg-warning/10"); + }); + + it("renders the test-connection button + data link when connected", () => { + statusPayload = { + connected: true, + configured: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + }; + const html = render(); + expect(html).toContain("Test connection"); + expect(html).toContain('data-testid="nightscout-data-link"'); + expect(html).toContain('href="/insights/blood-glucose"'); + }); + + it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => { + statusPayload = { connected: true, configured: true, state: "connected" }; + invalidateSpy.mockClear(); + capturedDisconnectOnSuccess.fn = null; + render(); + const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null; + expect(typeof onSuccess).toBe("function"); + onSuccess?.(); + const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); + expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus()); + }); +}); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx new file mode 100644 index 000000000..1e6b801dd --- /dev/null +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -0,0 +1,88 @@ +/** + * v1.17.0 — interaction-parity check for the shared OAuth integration card + * (Polar / Oura). The new cards must match the existing WHOOP card's + * parked-state + test-connection + connect→data treatment: + * + * 1. A `parked` status renders the warning banner + reconnect button. + * 2. A `connected` status renders the shared TestConnectionButton (its + * "Test connection" affordance) AND the connect→data link. + * 3. The data link resolves to the provider's insight surface. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Watch } from "lucide-react"; + +// Drive the card's own `useQuery` status read off a per-test payload. +let statusPayload: unknown = null; +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: statusPayload, isLoading: false }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { OAuthProviderCard } from "../oauth-provider-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("OAuthProviderCard — parked + test + data-link parity", () => { + it("renders the parked banner + reconnect button when state is parked", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + state: "parked", + lastSuccessAt: null, + lastError: "Polar grant expired", + }; + const html = render(); + expect(html).toContain('data-state="parked"'); + expect(html).toContain('data-testid="polar-parked-banner"'); + expect(html).toContain('data-testid="polar-resume-button"'); + expect(html).toContain("reconnect manually"); + // The parked banner uses the same warning treatment as the WHOOP card. + expect(html).toContain("border-warning/30 bg-warning/10"); + }); + + it("renders the test-connection button + data link when connected", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + }; + const html = render(); + // The shared TestConnectionButton surfaces its "Test connection" label. + expect(html).toContain("Test connection"); + // connect→data link points at the provider's insight surface. + expect(html).toContain('data-testid="polar-data-link"'); + expect(html).toContain('href="/insights/sleep"'); + }); + + it("does not render the data link or test button when disconnected", () => { + statusPayload = { + connected: false, + configured: false, + available: true, + }; + const html = render(); + expect(html).not.toContain('data-testid="polar-data-link"'); + expect(html).not.toContain("Test connection"); + // The connect CTA stands in instead. + expect(html).toContain('data-testid="polar-connect"'); + }); +}); diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index c42bbfc91..97edbc3e7 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -7,9 +7,10 @@ // consolidated envelope. Warm copy, mobile-first, the private-network opt-in // toggle maps to `nightscoutAllowPrivateHost`. -import { useState } from "react"; +import { useRef, useState } from "react"; +import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Activity, Link2, Loader2, Save, Unlink } from "lucide-react"; +import { ArrowRight, Droplet, Link2, Loader2, Save, Unlink } from "lucide-react"; import { AlertDialog, @@ -32,6 +33,7 @@ import { IntegrationStatusPill, type IntegrationPillState, } from "@/components/settings/integration-status-pill"; +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, queryKeys } from "@/lib/query-keys"; @@ -82,6 +84,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { refetchOnWindowFocus: true, }); + const formRef = useRef(null); + const disconnect = useMutation({ mutationFn: async () => { await apiPost("/api/nightscout/disconnect"); @@ -90,7 +94,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { setUrl(""); setToken(""); setAllowPrivateHost(false); - queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + // The status read keys on `nightscoutStatus()`; invalidate it so the + // pill flips back after a disconnect (matching the Withings / WHOOP + // pattern), alongside the cross-integration envelope. + queryClient.invalidateQueries({ + queryKey: queryKeys.nightscoutStatus(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); }, }); @@ -114,7 +126,12 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { setMsgType("success"); setToken(""); void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.nightscoutStatus(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); } else { let detail = t("settings.nightscoutConnectFailed"); try { @@ -145,7 +162,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { className="bg-card border-border rounded-xl border p-4 sm:p-6" > @@ -166,11 +183,46 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
{errorMessage && } + {/* Parked-integration resume CTA. Surfaces only when the row state + is `parked` (>24h of persistent failures). Nightscout has no OAuth + redirect — the user re-validates by re-submitting the connect form, + so "reconnect" scrolls the form into view + focuses the URL field. + Markup matches the WHOOP card byte for byte. */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} +

{t("settings.nightscoutHelp")}

-
+
{status?.connected && ( - - - - - - - - {t("settings.nightscoutDisconnectTitle")} - - - {t("settings.nightscoutDisconnectDescription")} - - - - {t("common.cancel")} - disconnect.mutate()} - > - {t("settings.nightscoutDisconnect")} - - - - + <> +
+ + + + + + + + + {t("settings.nightscoutDisconnectTitle")} + + + {t("settings.nightscoutDisconnectDescription")} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t("settings.nightscoutDisconnect")} + + + + +
+ {/* connect→data loop: a discreet link to where the glucose + readings now surface — doubles as the "your data is richer" + cue. */} + + {t("settings.nightscoutViewData")} + + + )}
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 42f68950a..ea783d179 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -8,8 +8,9 @@ // Mirrors the Nightscout card's self-contained status pattern. import { useState } from "react"; +import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Activity, Link2, Unlink } from "lucide-react"; +import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react"; import { AlertDialog, @@ -28,6 +29,7 @@ import { IntegrationStatusPill, type IntegrationPillState, } from "@/components/settings/integration-status-pill"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys"; @@ -68,6 +70,10 @@ export interface OAuthProviderCardProps { statusQueryKey: readonly unknown[]; /** i18n key prefix (e.g. `settings.polar`). */ i18nPrefix: string; + /** Distinct card glyph, kept in the same size/treatment as the other cards. */ + icon: LucideIcon; + /** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */ + dataHref: string; enabled?: boolean; } @@ -75,6 +81,8 @@ export function OAuthProviderCard({ provider, statusQueryKey, i18nPrefix, + icon, + dataHref, enabled = true, }: OAuthProviderCardProps) { const { t } = useTranslations(); @@ -116,7 +124,7 @@ export function OAuthProviderCard({ className="bg-card border-border rounded-xl border p-4 sm:p-6" > {t(`${i18nPrefix}Description`)}

} status={ @@ -140,6 +148,36 @@ export function OAuthProviderCard({

)} + {/* Parked-integration resume CTA. Surfaces only when the row state + is `parked` (>24h of persistent failures). For an env-based OAuth + provider there are no stored BYO credentials to re-validate — the + grant itself has to be re-issued, so "reconnect" re-initiates the + existing connect flow (`/api//connect`); a successful + callback clears the park. Markup matches the WHOOP card byte for + byte. */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} +

{t(`${i18nPrefix}Help`)}

@@ -154,40 +192,57 @@ export function OAuthProviderCard({ )} {status?.connected ? ( -
- - - - - - - - {t(`${i18nPrefix}DisconnectTitle`)} - - - {t(`${i18nPrefix}DisconnectDescription`)} - - - - {t("common.cancel")} - disconnect.mutate()} + <> +
+ + + +
+ + + + + + {t(`${i18nPrefix}DisconnectTitle`)} + + + {t(`${i18nPrefix}DisconnectDescription`)} + + + + {t("common.cancel")} + disconnect.mutate()} + > + {t(`${i18nPrefix}Disconnect`)} + + + +
+
+ {/* connect→data loop: a discreet link to where this provider's + readings now surface — doubles as the "your data is richer" + cue. */} + + {t(`${i18nPrefix}ViewData`)} + + + ) : (