diff --git a/messages/de.json b/messages/de.json
index d1bf6dc03..addbde3b6 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4573,6 +4573,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",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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 61dca2fda..fdae35aad 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4573,6 +4573,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",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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 3e96aa0ad..1de831848 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4573,6 +4573,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",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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 5d8addd49..24853a912 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4573,6 +4573,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",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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 fa1f6622a..e08e5cce4 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4573,6 +4573,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",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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 29e6d5098..4e603ea0f 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4573,6 +4573,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ć",
@@ -4591,6 +4592,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",
@@ -4611,6 +4613,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/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 },
+ );
+ }
+});
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/
{t("settings.nightscoutHelp")}
{t(`${i18nPrefix}Help`)}
@@ -154,40 +192,57 @@ export function OAuthProviderCard({ )} {status?.connected ? ( -