diff --git a/prisma/migrations/0029_integration_status/migration.sql b/prisma/migrations/0029_integration_status/migration.sql
new file mode 100644
index 000000000..512015ea0
--- /dev/null
+++ b/prisma/migrations/0029_integration_status/migration.sql
@@ -0,0 +1,52 @@
+-- v1.4.15 Phase B2: per-(user, integration) sync status row.
+--
+-- The Settings → Integrations card needs more than a "connected yes/no"
+-- bit to be useful: it must show last-success time, last-attempt time,
+-- the most-recent error, and a "needs reconnect" state when the
+-- refresh-token grant has revoked. A separate table is preferable to
+-- piling more columns onto `users` / `withings_connections` because:
+--
+-- 1. moodLog credentials live on `users`, Withings tokens live on
+-- `withings_connections` — neither is a good home for the OTHER
+-- integration's status.
+-- 2. Future integrations (HealthKit, Garmin, etc.) get a row here
+-- without a schema migration per integration.
+-- 3. The (userId, integration) UNIQUE constraint upserts cleanly
+-- from the `recordSyncSuccess` / `recordSyncFailure` helpers in
+-- `src/lib/integrations/status.ts`.
+--
+-- Encryption: `last_error` is AES-256-GCM encrypted at the application
+-- layer (see `src/lib/crypto.ts`) because some upstream errors echo
+-- back fragments of the user's URL or apiKey (moodLog 401 responses
+-- can include the supplied Authorization header in the body). Keeping
+-- this column ciphertext-only matches the Withings/moodLog credential
+-- treatment elsewhere in the schema.
+--
+-- `consecutive_failures` resets to 0 on the next success and feeds the
+-- admin-Telegram alert threshold (default 3, see PERSISTENT_FAILURE_*
+-- constants in `src/lib/integrations/status.ts`). `alerted_at` is the
+-- idempotency guard so the alert fires once per failure burst, not
+-- once per failure.
+
+CREATE TABLE "integration_statuses" (
+ "id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "integration" TEXT NOT NULL,
+ "state" TEXT NOT NULL DEFAULT 'connected',
+ "last_success_at" TIMESTAMP(3),
+ "last_attempt_at" TIMESTAMP(3),
+ "last_error" TEXT,
+ "consecutive_failures" INTEGER NOT NULL DEFAULT 0,
+ "alerted_at" TIMESTAMP(3),
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updated_at" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "integration_statuses_pkey" PRIMARY KEY ("id")
+);
+
+CREATE INDEX "integration_statuses_user_id_idx" ON "integration_statuses"("user_id");
+CREATE INDEX "integration_statuses_state_idx" ON "integration_statuses"("state");
+CREATE UNIQUE INDEX "integration_statuses_user_id_integration_key" ON "integration_statuses"("user_id", "integration");
+
+ALTER TABLE "integration_statuses" ADD CONSTRAINT "integration_statuses_user_id_fkey"
+ FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
index 99e4f2009..044d57cdb 100644
--- a/prisma/migrations/migration_lock.toml
+++ b/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
+# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
new file mode 100644
index 000000000..9b8569bb5
--- /dev/null
+++ b/src/app/api/integrations/status/route.ts
@@ -0,0 +1,111 @@
+/**
+ * Combined status endpoint for the Settings → Integrations card.
+ *
+ * Returns a snapshot per integration containing:
+ * - the IntegrationStatus row (state, last-success, last-attempt,
+ * decrypted last-error, consecutive failures, threshold)
+ * - integration-specific extras the UI already shows (Withings:
+ * credentials configured, token expiry, OAuth-connected; moodLog:
+ * credentials configured, enabled flag)
+ *
+ * Combining both into one fetch removes a third round-trip from the
+ * Settings page. The legacy /api/withings/status and
+ * /api/integrations/moodlog/status remain for the existing UI/tests
+ * — we add this in addition rather than rewriting them.
+ */
+import { prisma } from "@/lib/db";
+import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiSuccess } from "@/lib/api-response";
+import { annotate } from "@/lib/logging/context";
+import {
+ getIntegrationStatus,
+ getPersistentFailureThreshold,
+ type IntegrationKey,
+} from "@/lib/integrations/status";
+
+export const dynamic = "force-dynamic";
+
+export const GET = apiHandler(async () => {
+ const { user } = await requireAuth();
+ annotate({ action: { name: "integrations.status" } });
+
+ const [withingsStatus, moodLogStatus, dbUser, withingsConn] =
+ await Promise.all([
+ getIntegrationStatus(user.id, "withings"),
+ getIntegrationStatus(user.id, "moodlog"),
+ prisma.user.findUnique({
+ where: { id: user.id },
+ select: {
+ withingsClientIdEncrypted: true,
+ withingsClientSecretEncrypted: true,
+ moodLogUrlEncrypted: true,
+ moodLogApiKeyEncrypted: true,
+ moodLogEnabled: true,
+ moodLogLastSyncedAt: true,
+ },
+ }),
+ prisma.withingsConnection.findUnique({
+ where: { userId: user.id },
+ select: {
+ tokenExpiresAt: true,
+ lastSyncedAt: true,
+ createdAt: true,
+ },
+ }),
+ ]);
+
+ const now = Date.now();
+
+ return apiSuccess({
+ threshold: getPersistentFailureThreshold(),
+ integrations: [
+ {
+ ...withingsStatus,
+ configured:
+ !!dbUser?.withingsClientIdEncrypted &&
+ !!dbUser?.withingsClientSecretEncrypted,
+ connected: !!withingsConn,
+ connectedAt: withingsConn?.createdAt?.toISOString() ?? null,
+ // Surface the connection row's success time too so the UI
+ // can fall back to it when no IntegrationStatus row exists
+ // yet (legacy connections established before this feature).
+ legacyLastSyncedAt: withingsConn?.lastSyncedAt?.toISOString() ?? null,
+ tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null,
+ tokenExpired: withingsConn
+ ? withingsConn.tokenExpiresAt.getTime() <= now
+ : null,
+ } satisfies IntegrationViewModel & WithingsExtras,
+ {
+ ...moodLogStatus,
+ configured:
+ !!dbUser?.moodLogUrlEncrypted && !!dbUser?.moodLogApiKeyEncrypted,
+ enabled: dbUser?.moodLogEnabled ?? false,
+ legacyLastSyncedAt: dbUser?.moodLogLastSyncedAt?.toISOString() ?? null,
+ } satisfies IntegrationViewModel & MoodLogExtras,
+ ],
+ });
+});
+
+interface IntegrationViewModel {
+ integration: IntegrationKey;
+ state: string;
+ lastSuccessAt: string | null;
+ lastAttemptAt: string | null;
+ lastError: string | null;
+ consecutiveFailures: number;
+}
+
+interface WithingsExtras {
+ configured: boolean;
+ connected: boolean;
+ connectedAt: string | null;
+ legacyLastSyncedAt: string | null;
+ tokenExpiresAt: string | null;
+ tokenExpired: boolean | null;
+}
+
+interface MoodLogExtras {
+ configured: boolean;
+ enabled: boolean;
+ legacyLastSyncedAt: string | null;
+}
diff --git a/src/components/settings/__tests__/integrations-section.test.tsx b/src/components/settings/__tests__/integrations-section.test.tsx
new file mode 100644
index 000000000..c22fdb704
--- /dev/null
+++ b/src/components/settings/__tests__/integrations-section.test.tsx
@@ -0,0 +1,209 @@
+/**
+ * v1.4.15 Phase B2 — Settings → Integrations status surface tests.
+ *
+ * The four states this file locks in (one assertion each):
+ * 1. connected → no banner, "Connected" badge only
+ * 2. error_transient → banner with "{count}/{threshold}"
+ * counter, last-error message visible
+ * 3. error_reauth → "Reconnect required" badge surfaces
+ * 4. disconnected → "Disconnected" badge surfaces (used
+ * after the user clicks "Disconnect")
+ *
+ * We test by rendering only the IntegrationsSection with a TanStack
+ * Query mock that returns the desired status. The other queries
+ * (withings status, moodlog status, global services) are stubbed to
+ * stable defaults — the test isn't about those surfaces.
+ */
+import { describe, expect, it, vi, beforeEach } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
+ usePathname: () => "/settings/integrations",
+}));
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: { id: "u1", role: "USER" },
+ isAuthenticated: true,
+ isLoading: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+// Per-test mock — we'll rewrite the integration-status payload between
+// cases via `setIntegrationStatus()`. Withings + moodlog status return
+// stable defaults so the cards render their connect-CTA branch (the
+// banner is what we actually assert on).
+let integrationStatusPayload: unknown = null;
+function setIntegrationStatus(payload: unknown) {
+ integrationStatusPayload = payload;
+}
+
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
+ const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
+ if (key === "integrations/status") {
+ return { data: integrationStatusPayload, isLoading: false };
+ }
+ if (key === "settings/global-services") {
+ return {
+ data: {
+ telegramGlobal: true,
+ ntfyGlobal: true,
+ webPushGlobal: true,
+ apiGlobal: true,
+ moodLogGlobal: true,
+ },
+ isLoading: false,
+ };
+ }
+ if (key === "withings/status") {
+ return {
+ data: { connected: false, configured: false },
+ isLoading: false,
+ };
+ }
+ if (key === "moodlog-status") {
+ return {
+ data: {
+ configured: false,
+ enabled: false,
+ lastSyncedAt: null,
+ entryCount: 0,
+ webhookSecret: null,
+ },
+ isLoading: false,
+ refetch: vi.fn(),
+ };
+ }
+ return { data: null, isLoading: false, refetch: vi.fn() };
+ },
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { IntegrationsSection } from "../integrations-section";
+
+function render() {
+ return renderToStaticMarkup(
+