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] feat(integrations): match parked, test, and data-link treatment on new cards --- messages/de.json | 3 + messages/en.json | 3 + messages/es.json | 3 + messages/fr.json | 3 + messages/it.json | 3 + messages/pl.json | 3 + .../__tests__/nightscout-card.test.tsx | 85 ++++++++++ .../__tests__/oauth-provider-card.test.tsx | 88 +++++++++++ .../settings/integrations/nightscout-card.tsx | 145 +++++++++++++----- .../integrations/oauth-provider-card.tsx | 123 +++++++++++---- .../settings/integrations/oura-card.tsx | 4 + .../settings/integrations/polar-card.tsx | 4 + 12 files changed, 396 insertions(+), 71 deletions(-) create mode 100644 src/components/settings/integrations/__tests__/nightscout-card.test.tsx create mode 100644 src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx diff --git a/messages/de.json b/messages/de.json index da188c5c9..0e16cf538 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Verbinde deine eigene Nightscout-Seite, um kontinuierliche Glukosewerte für das Glukose-Panel zu synchronisieren.", + "nightscoutViewData": "Glukose ansehen", "nightscoutHelp": "Richte HealthLog auf deine Nightscout-Instanz aus. Werte werden stündlich im Hintergrund abgerufen.", "nightscoutUrl": "Nightscout-URL", "nightscoutUrlSavedPlaceholder": "Gespeichert — neue URL eingeben zum Ersetzen", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Künftige Synchronisierungen stoppen. Bereits importierte Glukosewerte bleiben in deinem Verlauf.", "polar": "Polar", "polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.", + "polarViewData": "Schlaf & Erholung ansehen", "polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", "polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", "polarConnect": "Polar verbinden", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.", + "ouraViewData": "Schlaf & Erholung ansehen", "ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.", "ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.", "ouraConnect": "Oura verbinden", diff --git a/messages/en.json b/messages/en.json index 162ac5504..68afa4acc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connect your own Nightscout site to sync continuous glucose readings for the glucose panel.", + "nightscoutViewData": "View your glucose", "nightscoutHelp": "Point HealthLog at your Nightscout instance. Readings are pulled in the background once an hour.", "nightscoutUrl": "Nightscout URL", "nightscoutUrlSavedPlaceholder": "Saved — enter a new URL to replace it", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Future syncs stop. Glucose readings already imported stay in your history.", "polar": "Polar", "polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.", + "polarViewData": "View your sleep & recovery", "polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.", "polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", "polarConnect": "Connect Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.", + "ouraViewData": "View your sleep & recovery", "ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.", "ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.", "ouraConnect": "Connect Oura", diff --git a/messages/es.json b/messages/es.json index ac43c3815..ac2bf14e3 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Conecta tu propio sitio Nightscout para sincronizar lecturas continuas de glucosa en el panel de glucosa.", + "nightscoutViewData": "Ver tu glucosa", "nightscoutHelp": "Apunta HealthLog a tu instancia de Nightscout. Las lecturas se obtienen en segundo plano una vez por hora.", "nightscoutUrl": "URL de Nightscout", "nightscoutUrlSavedPlaceholder": "Guardado: introduce una URL nueva para reemplazarla", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Las sincronizaciones futuras se detienen. Las lecturas de glucosa ya importadas permanecen en tu historial.", "polar": "Polar", "polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.", + "polarViewData": "Ver tu sueño y recuperación", "polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", "polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", "polarConnect": "Conectar Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.", + "ouraViewData": "Ver tu sueño y recuperación", "ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.", "ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.", "ouraConnect": "Conectar Oura", diff --git a/messages/fr.json b/messages/fr.json index 1f8eaa98e..182ec080b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Connectez votre propre site Nightscout pour synchroniser les mesures de glycémie en continu dans le panneau glucose.", + "nightscoutViewData": "Voir votre glycémie", "nightscoutHelp": "Pointez HealthLog vers votre instance Nightscout. Les mesures sont récupérées en arrière-plan une fois par heure.", "nightscoutUrl": "URL Nightscout", "nightscoutUrlSavedPlaceholder": "Enregistré — saisissez une nouvelle URL pour la remplacer", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Les synchronisations futures s'arrêtent. Les mesures de glycémie déjà importées restent dans votre historique.", "polar": "Polar", "polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.", + "polarViewData": "Voir votre sommeil et récupération", "polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", "polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", "polarConnect": "Connecter Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.", + "ouraViewData": "Voir votre sommeil et récupération", "ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.", "ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.", "ouraConnect": "Connecter Oura", diff --git a/messages/it.json b/messages/it.json index bc5a81c1c..4d833acfb 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Collega il tuo sito Nightscout per sincronizzare le letture continue della glicemia nel pannello glicemia.", + "nightscoutViewData": "Vedi la tua glicemia", "nightscoutHelp": "Punta HealthLog alla tua istanza Nightscout. Le letture vengono recuperate in background una volta all'ora.", "nightscoutUrl": "URL Nightscout", "nightscoutUrlSavedPlaceholder": "Salvato — inserisci un nuovo URL per sostituirlo", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Le sincronizzazioni future si interrompono. Le letture della glicemia già importate restano nella tua cronologia.", "polar": "Polar", "polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.", + "polarViewData": "Vedi sonno e recupero", "polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", "polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", "polarConnect": "Collega Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.", + "ouraViewData": "Vedi sonno e recupero", "ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.", "ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.", "ouraConnect": "Collega Oura", diff --git a/messages/pl.json b/messages/pl.json index e66363b37..13147adea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4570,6 +4570,7 @@ "nightscout": "Nightscout", "nightscoutTag": "CGM", "nightscoutDescription": "Połącz własną witrynę Nightscout, aby synchronizować ciągłe odczyty glukozy w panelu glukozy.", + "nightscoutViewData": "Zobacz swoją glukozę", "nightscoutHelp": "Wskaż HealthLog na swoją instancję Nightscout. Odczyty są pobierane w tle raz na godzinę.", "nightscoutUrl": "Adres URL Nightscout", "nightscoutUrlSavedPlaceholder": "Zapisano — wpisz nowy adres URL, aby go zastąpić", @@ -4588,6 +4589,7 @@ "nightscoutDisconnectDescription": "Przyszłe synchronizacje zostaną zatrzymane. Już zaimportowane odczyty glukozy pozostaną w historii.", "polar": "Polar", "polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.", + "polarViewData": "Zobacz sen i regenerację", "polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", "polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", "polarConnect": "Połącz Polar", @@ -4608,6 +4610,7 @@ }, "oura": "Oura", "ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.", + "ouraViewData": "Zobacz sen i regenerację", "ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.", "ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.", "ouraConnect": "Połącz Oura", diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx new file mode 100644 index 000000000..6b5a6b0ac --- /dev/null +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -0,0 +1,85 @@ +/** + * v1.17.0 — interaction-parity + correctness checks for the Nightscout card. + * + * 1. A `parked` status renders the warning banner + reconnect button + * (matching the WHOOP card's parked treatment, byte-for-byte classes). + * 2. A `connected` status renders the shared TestConnectionButton AND the + * connect→data link to /insights/blood-glucose. + * 3. The disconnect mutation invalidates `nightscoutStatus()` — the key the + * status query actually reads — so the pill refreshes after disconnect. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { queryKeys } from "@/lib/query-keys"; + +// Capture the disconnect mutation's onSuccess so the test can assert which +// query keys it invalidates. Status is driven off a per-test payload. +let statusPayload: unknown = null; +const invalidateSpy = vi.fn(); +type OnSuccess = () => void; +const capturedDisconnectOnSuccess: { fn: OnSuccess | null } = { fn: null }; + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: statusPayload, isLoading: false }), + useMutation: ({ onSuccess }: { onSuccess?: () => void }) => { + // The Nightscout card declares exactly one mutation (disconnect). + capturedDisconnectOnSuccess.fn = onSuccess ?? null; + return { mutate: vi.fn(), isPending: false }; + }, + useQueryClient: () => ({ invalidateQueries: invalidateSpy }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { NightscoutCard } from "../nightscout-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("NightscoutCard — parked + test + data-link + invalidation", () => { + it("renders the parked banner + reconnect button when state is parked", () => { + statusPayload = { + connected: true, + configured: true, + state: "parked", + lastSuccessAt: null, + lastError: "Nightscout unreachable", + }; + const html = render(); + expect(html).toContain('data-state="parked"'); + expect(html).toContain('data-testid="nightscout-parked-banner"'); + expect(html).toContain('data-testid="nightscout-resume-button"'); + expect(html).toContain("border-warning/30 bg-warning/10"); + }); + + it("renders the test-connection button + data link when connected", () => { + statusPayload = { + connected: true, + configured: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + }; + const html = render(); + expect(html).toContain("Test connection"); + expect(html).toContain('data-testid="nightscout-data-link"'); + expect(html).toContain('href="/insights/blood-glucose"'); + }); + + it("disconnect invalidates the status key the query reads (nightscoutStatus)", () => { + statusPayload = { connected: true, configured: true, state: "connected" }; + invalidateSpy.mockClear(); + capturedDisconnectOnSuccess.fn = null; + render(); + const onSuccess = capturedDisconnectOnSuccess.fn as (() => void) | null; + expect(typeof onSuccess).toBe("function"); + onSuccess?.(); + const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey); + expect(invalidatedKeys).toContainEqual(queryKeys.nightscoutStatus()); + }); +}); diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx new file mode 100644 index 000000000..1e6b801dd --- /dev/null +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -0,0 +1,88 @@ +/** + * v1.17.0 — interaction-parity check for the shared OAuth integration card + * (Polar / Oura). The new cards must match the existing WHOOP card's + * parked-state + test-connection + connect→data treatment: + * + * 1. A `parked` status renders the warning banner + reconnect button. + * 2. A `connected` status renders the shared TestConnectionButton (its + * "Test connection" affordance) AND the connect→data link. + * 3. The data link resolves to the provider's insight surface. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { Watch } from "lucide-react"; + +// Drive the card's own `useQuery` status read off a per-test payload. +let statusPayload: unknown = null; +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: statusPayload, isLoading: false }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { OAuthProviderCard } from "../oauth-provider-card"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("OAuthProviderCard — parked + test + data-link parity", () => { + it("renders the parked banner + reconnect button when state is parked", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + state: "parked", + lastSuccessAt: null, + lastError: "Polar grant expired", + }; + const html = render(); + expect(html).toContain('data-state="parked"'); + expect(html).toContain('data-testid="polar-parked-banner"'); + expect(html).toContain('data-testid="polar-resume-button"'); + expect(html).toContain("reconnect manually"); + // The parked banner uses the same warning treatment as the WHOOP card. + expect(html).toContain("border-warning/30 bg-warning/10"); + }); + + it("renders the test-connection button + data link when connected", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + state: "connected", + lastSuccessAt: "2026-06-01T00:00:00.000Z", + lastError: null, + }; + const html = render(); + // The shared TestConnectionButton surfaces its "Test connection" label. + expect(html).toContain("Test connection"); + // connect→data link points at the provider's insight surface. + expect(html).toContain('data-testid="polar-data-link"'); + expect(html).toContain('href="/insights/sleep"'); + }); + + it("does not render the data link or test button when disconnected", () => { + statusPayload = { + connected: false, + configured: false, + available: true, + }; + const html = render(); + expect(html).not.toContain('data-testid="polar-data-link"'); + expect(html).not.toContain("Test connection"); + // The connect CTA stands in instead. + expect(html).toContain('data-testid="polar-connect"'); + }); +}); diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index c42bbfc91..97edbc3e7 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -7,9 +7,10 @@ // consolidated envelope. Warm copy, mobile-first, the private-network opt-in // toggle maps to `nightscoutAllowPrivateHost`. -import { useState } from "react"; +import { useRef, useState } from "react"; +import Link from "next/link"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Activity, Link2, Loader2, Save, Unlink } from "lucide-react"; +import { ArrowRight, Droplet, Link2, Loader2, Save, Unlink } from "lucide-react"; import { AlertDialog, @@ -32,6 +33,7 @@ import { IntegrationStatusPill, type IntegrationPillState, } from "@/components/settings/integration-status-pill"; +import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys"; @@ -82,6 +84,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { refetchOnWindowFocus: true, }); + const formRef = useRef(null); + const disconnect = useMutation({ mutationFn: async () => { await apiPost("/api/nightscout/disconnect"); @@ -90,7 +94,15 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { setUrl(""); setToken(""); setAllowPrivateHost(false); - queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + // The status read keys on `nightscoutStatus()`; invalidate it so the + // pill flips back after a disconnect (matching the Withings / WHOOP + // pattern), alongside the cross-integration envelope. + queryClient.invalidateQueries({ + queryKey: queryKeys.nightscoutStatus(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); }, }); @@ -114,7 +126,12 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { setMsgType("success"); setToken(""); void invalidateKeys(queryClient, measurementDependentKeys); - queryClient.invalidateQueries({ queryKey: queryKeys.nightscout() }); + queryClient.invalidateQueries({ + queryKey: queryKeys.nightscoutStatus(), + }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); } else { let detail = t("settings.nightscoutConnectFailed"); try { @@ -145,7 +162,7 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) { className="bg-card border-border rounded-xl border p-4 sm:p-6" > @@ -166,11 +183,46 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
{errorMessage && } + {/* Parked-integration resume CTA. Surfaces only when the row state + is `parked` (>24h of persistent failures). Nightscout has no OAuth + redirect — the user re-validates by re-submitting the connect form, + so "reconnect" scrolls the form into view + focuses the URL field. + Markup matches the WHOOP card byte for byte. */} + {pillState === "parked" && ( +
+ + {t("settings.integrationPill.parkedReconnect")} + + +
+ )} +

{t("settings.nightscoutHelp")}

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

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

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

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

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