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" && (
+
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" && (
+