From 001c158203fb6b322096752cf58fa1d0948b390d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Mon, 15 Jun 2026 00:59:33 +0200
Subject: [PATCH] feat(integrations): add a Setup-guide doc-link to every
integration card
Each integration card now carries the same discreet "Setup guide" link to
its runbook under docs.healthlog.dev/integrations/, so a user who
is mid-setup always knows where to go. The docs host lives in one shared
constant and the affordance renders identically across all six providers
(WHOOP, Withings, Fitbit, Polar, Oura, Nightscout).
---
messages/de.json | 33 ++++++++++-
messages/en.json | 33 ++++++++++-
messages/es.json | 33 ++++++++++-
messages/fr.json | 33 ++++++++++-
messages/it.json | 33 ++++++++++-
messages/pl.json | 33 ++++++++++-
.../__tests__/integrations-section.test.tsx | 39 +++++++++++++
.../settings/integrations/fitbit-card.tsx | 3 +
.../settings/integrations/nightscout-card.tsx | 3 +
.../integrations/oauth-provider-card.tsx | 3 +
.../integrations/setup-guide-link.tsx | 57 +++++++++++++++++++
.../settings/integrations/whoop-card.tsx | 3 +
.../settings/integrations/withings-card.tsx | 3 +
13 files changed, 303 insertions(+), 6 deletions(-)
create mode 100644 src/components/settings/integrations/setup-guide-link.tsx
diff --git a/messages/de.json b/messages/de.json
index bb1026aa7..3acdc22e7 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
+ },
+ "reminders": {
+ "title": "Erinnerungen & Benachrichtigungen",
+ "description": "Ein Ort für jede Erinnerung — woran und wann erinnert wird — und die Kanäle, die sie zustellen.",
+ "categoriesGroup": "Erinnerungen",
+ "channelsGroup": "Zustellkanäle",
+ "medication": {
+ "title": "Medikamenten-Erinnerungen",
+ "description": "Einnahmezeiten und Zeitfenster, je Medikament eingestellt."
+ },
+ "vorsorge": {
+ "title": "Vorsorge",
+ "description": "Wiederkehrende Untersuchungen und Messungen, inklusive Ort."
+ },
+ "mood": {
+ "title": "Stimmungs-Check-in",
+ "description": "Eine tägliche Erinnerung, dein Befinden zu erfassen."
+ },
+ "lowStock": {
+ "title": "Reichweite bei Restbestand",
+ "description": "Werde gewarnt, bevor ein Medikament zur Neige geht."
+ },
+ "coach": {
+ "title": "Coach-Hinweis",
+ "description": "Proaktive Impulse vom KI-Coach."
+ },
+ "channels": {
+ "title": "Benachrichtigungskanäle",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook und E-Mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
- }
+ },
+ "integrationSetupGuide": "Einrichtungsanleitung"
},
"admin": {
"title": "Administration",
diff --git a/messages/en.json b/messages/en.json
index 63210fddf..8339967fc 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
+ },
+ "reminders": {
+ "title": "Reminders & Notifications",
+ "description": "One home for every reminder — what gets reminded and when — plus the channels that deliver them.",
+ "categoriesGroup": "Reminders",
+ "channelsGroup": "Delivery channels",
+ "medication": {
+ "title": "Medication reminders",
+ "description": "Dose times and windows, configured on each medication."
+ },
+ "vorsorge": {
+ "title": "Preventive care",
+ "description": "Recurring check-ups and measurements, with where to go."
+ },
+ "mood": {
+ "title": "Mood check-in",
+ "description": "A daily nudge to log how you feel."
+ },
+ "lowStock": {
+ "title": "Low-stock runway",
+ "description": "Get warned before a medication runs out."
+ },
+ "coach": {
+ "title": "Coach nudge",
+ "description": "Proactive prompts from the AI coach."
+ },
+ "channels": {
+ "title": "Notification channels",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook, and Email."
+ }
}
},
"profile": "Profile",
@@ -4773,7 +4803,8 @@
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"generic": "Unknown error while connecting. Please start the connection again."
- }
+ },
+ "integrationSetupGuide": "Setup guide"
},
"admin": {
"title": "Administration",
diff --git a/messages/es.json b/messages/es.json
index 2da94f8f0..f5c8d5fcb 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
+ },
+ "reminders": {
+ "title": "Recordatorios y notificaciones",
+ "description": "Un único lugar para cada recordatorio — qué se recuerda y cuándo — y los canales que lo entregan.",
+ "categoriesGroup": "Recordatorios",
+ "channelsGroup": "Canales de entrega",
+ "medication": {
+ "title": "Recordatorios de medicación",
+ "description": "Horas y ventanas de dosis, configuradas en cada medicamento."
+ },
+ "vorsorge": {
+ "title": "Cuidado preventivo",
+ "description": "Revisiones y mediciones periódicas, con el lugar al que acudir."
+ },
+ "mood": {
+ "title": "Registro de ánimo",
+ "description": "Un aviso diario para registrar cómo te sientes."
+ },
+ "lowStock": {
+ "title": "Margen por bajo stock",
+ "description": "Recibe un aviso antes de que se acabe un medicamento."
+ },
+ "coach": {
+ "title": "Sugerencia del coach",
+ "description": "Indicaciones proactivas del coach de IA."
+ },
+ "channels": {
+ "title": "Canales de notificación",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook y correo."
+ }
}
},
"profile": "Perfil",
@@ -4773,7 +4803,8 @@
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
- }
+ },
+ "integrationSetupGuide": "Guía de configuración"
},
"admin": {
"title": "Administration",
diff --git a/messages/fr.json b/messages/fr.json
index 21461fbc4..7506b034d 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
+ },
+ "reminders": {
+ "title": "Rappels et notifications",
+ "description": "Un seul endroit pour chaque rappel — quoi et quand — ainsi que les canaux qui les délivrent.",
+ "categoriesGroup": "Rappels",
+ "channelsGroup": "Canaux de diffusion",
+ "medication": {
+ "title": "Rappels de médication",
+ "description": "Heures et fenêtres de prise, configurées sur chaque médicament."
+ },
+ "vorsorge": {
+ "title": "Prévention",
+ "description": "Bilans et mesures récurrents, avec le lieu où se rendre."
+ },
+ "mood": {
+ "title": "Suivi de l'humeur",
+ "description": "Un rappel quotidien pour noter votre ressenti."
+ },
+ "lowStock": {
+ "title": "Marge de stock faible",
+ "description": "Soyez averti avant qu'un médicament ne s'épuise."
+ },
+ "coach": {
+ "title": "Suggestion du coach",
+ "description": "Invitations proactives du coach IA."
+ },
+ "channels": {
+ "title": "Canaux de notification",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook et e-mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
- }
+ },
+ "integrationSetupGuide": "Guide de configuration"
},
"admin": {
"title": "Administration",
diff --git a/messages/it.json b/messages/it.json
index 265885255..0719bf886 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
+ },
+ "reminders": {
+ "title": "Promemoria e notifiche",
+ "description": "Un'unica sede per ogni promemoria — cosa ricordare e quando — e i canali che li recapitano.",
+ "categoriesGroup": "Promemoria",
+ "channelsGroup": "Canali di consegna",
+ "medication": {
+ "title": "Promemoria dei farmaci",
+ "description": "Orari e finestre delle dosi, impostati su ogni farmaco."
+ },
+ "vorsorge": {
+ "title": "Prevenzione",
+ "description": "Controlli e misurazioni ricorrenti, con il luogo dove recarsi."
+ },
+ "mood": {
+ "title": "Check-in dell'umore",
+ "description": "Un promemoria quotidiano per registrare come ti senti."
+ },
+ "lowStock": {
+ "title": "Autonomia scorte",
+ "description": "Ricevi un avviso prima che un farmaco finisca."
+ },
+ "coach": {
+ "title": "Suggerimento del coach",
+ "description": "Stimoli proattivi dal coach IA."
+ },
+ "channels": {
+ "title": "Canali di notifica",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook ed e-mail."
+ }
}
},
"profile": "Profilo",
@@ -4773,7 +4803,8 @@
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
- }
+ },
+ "integrationSetupGuide": "Guida alla configurazione"
},
"admin": {
"title": "Administration",
diff --git a/messages/pl.json b/messages/pl.json
index a969c66f2..ae04caf95 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -4087,6 +4087,36 @@
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
+ },
+ "reminders": {
+ "title": "Przypomnienia i powiadomienia",
+ "description": "Jedno miejsce dla każdego przypomnienia — o czym i kiedy — oraz kanały, które je dostarczają.",
+ "categoriesGroup": "Przypomnienia",
+ "channelsGroup": "Kanały dostarczania",
+ "medication": {
+ "title": "Przypomnienia o lekach",
+ "description": "Godziny i okna dawek, ustawiane przy każdym leku."
+ },
+ "vorsorge": {
+ "title": "Profilaktyka",
+ "description": "Cykliczne badania i pomiary wraz z miejscem, gdzie się udać."
+ },
+ "mood": {
+ "title": "Zapis nastroju",
+ "description": "Codzienne przypomnienie, by zapisać samopoczucie."
+ },
+ "lowStock": {
+ "title": "Zapas na wyczerpaniu",
+ "description": "Otrzymaj ostrzeżenie, zanim lek się skończy."
+ },
+ "coach": {
+ "title": "Podpowiedź trenera",
+ "description": "Proaktywne wskazówki od trenera AI."
+ },
+ "channels": {
+ "title": "Kanały powiadomień",
+ "description": "APNs, Telegram, ntfy, Web Push, Webhook i e-mail."
+ }
}
},
"profile": "Profil",
@@ -4773,7 +4803,8 @@
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
- }
+ },
+ "integrationSetupGuide": "Przewodnik konfiguracji"
},
"admin": {
"title": "Administracja",
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
index 8381da09e..69b49d1fb 100644
--- a/src/components/settings/__tests__/integrations-section.test.tsx
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -303,4 +303,43 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
// Fitbit / Google Health). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-card-divider"')).toBe(3);
});
+
+ // v1.17.1 — every integration card carries the same discreet "Setup guide"
+ // doc-link pointing at its runbook under the single shared docs base. The
+ // affordance is one family across all six providers.
+ it("every integration card carries the shared Setup-guide doc-link", () => {
+ setIntegrationStatus({
+ threshold: 3,
+ integrations: [
+ {
+ integration: "withings",
+ state: "connected",
+ lastSuccessAt: "2026-05-09T18:00:00.000Z",
+ lastAttemptAt: "2026-05-09T18:00:00.000Z",
+ lastError: null,
+ connected: true,
+ configured: true,
+ legacyLastSyncedAt: "2026-05-09T18:00:00.000Z",
+ hasActivityScope: true,
+ },
+ ],
+ });
+
+ const html = render();
+ // One setup-guide link per card → six providers.
+ expect(count(html, 'data-slot="integration-setup-guide"')).toBe(6);
+ for (const provider of [
+ "withings",
+ "whoop",
+ "fitbit",
+ "polar",
+ "oura",
+ "nightscout",
+ ]) {
+ expect(html).toContain(`data-testid="${provider}-setup-guide"`);
+ expect(html).toContain(
+ `href="https://docs.healthlog.dev/integrations/${provider}"`,
+ );
+ }
+ });
});
diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx
index 5b394c615..f2b1c3b04 100644
--- a/src/components/settings/integrations/fitbit-card.tsx
+++ b/src/components/settings/integrations/fitbit-card.tsx
@@ -51,6 +51,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function FitbitCard({
viewModel,
@@ -484,6 +485,8 @@ export function FitbitCard({
{t("settings.fitbitNoCredentials")}
)}
+
+
);
diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx
index 97edbc3e7..6dc028bb9 100644
--- a/src/components/settings/integrations/nightscout-card.tsx
+++ b/src/components/settings/integrations/nightscout-card.tsx
@@ -39,6 +39,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys, queryKeys } from "@/lib/query-keys";
import { IntegrationErrorMessage } from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
interface NightscoutStatus {
connected: boolean;
@@ -366,6 +367,8 @@ export function NightscoutCard({ enabled = true }: { enabled?: boolean }) {
>
)}
+
+
);
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index 77b2e97fd..d7642ecab 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -37,6 +37,7 @@ import { TestConnectionButton } from "@/components/settings/test-connection-butt
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export interface OAuthProviderStatus {
connected: boolean;
@@ -398,6 +399,8 @@ export function OAuthProviderCard({
{msg}
)}
+
+
);
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx
new file mode 100644
index 000000000..a6cf6a652
--- /dev/null
+++ b/src/components/settings/integrations/setup-guide-link.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * v1.17.1 — shared "Setup guide" doc-link for every Settings → Integrations
+ * card. Each card paints the same affordance: one discreet, external link to
+ * the provider's setup runbook ("was eingeben, wo klicken"). Single-sourced
+ * here so all six cards (WHOOP / Withings / Fitbit / Polar / Oura / Nightscout)
+ * read as one family and the docs host lives in exactly one place.
+ *
+ * The runbooks themselves are authored separately; this link wires the
+ * destination now so a user who is mid-setup always knows where to go.
+ */
+
+import { ExternalLink } from "lucide-react";
+
+import { useTranslations } from "@/lib/i18n/context";
+
+/**
+ * Base for every provider setup runbook. The provider key is appended as a
+ * path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
+ * a single constant so the host never drifts across cards.
+ */
+export const INTEGRATION_DOCS_BASE =
+ "https://docs.healthlog.dev/integrations";
+
+export type IntegrationDocsProvider =
+ | "whoop"
+ | "withings"
+ | "fitbit"
+ | "polar"
+ | "oura"
+ | "nightscout";
+
+export function integrationDocsHref(provider: IntegrationDocsProvider): string {
+ return `${INTEGRATION_DOCS_BASE}/${provider}`;
+}
+
+export function IntegrationSetupGuideLink({
+ provider,
+}: {
+ provider: IntegrationDocsProvider;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ {t("settings.integrationSetupGuide")}
+
+
+ );
+}
diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx
index a3acc2077..23b875f4e 100644
--- a/src/components/settings/integrations/whoop-card.tsx
+++ b/src/components/settings/integrations/whoop-card.tsx
@@ -43,6 +43,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function WhoopCard({
viewModel,
@@ -456,6 +457,8 @@ export function WhoopCard({
{t("settings.whoopNoCredentials")}
)}
+
+
);
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index d9613697a..009566d7d 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -36,6 +36,7 @@ import {
pillStateFor,
type IntegrationStatusViewModel,
} from "./shared";
+import { IntegrationSetupGuideLink } from "./setup-guide-link";
export function WithingsCard({
viewModel,
@@ -491,6 +492,8 @@ export function WithingsCard({
{t("settings.withingsNoCredentials")}
)}
+
+
);