mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(integrations): make the new cards match the existing ones and link to their data
Give the Polar, Oura and Nightscout cards the same parked-state resume banner and test-connection button the other integrations carry, distinct brand icons, a connected-state link to where their data appears, and the correct status-key invalidation so the pill refreshes after a disconnect. Fix the sleep empty-state link that pointed at a non-existent settings slug.
This commit is contained in:
commit
46f9dfa47f
17 files changed
+686
-73
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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/<slug> 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");
|
||||
});
|
||||
});
|
||||
@@ -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={
|
||||
<Button size="sm" asChild>
|
||||
<Link href="/settings/data-sources">
|
||||
<Link href="/settings/integrations">
|
||||
{t("insights.emptyState.sleep.cta")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<NightscoutCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
provider="polar"
|
||||
statusQueryKey={["polar"]}
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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"');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLFormElement | null>(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"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
icon={Droplet}
|
||||
title={t("settings.nightscout")}
|
||||
titleAccessory={
|
||||
<span className="bg-muted text-foreground rounded-full px-2 py-0.5 text-[0.6875rem] font-medium">
|
||||
@@ -166,11 +183,46 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
{errorMessage && <IntegrationErrorMessage message={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" && (
|
||||
<div
|
||||
data-testid="nightscout-parked-banner"
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
formRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
formRef.current
|
||||
?.querySelector<HTMLInputElement>("#nightscout-url")
|
||||
?.focus();
|
||||
}}
|
||||
data-testid="nightscout-resume-button"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.nightscoutHelp")}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleConnect} className="space-y-3">
|
||||
<form ref={formRef} onSubmit={handleConnect} className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="nightscout-url">{t("settings.nightscoutUrl")}</Label>
|
||||
<Input
|
||||
@@ -263,37 +315,56 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
|
||||
</form>
|
||||
|
||||
{status?.connected && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.nightscoutDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.nightscoutDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<TestConnectionButton
|
||||
endpoint="/api/nightscout/test"
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("settings.nightscoutDisconnectTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("settings.nightscoutDisconnectDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t("settings.nightscoutDisconnect")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{/* connect→data loop: a discreet link to where the glucose
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
cue. */}
|
||||
<Link
|
||||
href="/insights/blood-glucose"
|
||||
data-testid="nightscout-data-link"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.nightscoutViewData")}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<SettingsCardHeader
|
||||
icon={Activity}
|
||||
icon={icon}
|
||||
title={t(`${i18nPrefix}`)}
|
||||
description={<p>{t(`${i18nPrefix}Description`)}</p>}
|
||||
status={
|
||||
@@ -140,6 +148,36 @@ export function OAuthProviderCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 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/<provider>/connect`); a successful
|
||||
callback clears the park. Markup matches the WHOOP card byte for
|
||||
byte. */}
|
||||
{pillState === "parked" && (
|
||||
<div
|
||||
data-testid={`${provider}-parked-banner`}
|
||||
className="border-warning/30 bg-warning/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="text-warning min-w-0 text-xs break-words">
|
||||
{t("settings.integrationPill.parkedReconnect")}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
data-testid={`${provider}-resume-button`}
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrationPill.resumeCta")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`${i18nPrefix}Help`)}
|
||||
</p>
|
||||
@@ -154,40 +192,57 @@ export function OAuthProviderCard({
|
||||
)}
|
||||
|
||||
{status?.connected ? (
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
data-testid={`${provider}-disconnect`}
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(`${i18nPrefix}DisconnectTitle`)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(`${i18nPrefix}DisconnectDescription`)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
<>
|
||||
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
|
||||
<TestConnectionButton
|
||||
endpoint={`/api/${provider}/test`}
|
||||
disabled={!status?.connected}
|
||||
/>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive min-h-11"
|
||||
data-testid={`${provider}-disconnect`}
|
||||
>
|
||||
<Unlink className="h-3.5 w-3.5" />
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t(`${i18nPrefix}DisconnectTitle`)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(`${i18nPrefix}DisconnectDescription`)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
onClick={() => disconnect.mutate()}
|
||||
>
|
||||
{t(`${i18nPrefix}Disconnect`)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
{/* connect→data loop: a discreet link to where this provider's
|
||||
readings now surface — doubles as the "your data is richer"
|
||||
cue. */}
|
||||
<Link
|
||||
href={dataHref}
|
||||
data-testid={`${provider}-data-link`}
|
||||
className="text-primary inline-flex items-center gap-1 text-xs underline-offset-2 hover:underline"
|
||||
>
|
||||
{t(`${i18nPrefix}ViewData`)}
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
|
||||
// env-based OAuth card.
|
||||
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -12,6 +14,8 @@ export function OuraCard({ enabled = true }: { enabled?: boolean }) {
|
||||
provider="oura"
|
||||
statusQueryKey={queryKeys.oura()}
|
||||
i18nPrefix="settings.oura"
|
||||
icon={CircleDot}
|
||||
dataHref="/insights/sleep"
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
|
||||
// shared env-based OAuth card.
|
||||
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -12,6 +14,8 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
|
||||
provider="polar"
|
||||
statusQueryKey={queryKeys.polar()}
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user