"use client"; import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Activity, AlertCircle, Download, HeartPulse, Link2, Loader2, RefreshCw, Save, Smile, Unlink, } from "lucide-react"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; import { TestConnectionButton } from "@/components/settings/test-connection-button"; import { IntegrationStatusPill } from "@/components/settings/integration-status-pill"; import type { IntegrationPillState } from "@/components/settings/integration-status-pill"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, measurementDependentKeys, queryKeys, } from "@/lib/query-keys"; interface MoodLogStatus { configured: boolean; enabled: boolean; lastSyncedAt: string | null; entryCount: number; webhookSecret: string | null; } interface GlobalServiceAvailability { telegramGlobal: boolean; ntfyGlobal: boolean; webPushGlobal: boolean; apiGlobal: boolean; moodLogGlobal: boolean; } // v1.4.15 Phase B2: shared status payload for both integration cards. // v1.4.19 Phase A5: the redundant in-card status banner is gone — the // IntegrationStatusPill now owns state + last-sync presentation, and // the actionable error message is shown inline above the action row. type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog"; type IntegrationState = | "connected" | "error_transient" | "error_reauth" | "disconnected" | "parked"; interface IntegrationStatusViewModel { integration: IntegrationKey; state: IntegrationState; lastSuccessAt: string | null; lastAttemptAt: string | null; lastError: string | null; consecutiveFailuresByKind?: { transient: number; reauth_required: number; persistent: number; } | null; configured?: boolean; connected?: boolean; connectedAt?: string | null; legacyLastSyncedAt?: string | null; tokenExpiresAt?: string | null; tokenExpired?: boolean | null; enabled?: boolean; } interface IntegrationStatusEnvelope { threshold: number; integrations: IntegrationStatusViewModel[]; } /** * Shared status fetch for the Settings → Integrations card. Returns * the per-integration view-model AND the global threshold so the * "{n}/{threshold} consecutive failures" string in the UI is single- * sourced from the server. */ function useIntegrationStatuses(enabled: boolean) { return useQuery({ queryKey: queryKeys.integrationsStatus(), queryFn: async () => { const res = await fetch("/api/integrations/status"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as IntegrationStatusEnvelope; }, enabled, refetchOnWindowFocus: true, }); } function pickStatus( envelope: IntegrationStatusEnvelope | undefined, integration: IntegrationKey, ): IntegrationStatusViewModel | undefined { return envelope?.integrations.find((i) => i.integration === integration); } /** * Collapse the API's five-state machine into the four states the * pill UI cares about. `error_transient` and `error_reauth` both * surface as the same "Error — reconnect" pill, the actionable * difference (whether the user must reconnect vs wait for the next * retry) is conveyed via the inline error text underneath. `parked` * (v1.4.43 W14) is its own pill state — the integration has been * disabled after 24h of persistent failures and needs an explicit * "Wieder verbinden" click to resume. */ function pillStateFor( status: IntegrationStatusViewModel | undefined, ): IntegrationPillState { if (!status) return "disconnected"; switch (status.state) { case "connected": return "connected"; case "error_transient": // v1.4.43 W4 H3 — a `persistent` failure-kind streak (Withings // rate-limit 601 / contract-mismatch 293/294) maps to the same // `error_transient` DB state as a normal retryable failure but // tells the user a different story: the access token still // works, the upstream is responding with a non-recoverable // status. Surfacing it as a "warning" pill (orange) instead of // the red "Fehler — neu verbinden" stops the user from clicking // reconnect ten times when reconnect can't fix it. if ((status.consecutiveFailuresByKind?.persistent ?? 0) > 0) { return "warning"; } return "error"; case "error_reauth": return "error"; case "parked": return "parked"; case "disconnected": return "disconnected"; } } /** * Inline actionable error message that surfaces under the pill when a * sync attempt failed. The pill conveys "something is wrong"; this * line tells the user *what* is wrong so they can act on it. Keeping * it deliberately small (one icon + one line) so it doesn't recreate * the v1.4.18 redundant banner the maintainer removed. */ function IntegrationErrorMessage({ message }: { message: string }) { return (

{message}

); } export function IntegrationsSection() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const { data: globalServices } = useQuery({ queryKey: queryKeys.settingsGlobalServices(), queryFn: async () => { const res = await fetch("/api/settings/global-services"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as GlobalServiceAvailability; }, enabled: isAuthenticated, }); const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); const moodLogEnabled = globalServices?.moodLogGlobal ?? true; const withingsViewModel = pickStatus(integrationStatus, "withings"); const whoopViewModel = pickStatus(integrationStatus, "whoop"); const fitbitViewModel = pickStatus(integrationStatus, "fitbit"); const moodLogViewModel = pickStatus(integrationStatus, "moodlog"); return (

{t("settings.sections.integrations.title")}

{t("settings.sections.integrations.description")}

{moodLogEnabled && }
); } function WithingsCard({ isAuthenticated, viewModel, }: { isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( null, ); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); const [credsMsg, setCredsMsg] = useState(null); const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( null, ); const queryClient = useQueryClient(); const { data: status } = useQuery({ queryKey: queryKeys.withingsStatus(), queryFn: async () => { const res = await fetch("/api/withings/status"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as { connected: boolean; configured: boolean; lastSyncedAt?: string | null; connectedAt?: string; tokenExpired?: boolean; // v1.4.25 W5d — reconnect banner conditional. `null` = // legacy v1.4.24 connection without `user.activity`; the // user needs to re-auth before steps / active energy / // distance / floors ingest unlocks. scope?: string | null; hasActivityScope?: boolean; }; }, enabled: isAuthenticated, }); const disconnect = useMutation({ mutationFn: async () => { const res = await fetch("/api/withings/disconnect", { method: "POST" }); if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); }, }); // v1.4.43 W14 — clear a parked integration via the resume endpoint. // The CTA is rendered inside the parked banner below; success // invalidates both the per-card status (so the pill flips back to // connected immediately) and the cross-integration envelope (so any // other view picks up the change on its next focus). const resume = useMutation({ mutationFn: async () => { const res = await fetch("/api/integrations/withings/resume", { method: "POST", }); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as { resumed: boolean; wasParked: boolean; }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); }, }); async function handleSync(fullSync = false) { setSyncing(true); setSyncMsg(null); setSyncMsgType(null); try { const res = await fetch("/api/withings/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); const json = await res.json(); if (res.ok) { setSyncMsg( fullSync ? t("settings.withingsFullSyncResult", { count: json.data.imported, }) : t("settings.withingsSyncResult", { count: json.data.imported }), ); setSyncMsgType("success"); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { setSyncMsg(json.error || t("settings.withingsSyncFailed")); setSyncMsgType("error"); } } catch { setSyncMsg(t("settings.withingsSyncFailed")); setSyncMsgType("error"); } finally { setSyncing(false); } } async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); setCredsSaving(true); setCredsMsg(null); setCredsMsgType(null); try { const res = await fetch("/api/withings/credentials", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim(), }), }); if (res.ok) { setCredsMsg(t("settings.withingsCredentialsSaved")); setCredsMsgType("success"); setClientId(""); setClientSecret(""); queryClient.invalidateQueries({ queryKey: queryKeys.withings() }); } else { try { const json = await res.json(); setCredsMsg(json.error || t("settings.savingError")); } catch { setCredsMsg(t("settings.savingError")); } setCredsMsgType("error"); } } catch { setCredsMsg(t("common.networkError")); setCredsMsgType("error"); } setCredsSaving(false); } // The pill state derives from the cross-integration status // envelope, but the per-card `lastSyncedAt` is sourced from the // Withings-specific endpoint so it stays accurate immediately // after a manual "Sync now" (which only invalidates the per-card // query). When neither endpoint has answered yet we fall back to // "disconnected" so the card never renders a status-less header. const pillState: IntegrationPillState = status?.connected ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; // v1.4.43 W14 — `parked` and `error` both want the underlying error // message surfaced under the pill (the pill says "what" — the // message says "why"). Other states leave the inline line off. const errorMessage = (pillState === "error" || pillState === "parked") && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.withings")}

{t("settings.withingsDescription")}


{errorMessage && } {/* v1.4.25 W5d — Withings activity-scope reconnect banner. Only paints when (a) the user has an active Withings connection (so the credentials are already saved + a token exists) AND (b) the persisted scope is missing `user.activity`. Reconnecting takes the user through the standard /api/withings/connect → Withings → callback flow; the callback persists the upgraded scope and the banner vanishes on the next status poll. */} {status?.connected && status?.hasActivityScope === false && ( {t("settings.integrations.withings.reconnect.banner.title")} {t("settings.integrations.withings.reconnect.banner.body")} {t("settings.integrations.withings.reconnect.banner.action")} {" →"} )} {/* v1.4.43 W14 — parked-integration resume CTA. Surfaces only when the row state is `parked` (>24h of persistent failures). The button POSTs to /api/integrations/withings/ resume which calls `resumeIntegrationFromPark`; on success the per-card status invalidates and the pill flips back to connected without a page refresh. The button is the primary action the user can take from this card — wider tap target than the inline action row so it's reachable on a Pixel 5 viewport. */} {pillState === "parked" && (
{t("settings.integrationPill.parkedReconnect")}
)} {resume.isError && (

{t("settings.integrationPill.resumeError")}

)} {resume.isSuccess && resume.data?.wasParked && (

{t("settings.integrationPill.resumeSuccess")}

)}

{t("settings.withingsCredentials")}

{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from a 3-column row (client-id / secret / save) to a 2-column row of inputs at `sm:`, with the Save button lifted out into its own row below. The previous "invisible Label" hack to align the button with the input baselines fell apart on Galaxy Fold; lifting the button into a dedicated `flex justify-end` row gives it consistent placement on every viewport and lets the input pair span the full width. */}
setClientId(e.target.value)} placeholder={ status?.configured ? t("settings.withingsCredentialsSavedPlaceholder") : t("settings.withingsClientId") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="next" />
setClientSecret(e.target.value)} placeholder={ status?.configured ? t("settings.withingsCredentialsSavedPlaceholderSecret") : t("settings.withingsClientSecret") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="done" />
{credsMsg && (

{credsMsg}

)}
{status?.connected ? ( <> {/* v1.4.27 MB7 / CF-57 — the action row already wraps via `flex-wrap`, but on Pixel 5 the four AlertDialog + test-connection triggers each took a fractional slot that read jagged. Force each button to a `min-w-[10rem]` on ` {t("settings.withingsFullSyncTitle")} {t("settings.withingsFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.withingsSynchronize")} {t("settings.withingsDisconnectTitle")} {t("settings.withingsDisconnectDescription")} {t("common.cancel")} disconnect.mutate()} > {t("settings.withingsDisconnect")}
{syncMsg && (

{syncMsg}

)} ) : status?.configured ? ( ) : (
{t("settings.withingsNoCredentials")}
)}
); } function WhoopCard({ isAuthenticated, viewModel, }: { isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( null, ); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); const [credsMsg, setCredsMsg] = useState(null); const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( null, ); const queryClient = useQueryClient(); const { data: status } = useQuery({ queryKey: queryKeys.whoopStatus(), queryFn: async () => { const res = await fetch("/api/whoop/status"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as { connected: boolean; configured: boolean; lastSyncedAt?: string | null; connectedAt?: string; tokenExpired?: boolean; backfillCompleted?: boolean; scope?: string | null; }; }, enabled: isAuthenticated, }); const disconnect = useMutation({ mutationFn: async () => { const res = await fetch("/api/whoop/disconnect", { method: "POST" }); if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); // Clear a parked integration via the resume endpoint. The CTA is // rendered inside the parked banner below; success invalidates both // the per-card status (so the pill flips back to connected // immediately) and the cross-integration envelope (so any other view // picks up the change on its next focus). const resume = useMutation({ mutationFn: async () => { const res = await fetch("/api/integrations/whoop/resume", { method: "POST", }); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as { resumed: boolean; wasParked: boolean; }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); async function handleSync(fullSync = false) { setSyncing(true); setSyncMsg(null); setSyncMsgType(null); try { const res = await fetch("/api/whoop/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); const json = await res.json(); if (res.ok) { setSyncMsg(t("settings.whoopSyncResult", { count: json.data.imported })); setSyncMsgType("success"); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { setSyncMsg(json.error || t("settings.whoopSyncFailed")); setSyncMsgType("error"); } } catch { setSyncMsg(t("settings.whoopSyncFailed")); setSyncMsgType("error"); } finally { setSyncing(false); } } async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); setCredsSaving(true); setCredsMsg(null); setCredsMsgType(null); try { const res = await fetch("/api/whoop/credentials", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim(), }), }); if (res.ok) { setCredsMsg(t("settings.whoopCredentialsSaved")); setCredsMsgType("success"); setClientId(""); setClientSecret(""); queryClient.invalidateQueries({ queryKey: queryKeys.whoop() }); } else { try { const json = await res.json(); setCredsMsg(json.error || t("settings.savingError")); } catch { setCredsMsg(t("settings.savingError")); } setCredsMsgType("error"); } } catch { setCredsMsg(t("common.networkError")); setCredsMsgType("error"); } setCredsSaving(false); } const pillState: IntegrationPillState = status?.connected ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = (pillState === "error" || pillState === "parked") && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.whoop")}

{t("settings.whoopDescription")}

{t("settings.whoopOverlapNote")}


{errorMessage && } {/* Parked-integration resume CTA. Surfaces only when the row state is `parked` (>24h of persistent failures). The button POSTs to /api/integrations/whoop/resume which calls `resumeIntegrationFromPark`; on success the per-card status invalidates and the pill flips back to connected without a page refresh. Wider tap target than the inline action row so it stays reachable on a Pixel 5 viewport. */} {pillState === "parked" && (
{t("settings.integrationPill.parkedReconnect")}
)} {resume.isError && (

{t("settings.integrationPill.resumeError")}

)} {resume.isSuccess && resume.data?.wasParked && (

{t("settings.integrationPill.resumeSuccess")}

)}

{t("settings.whoopCredentials")}

{t("settings.whoopCredentialsHelp")}

setClientId(e.target.value)} placeholder={ status?.configured ? t("settings.whoopCredentialsSavedPlaceholder") : t("settings.whoopClientId") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="next" />
setClientSecret(e.target.value)} placeholder={ status?.configured ? t("settings.whoopCredentialsSavedPlaceholderSecret") : t("settings.whoopClientSecret") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="done" />
{credsMsg && (

{credsMsg}

)}
{status?.connected ? ( <>
{t("settings.whoopFullSyncTitle")} {t("settings.whoopFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.whoopSynchronize")} {t("settings.whoopDisconnectTitle")} {t("settings.whoopDisconnectDescription")} {t("common.cancel")} disconnect.mutate()} > {t("settings.whoopDisconnect")}
{status?.backfillCompleted === false && (

{t("settings.whoopBackfillInProgress")}

)} {syncMsg && (

{syncMsg}

)} ) : status?.configured ? ( ) : (
{t("settings.whoopNoCredentials")}
)}
); } // v1.12.0 — Google Health (Fitbit & Pixel) card. Mirrors the WHOOP card: a // BYO-key credentials form first, then an OAuth connect, then the // sync/test/disconnect action row + parked-resume banner. Status reads from the // dedicated /api/fitbit/status (queryKeys.fitbitStatus); the pill/error/parked // state comes off the cross-integration envelope view-model like WHOOP. function FitbitCard({ isAuthenticated, viewModel, }: { isAuthenticated: boolean; viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const [syncing, setSyncing] = useState(false); const [syncMsg, setSyncMsg] = useState(null); const [syncMsgType, setSyncMsgType] = useState<"success" | "error" | null>( null, ); const [clientId, setClientId] = useState(""); const [clientSecret, setClientSecret] = useState(""); const [credsSaving, setCredsSaving] = useState(false); const [credsMsg, setCredsMsg] = useState(null); const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>( null, ); const queryClient = useQueryClient(); const { data: status } = useQuery({ queryKey: queryKeys.fitbitStatus(), queryFn: async () => { const res = await fetch("/api/fitbit/status"); if (!res.ok) throw new Error("Failed"); const json = await res.json(); return json.data as { connected: boolean; configured: boolean; lastSyncedAt?: string | null; connectedAt?: string; tokenExpired?: boolean; backfillCompleted?: boolean; scope?: string | null; }; }, enabled: isAuthenticated, }); const disconnect = useMutation({ mutationFn: async () => { const res = await fetch("/api/fitbit/disconnect", { method: "POST" }); if (!res.ok) throw new Error("Failed"); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); // Clear a parked integration via the resume endpoint. The CTA is // rendered inside the parked banner below; success invalidates both // the per-card status (so the pill flips back to connected // immediately) and the cross-integration envelope (so any other view // picks up the change on its next focus). const resume = useMutation({ mutationFn: async () => { const res = await fetch("/api/integrations/fitbit/resume", { method: "POST", }); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as { resumed: boolean; wasParked: boolean; }; }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); }, }); async function handleSync(fullSync = false) { setSyncing(true); setSyncMsg(null); setSyncMsgType(null); try { const res = await fetch("/api/fitbit/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); const json = await res.json(); if (res.ok) { setSyncMsg( t("settings.fitbitSyncResult", { count: json.data.imported }), ); setSyncMsgType("success"); void invalidateKeys(queryClient, measurementDependentKeys); queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); } else { setSyncMsg(json.error || t("settings.fitbitSyncFailed")); setSyncMsgType("error"); } } catch { setSyncMsg(t("settings.fitbitSyncFailed")); setSyncMsgType("error"); } finally { setSyncing(false); } } async function handleSaveCredentials(e: React.FormEvent) { e.preventDefault(); setCredsSaving(true); setCredsMsg(null); setCredsMsgType(null); try { const res = await fetch("/api/fitbit/credentials", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId: clientId.trim(), clientSecret: clientSecret.trim(), }), }); if (res.ok) { setCredsMsg(t("settings.fitbitCredentialsSaved")); setCredsMsgType("success"); setClientId(""); setClientSecret(""); queryClient.invalidateQueries({ queryKey: queryKeys.fitbit() }); } else { try { const json = await res.json(); setCredsMsg(json.error || t("settings.savingError")); } catch { setCredsMsg(t("settings.savingError")); } setCredsMsgType("error"); } } catch { setCredsMsg(t("common.networkError")); setCredsMsgType("error"); } setCredsSaving(false); } const pillState: IntegrationPillState = status?.connected ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = (pillState === "error" || pillState === "parked") && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.fitbit")}

{t("settings.fitbitTag")}

{t("settings.fitbitDescription")}

{t("settings.fitbitOverlapNote")}


{errorMessage && } {/* Parked-integration resume CTA. Surfaces only when the row state is `parked` (>24h of persistent failures). The button POSTs to /api/integrations/fitbit/resume which calls `resumeIntegrationFromPark`; on success the per-card status invalidates and the pill flips back to connected without a page refresh. */} {pillState === "parked" && (
{t("settings.integrationPill.parkedReconnect")}
)} {resume.isError && (

{t("settings.integrationPill.resumeError")}

)} {resume.isSuccess && resume.data?.wasParked && (

{t("settings.integrationPill.resumeSuccess")}

)}

{t("settings.fitbitCredentials")}

{t("settings.fitbitCredentialsHelp")}

setClientId(e.target.value)} placeholder={ status?.configured ? t("settings.fitbitCredentialsSavedPlaceholder") : t("settings.fitbitClientId") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="next" />
setClientSecret(e.target.value)} placeholder={ status?.configured ? t("settings.fitbitCredentialsSavedPlaceholderSecret") : t("settings.fitbitClientSecret") } maxLength={200} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="done" />
{credsMsg && (

{credsMsg}

)}
{status?.connected ? ( <>
{t("settings.fitbitFullSyncTitle")} {t("settings.fitbitFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.fitbitSynchronize")} {t("settings.fitbitDisconnectTitle")} {t("settings.fitbitDisconnectDescription")} {t("common.cancel")} disconnect.mutate()} > {t("settings.fitbitDisconnect")}
{status?.backfillCompleted === false && (

{t("settings.fitbitBackfillInProgress")}

)} {syncMsg && (

{syncMsg}

)} ) : status?.configured ? ( ) : (
{t("settings.fitbitNoCredentials")}
)}
); } function MoodLogCard({ viewModel, }: { viewModel: IntegrationStatusViewModel | undefined; }) { const { t } = useTranslations(); const queryClient = useQueryClient(); const [url, setUrl] = useState(""); const [apiKey, setApiKey] = useState(""); const [saving, setSaving] = useState(false); const [syncing, setSyncing] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); const { data: status, refetch: refetchStatus } = useQuery({ queryKey: queryKeys.moodlogStatus(), queryFn: async () => { const res = await fetch("/api/integrations/moodlog/status"); if (!res.ok) throw new Error("Failed"); return (await res.json()).data as MoodLogStatus; }, }); async function handleSave(e: React.FormEvent) { e.preventDefault(); setSaving(true); setMsg(null); const res = await fetch("/api/settings/moodlog", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ url: url.trim(), apiKey: apiKey.trim() }), }); if (res.ok) { setMsg(t("settings.moodLogSaved")); setMsgType("success"); setUrl(""); setApiKey(""); await refetchStatus(); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { const json = await res.json(); setMsg(json.error || t("settings.savingError")); setMsgType("error"); } setSaving(false); } async function handleSync(fullSync = false) { setSyncing(true); setMsg(null); try { const res = await fetch("/api/integrations/moodlog/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fullSync }), }); if (res.ok) { const json = await res.json(); setMsg( t("settings.moodLogSyncResult").replace( "{count}", String(json.data.imported), ), ); setMsgType("success"); await refetchStatus(); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } else { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); } } catch { setMsg(t("settings.moodLogSyncFailed")); setMsgType("error"); } setSyncing(false); } async function handleDisconnect() { const res = await fetch("/api/settings/moodlog", { method: "DELETE" }); if (res.ok) { setMsg(t("settings.moodLogDisconnected")); setMsgType("success"); await refetchStatus(); queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus() }); } } // Mirror Withings logic: the pill state comes from the cross- // integration envelope; per-card `lastSyncedAt` from the Mood Log // endpoint so a manual sync paints fresh "X min ago" without // waiting for the envelope to refetch. const pillState: IntegrationPillState = status?.configured ? pillStateFor(viewModel) : "disconnected"; const pillLastSyncAt = status?.lastSyncedAt ?? viewModel?.lastSuccessAt ?? null; const errorMessage = pillState === "error" && viewModel?.lastError ? viewModel.lastError : null; return (

{t("settings.moodLogDescription")}

{/* v1.4.19 A5 — visual divider matches Withings for consistency (the maintainer explicitly called the asymmetry out). */}
{errorMessage && }
setUrl(e.target.value)} autoComplete="off" spellCheck={false} autoCapitalize="none" enterKeyHint="next" />
setApiKey(e.target.value)} autoComplete="off" inputMode="text" spellCheck={false} autoCapitalize="none" enterKeyHint="done" />
{/* v1.4.33 — save-button placement contract: right-aligned primary `Speichern` with the secondary `Verbindung testen` immediately to its left. Mood Log used to left-align its row, which was the only integration form that did. */}
{status?.configured && (
{status.webhookSecret && (
{/* v1.4.33 — the webhook secret is a long hex string; on a 393 CSS px viewport the input + Copy button used to push past the card edge and trigger a body horizontal scroll. Stack vertically on `

{t("settings.moodLogWebhookSecretHelp")}

)} {/* v1.4.19 A5 — The "letzter Sync" line that used to live here is gone. The pill in the card header already carries that information; repeating it inside the body is the redundancy the maintainer flagged. The entry-count is the only number we still surface because it's an integration-specific datapoint the pill cannot convey. */}
{t("settings.moodLogEntries")}: {status.entryCount}
{/* v1.4.33 — the Sync / Voll-Sync / Trennen triplet overflowed the card edge on a 393 CSS px viewport (~376 px of buttons + gaps inside 345 px of inner width). Match the Withings card pattern: `flex-wrap` + `min-w-[10rem]` on ` {t("settings.moodLogFullSyncTitle")} {t("settings.moodLogFullSyncDescription")} {t("common.cancel")} handleSync(true)}> {t("settings.moodLogFullSyncConfirm")} {/* v1.4.3: aligned with the Withings disconnect button — outline + text-destructive instead of solid destructive so the trigger reads as a reversible action; the actual "yes, disconnect" confirmation inside the dialog keeps its solid-red treatment. */} {t("settings.moodLogDisconnectTitle")} {t("settings.moodLogDisconnectDescription")} {t("common.cancel")} {t("settings.moodLogDisconnect")}
)} {msg && (

{msg}

)} ); }