"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 (
{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" && (
{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")}
{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" && (
);
}
// 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" && (
{/* v1.4.19 A5 — visual divider matches Withings for consistency
(the maintainer explicitly called the asymmetry out). */}
{errorMessage && }
{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. */}
{/* 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")}