refactor(settings): move notification channels into Integrationen

Channels (Telegram, ntfy, Web Push, Webhook, Email) and the live
per-channel status surface are delivery providers, not notification
preferences, so they now sit under Integrations next to the connected
services. Integrations gains a Connections / Channels sub-tab split;
Notifications keeps the reminder-type content (mood, low-stock, coach
nudge) and cross-links to the relocated channels.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 22:54:19 +02:00
1 parent 4a1971c0dd
commit d3d8731209
10 files changed
+513 -319

No files matched your search

+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Integrationen",
"description": "Withings, moodLog und weitere verbundene Dienste."
"description": "Withings, moodLog und weitere verbundene Dienste.",
"tabs": {
"connections": "Verbindungen",
"channels": "Kanäle",
"sources": "Quellen"
},
"connections": {
"description": "Withings, WHOOP, Fitbit, Polar, Oura und Nightscout verbinden."
},
"channels": {
"description": "Telegram, ntfy, Web-Push, Webhook und E-Mail – die Wege, über die Benachrichtigungen zugestellt werden."
}
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Live-Übersicht aller konfigurierten Kanäle."
"description": "Welche Erinnerungen ausgelöst werden. Die Zustellkanäle liegen unter Integrationen.",
"channelsHint": "Telegram, ntfy, Web-Push, Webhook und E-Mail richtest du jetzt unter",
"channelsHintLink": "Integrationen → Kanäle"
},
"layout": {
"title": "Layout & Personalisierung",
+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Integrations",
"description": "Withings, moodLog, and other connected services."
"description": "Withings, moodLog, and other connected services.",
"tabs": {
"connections": "Connections",
"channels": "Channels",
"sources": "Sources"
},
"connections": {
"description": "Connect Withings, WHOOP, Fitbit, Polar, Oura, and Nightscout."
},
"channels": {
"description": "Telegram, ntfy, Web Push, Webhook, and Email — the routes notifications are delivered through."
}
},
"notifications": {
"title": "Notifications",
"description": "Live overview of every configured channel."
"description": "Which reminders fire. The delivery channels live under Integrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook, and Email are now set up under",
"channelsHintLink": "Integrations → Channels"
},
"layout": {
"title": "Layout & Personalization",
+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Integraciones",
"description": "Withings, moodLog y otros servicios conectados."
"description": "Withings, moodLog y otros servicios conectados.",
"tabs": {
"connections": "Conexiones",
"channels": "Canales",
"sources": "Fuentes"
},
"connections": {
"description": "Conecta Withings, WHOOP, Fitbit, Polar, Oura y Nightscout."
},
"channels": {
"description": "Telegram, ntfy, Web Push, Webhook y correo: las vías por las que se entregan las notificaciones."
}
},
"notifications": {
"title": "Notificaciones",
"description": "Vista en vivo de cada canal configurado."
"description": "Qué recordatorios se activan. Los canales de entrega están en Integraciones.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook y correo se configuran ahora en",
"channelsHintLink": "Integraciones → Canales"
},
"layout": {
"title": "Diseño y personalización",
+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Intégrations",
"description": "Withings, moodLog et autres services connectés."
"description": "Withings, moodLog et autres services connectés.",
"tabs": {
"connections": "Connexions",
"channels": "Canaux",
"sources": "Sources"
},
"connections": {
"description": "Connecter Withings, WHOOP, Fitbit, Polar, Oura et Nightscout."
},
"channels": {
"description": "Telegram, ntfy, Web Push, Webhook et e-mail : les voies par lesquelles les notifications sont envoyées."
}
},
"notifications": {
"title": "Notifications",
"description": "Aperçu en direct de chaque canal configuré."
"description": "Quels rappels se déclenchent. Les canaux de diffusion se trouvent dans Intégrations.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook et e-mail se configurent désormais dans",
"channelsHintLink": "Intégrations → Canaux"
},
"layout": {
"title": "Disposition et personnalisation",
+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Integrazioni",
"description": "Withings, moodLog e altri servizi collegati."
"description": "Withings, moodLog e altri servizi collegati.",
"tabs": {
"connections": "Connessioni",
"channels": "Canali",
"sources": "Origini"
},
"connections": {
"description": "Collega Withings, WHOOP, Fitbit, Polar, Oura e Nightscout."
},
"channels": {
"description": "Telegram, ntfy, Web Push, Webhook ed e-mail: le vie attraverso cui vengono recapitate le notifiche."
}
},
"notifications": {
"title": "Notifiche",
"description": "Panoramica in tempo reale di ogni canale configurato."
"description": "Quali promemoria vengono attivati. I canali di recapito si trovano in Integrazioni.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook ed e-mail ora si configurano in",
"channelsHintLink": "Integrazioni → Canali"
},
"layout": {
"title": "Layout e personalizzazione",
+15 -2
View File
@@ -3856,11 +3856,24 @@
},
"integrations": {
"title": "Integracje",
"description": "Withings, moodLog i inne połączone usługi."
"description": "Withings, moodLog i inne połączone usługi.",
"tabs": {
"connections": "Połączenia",
"channels": "Kanały",
"sources": "Źródła"
},
"connections": {
"description": "Połącz Withings, WHOOP, Fitbit, Polar, Oura i Nightscout."
},
"channels": {
"description": "Telegram, ntfy, Web Push, Webhook i e-mail — drogi dostarczania powiadomień."
}
},
"notifications": {
"title": "Powiadomienia",
"description": "Podgląd na żywo każdego skonfigurowanego kanału."
"description": "Które przypomnienia są uruchamiane. Kanały dostarczania znajdują się w Integracjach.",
"channelsHint": "Telegram, ntfy, Web Push, Webhook i e-mail konfigurujesz teraz w",
"channelsHintLink": "Integracje → Kanały"
},
"layout": {
"title": "Układ i personalizacja",
+46 -242
View File
@@ -1,234 +1,38 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
/**
* `<IntegrationsSection>` — Settings → Integrations.
*
* v1.18.0 (S3) — Integrations is now the one home for every delivery / ingest
* connection. Three sub-tabs under one nav entry:
* 1. Connections — Withings / WHOOP / Fitbit / Polar / Oura / Nightscout
* (the OAuth + device data sources). See `connections-panel.tsx`.
* 2. Channels — Telegram / ntfy / Web Push / Webhook / Email plus the live
* per-channel status surface (moved here from Settings → Notifications,
* where it never belonged: channels are delivery providers, not
* reminder preferences). See `notification-channels-panel.tsx`.
*
* `parseOAuthOutcome` / `oauthReasonKey` are re-exported from
* `connections-panel.tsx` so existing unit-test imports keep resolving.
*/
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { useAuth } from "@/hooks/use-auth";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The Withings OAuth callback (`/api/withings/callback`) redirects back
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
* Map every reason tag the callback emits onto a human-readable i18n
* key (what went wrong + what to do next). Unknown tags fall back to
* the generic entry so a future callback branch never strands the user
* with silent params.
*/
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
csrf1: "settings.withingsOauthError.csrf1",
replay: "settings.withingsOauthError.replay",
state: "settings.withingsOauthError.state",
expired: "settings.withingsOauthError.expired",
cross_user: "settings.withingsOauthError.cross_user",
nocode: "settings.withingsOauthError.nocode",
nocreds: "settings.withingsOauthError.nocreds",
token: "settings.withingsOauthError.token",
};
export {
parseOAuthOutcome,
oauthReasonKey,
} from "@/components/settings/integrations/connections-panel";
type WithingsOauthOutcome =
| { kind: "connected" }
| { kind: "error"; reason: string };
/**
* The OAuth providers whose callbacks redirect back to the settings page with a
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
* are invalidated on a successful return so the card repaints either way.
*/
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
const OAUTH_OUTCOME_KEYS: Record<
OAuthOutcomeProvider,
() => readonly unknown[]
> = {
polar: queryKeys.polar,
oura: queryKeys.oura,
whoop: queryKeys.whoop,
fitbit: queryKeys.fitbit,
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
*/
const OAUTH_OUTCOME_REASONS = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
"expired",
]);
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
/**
* Parse the OAuth-return outcome from a URL query string. Reads the first
* provider whose `?<provider>=connected|error` param is present, in
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
* coverage is unit-testable without a browser.
*/
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
const params = new URLSearchParams(search);
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
}
}
return null;
}
/**
* Resolve the i18n key for an error reason tag. Known tags map to the
* provider-specific message; anything else falls back to `generic`.
*/
export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
return OAUTH_OUTCOME_REASONS.has(reason)
? `settings.${provider}OauthError.${reason}`
: `settings.${provider}OauthError.generic`;
}
/**
* Adapt the consolidated-envelope view-model into the OAuth card's status
* shape. Returns `undefined` when the envelope hasn't loaded so the card
* renders its loading/disconnected default rather than a half-populated state.
*/
function toOAuthStatus(
vm: IntegrationStatusViewModel | undefined,
): OAuthProviderStatus | undefined {
if (!vm) return undefined;
return {
connected: vm.connected ?? false,
configured: vm.configured ?? false,
available: vm.available ?? false,
hasOwnCredentials: vm.hasOwnCredentials,
state: vm.state,
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
};
}
type IntegrationsTab = "connections" | "channels";
export function IntegrationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const router = useRouter();
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
// callback set these params and nothing ever read them: a user came
// back from Withings onto a silently unchanged settings page.
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const status = params.get("withings");
if (status === "connected") return { kind: "connected" };
if (status === "error") {
return { kind: "error", reason: params.get("reason") ?? "unknown" };
}
return null;
});
useEffect(() => {
if (!withingsOauthOutcome) return;
// Scrub the one-shot params so a reload / bookmark doesn't replay
// the toast.
const url = new URL(window.location.href);
url.searchParams.delete("withings");
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (withingsOauthOutcome.kind === "connected") {
toast.success(t("settings.withingsOauthConnected"));
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
const reasonKey =
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
"settings.withingsOauthError.generic";
toast.error(t("settings.withingsOauthFailed"), {
description: t(reasonKey),
duration: 10_000,
});
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
// the same gap the Withings handler was written to close.
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
if (typeof window === "undefined") return null;
return parseOAuthOutcome(window.location.search);
});
useEffect(() => {
if (!oauthOutcome) return;
const { provider } = oauthOutcome;
const url = new URL(window.location.href);
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
// consolidated envelope — invalidate both so the card repaints either way.
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
duration: 10_000,
});
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
const [tab, setTab] = useState<IntegrationsTab>("connections");
return (
<section
@@ -242,28 +46,28 @@ export function IntegrationsSection() {
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.description")}
</p>
{/* Cross-link to Settings → Sources: when two integrations (or
an integration + manual entry) report the same metric, the
source-priority ladder decides which value counts — a fact
newcomers otherwise discover only after a confusing chart. */}
<p className="text-muted-foreground text-xs">
{t("settings.integrationsSourcesHint")}{" "}
<Link
href="/settings/sources"
className="text-primary underline underline-offset-2"
data-slot="integrations-sources-cross-link"
>
{t("settings.integrationsSourcesHintLink")}
</Link>
</p>
</header>
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<NightscoutCard enabled={isAuthenticated} />
<Tabs
value={tab}
onValueChange={(value) => setTab(value as IntegrationsTab)}
>
<TabsList className="w-full sm:w-fit">
<TabsTrigger value="connections">
{t("settings.sections.integrations.tabs.connections")}
</TabsTrigger>
<TabsTrigger value="channels">
{t("settings.sections.integrations.tabs.channels")}
</TabsTrigger>
</TabsList>
<TabsContent value="connections">
<ConnectionsPanel />
</TabsContent>
<TabsContent value="channels">
<NotificationChannelsPanel />
</TabsContent>
</Tabs>
</section>
);
}
@@ -0,0 +1,257 @@
"use client";
/**
* `<ConnectionsPanel>` — the connected-services half of Settings →
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
*
* v1.18.0 (S3) — extracted out of `integrations-section.tsx` so the section
* file can host the Connections / Channels / Sources sub-tabs without growing
* unbounded. This panel owns the OAuth-callback toast handlers (the four
* providers + Withings redirect back here with `?<provider>=…` params) and the
* card grid. It renders no page header of its own; the parent supplies it.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
import { OuraCard } from "@/components/settings/integrations/oura-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationStatusViewModel,
} from "@/components/settings/integrations/shared";
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The Withings OAuth callback (`/api/withings/callback`) redirects back
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
* Map every reason tag the callback emits onto a human-readable i18n
* key (what went wrong + what to do next). Unknown tags fall back to
* the generic entry so a future callback branch never strands the user
* with silent params.
*/
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
csrf1: "settings.withingsOauthError.csrf1",
replay: "settings.withingsOauthError.replay",
state: "settings.withingsOauthError.state",
expired: "settings.withingsOauthError.expired",
cross_user: "settings.withingsOauthError.cross_user",
nocode: "settings.withingsOauthError.nocode",
nocreds: "settings.withingsOauthError.nocreds",
token: "settings.withingsOauthError.token",
};
type WithingsOauthOutcome =
| { kind: "connected" }
| { kind: "error"; reason: string };
/**
* The OAuth providers whose callbacks redirect back to the settings page with a
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
* are invalidated on a successful return so the card repaints either way.
*/
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
const OAUTH_OUTCOME_KEYS: Record<
OAuthOutcomeProvider,
() => readonly unknown[]
> = {
polar: queryKeys.polar,
oura: queryKeys.oura,
whoop: queryKeys.whoop,
fitbit: queryKeys.fitbit,
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
*/
const OAUTH_OUTCOME_REASONS = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
"expired",
]);
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
/**
* Parse the OAuth-return outcome from a URL query string. Reads the first
* provider whose `?<provider>=connected|error` param is present, in
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
* coverage is unit-testable without a browser.
*/
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
const params = new URLSearchParams(search);
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
}
}
return null;
}
/**
* Resolve the i18n key for an error reason tag. Known tags map to the
* provider-specific message; anything else falls back to `generic`.
*/
export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
return OAUTH_OUTCOME_REASONS.has(reason)
? `settings.${provider}OauthError.${reason}`
: `settings.${provider}OauthError.generic`;
}
/**
* Adapt the consolidated-envelope view-model into the OAuth card's status
* shape. Returns `undefined` when the envelope hasn't loaded so the card
* renders its loading/disconnected default rather than a half-populated state.
*/
function toOAuthStatus(
vm: IntegrationStatusViewModel | undefined,
): OAuthProviderStatus | undefined {
if (!vm) return undefined;
return {
connected: vm.connected ?? false,
configured: vm.configured ?? false,
available: vm.available ?? false,
hasOwnCredentials: vm.hasOwnCredentials,
state: vm.state,
lastSuccessAt: vm.lastSuccessAt,
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
};
}
export function ConnectionsPanel() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const router = useRouter();
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
// callback set these params and nothing ever read them: a user came
// back from Withings onto a silently unchanged settings page.
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const status = params.get("withings");
if (status === "connected") return { kind: "connected" };
if (status === "error") {
return { kind: "error", reason: params.get("reason") ?? "unknown" };
}
return null;
});
useEffect(() => {
if (!withingsOauthOutcome) return;
// Scrub the one-shot params so a reload / bookmark doesn't replay
// the toast.
const url = new URL(window.location.href);
url.searchParams.delete("withings");
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (withingsOauthOutcome.kind === "connected") {
toast.success(t("settings.withingsOauthConnected"));
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
const reasonKey =
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
"settings.withingsOauthError.generic";
toast.error(t("settings.withingsOauthFailed"), {
description: t(reasonKey),
duration: 10_000,
});
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
// the same gap the Withings handler was written to close.
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
if (typeof window === "undefined") return null;
return parseOAuthOutcome(window.location.search);
});
useEffect(() => {
if (!oauthOutcome) return;
const { provider } = oauthOutcome;
const url = new URL(window.location.href);
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
// consolidated envelope — invalidate both so the card repaints either way.
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
duration: 10_000,
});
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
// no longer fire their own /api/<provider>/status round-trip.
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
return (
<div className="space-y-6" data-slot="connections-panel">
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.connections.description")}
</p>
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
<NightscoutCard enabled={isAuthenticated} />
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
/**
* `<NotificationChannelsPanel>` — the delivery-provider half of Settings →
* Integrations.
*
* v1.18.0 (S3) — the notification CHANNELS (Telegram / ntfy / Web Push /
* Webhook / Email, plus the live per-channel status surface) moved out of
* Settings → Notifications and into Integrations. Channels are delivery
* providers — the same conceptual family as Withings / WHOOP / a connected
* device — not notification preferences. The reminder-TYPE content (which
* events fire) stays under Settings → Reminders / Notifications.
*
* Renders inside the Integrations "Channels" sub-tab. Owns no page header of
* its own; the parent section supplies it.
*/
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
import { WebPushCard } from "@/components/settings/web-push-card";
import { WebhookCard } from "@/components/settings/webhook-card";
import { EmailCard } from "@/components/settings/email-card";
import { apiGet } from "@/lib/api/api-fetch";
interface GlobalServiceAvailability {
telegramGlobal: boolean;
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
export function NotificationChannelsPanel() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: globalServices } = useQuery({
queryKey: queryKeys.settingsGlobalServices(),
queryFn: async () => {
return apiGet<GlobalServiceAvailability>("/api/settings/global-services");
},
enabled: isAuthenticated,
});
const showTelegram = globalServices?.telegramGlobal ?? true;
const showNtfy = globalServices?.ntfyGlobal ?? true;
const showWebPush = globalServices?.webPushGlobal ?? true;
return (
<div className="space-y-6" data-slot="notification-channels-panel">
<p className="text-muted-foreground text-sm">
{t("settings.sections.integrations.channels.description")}
</p>
<NotificationStatusCard />
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each channel card's anchor
target below the safe-area sticky page header when the user lands
on `/settings/integrations#telegram` or similar. */}
{showTelegram && (
<div id="telegram" className="scroll-mt-28">
<TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy" className="scroll-mt-28">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push" className="scroll-mt-28">
<WebPushCard />
</div>
)}
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
Matrix / Home Assistant in one channel). */}
<div id="webhook" className="scroll-mt-28">
<WebhookCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
<div id="email" className="scroll-mt-28">
<EmailCard isAuthenticated={isAuthenticated} />
</div>
</div>
);
}
@@ -1,56 +1,37 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { CoachNudgeCard } from "@/components/settings/coach-nudge-card";
import { LowStockCard } from "@/components/settings/low-stock-card";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
import { WebPushCard } from "@/components/settings/web-push-card";
import { WebhookCard } from "@/components/settings/webhook-card";
import { EmailCard } from "@/components/settings/email-card";
import { apiGet } from "@/lib/api/api-fetch";
interface GlobalServiceAvailability {
telegramGlobal: boolean;
ntfyGlobal: boolean;
webPushGlobal: boolean;
apiGlobal: boolean;
moodLogGlobal: boolean;
}
/**
* `<NotificationsSection>` — Settings → Notifications.
*
* v1.18.0 (S3) — the delivery CHANNELS (Telegram / ntfy / Web Push / Webhook /
* Email + the live per-channel status surface) moved to Settings →
* Integrations → Channels: a channel is a delivery provider, the same family as
* a connected device, not a notification preference. What stays here is the
* reminder-TYPE content (which events fire): mood reminder, medication
* low-stock runway, proactive Coach nudge. S4 will consolidate these onto the
* Reminders hub.
*/
export function NotificationsSection() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const { data: globalServices } = useQuery({
queryKey: queryKeys.settingsGlobalServices(),
queryFn: async () => {
return apiGet<GlobalServiceAvailability>("/api/settings/global-services");
},
enabled: isAuthenticated,
});
const showTelegram = globalServices?.telegramGlobal ?? true;
const showNtfy = globalServices?.ntfyGlobal ?? true;
const showWebPush = globalServices?.webPushGlobal ?? true;
return (
<section
aria-labelledby="settings-section-notifications-title"
className="space-y-6"
>
{/* v1.4.33 IW7 — disambiguate the channel-config screen from the
inbox at `/notifications`. Both surfaces used to be named plain
"Notifications", so the crumb spells out where the user is and
offers a one-tap jump to the inbox. */}
{/* v1.4.33 IW7 — disambiguate this settings screen from the inbox at
`/notifications`. The crumb spells out where the user is and offers a
one-tap jump to the inbox. */}
<nav
aria-label={t("nav.breadcrumb")}
className="text-muted-foreground text-xs"
@@ -83,40 +64,21 @@ export function NotificationsSection() {
<p className="text-muted-foreground text-sm">
{t("settings.sections.notifications.description")}
</p>
{/* v1.18.0 (S3) — the delivery channels now live under Integrations.
Cross-link so a user looking for "where do I connect Telegram"
finds them in one tap. */}
<p className="text-muted-foreground text-xs">
{t("settings.sections.notifications.channelsHint")}{" "}
<Link
href="/settings/integrations"
className="text-primary underline underline-offset-2"
data-slot="notifications-channels-cross-link"
>
{t("settings.sections.notifications.channelsHintLink")}
</Link>
</p>
</header>
<NotificationStatusCard />
{/* v1.4.27 MB3 — `scroll-mt-28` keeps each notification card's
anchor target below the safe-area sticky page header when the
user lands on `/settings/notifications#telegram` or similar.
Without the offset the section title pins under the header on
mobile and the user has to scroll back up to find it. */}
{showTelegram && (
<div id="telegram" className="scroll-mt-28">
<TelegramCard isAuthenticated={isAuthenticated} />
</div>
)}
{showNtfy && (
<div id="ntfy" className="scroll-mt-28">
<NtfyCard isAuthenticated={isAuthenticated} />
</div>
)}
{showWebPush && (
<div id="web-push" className="scroll-mt-28">
<WebPushCard />
</div>
)}
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
Matrix / Home Assistant in one channel). */}
<div id="webhook" className="scroll-mt-28">
<WebhookCard isAuthenticated={isAuthenticated} />
</div>
{/* v1.17.1 — SMTP / email. The card hides itself when the operator
hasn't configured SMTP_* env, so it never shows a dead toggle. */}
<div id="email" className="scroll-mt-28">
<EmailCard isAuthenticated={isAuthenticated} />
</div>
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>