fix(status): expose background failures

This commit is contained in:
Marc-André Bombeck committed 2026-07-28 14:00:44 +02:00
1 parent a689329e79
commit 8ece6f39cc
31 files changed
+854 -44

No files matched your search

@@ -13,7 +13,11 @@ import { join, sep } from "node:path";
import { globSync } from "node:fs";
import type { SyncVerdict } from "@/lib/integrations/sync-verdict";
import { pillStateForVerdict } from "../shared";
import {
pickStatus,
pillFailurePropsFor,
pillStateForVerdict,
} from "../shared";
describe("pillStateForVerdict — the verdict→pill table", () => {
const table: Array<[SyncVerdict | undefined, string]> = [
@@ -50,6 +54,61 @@ describe("pillStateForVerdict — the verdict→pill table", () => {
});
});
describe("pillFailurePropsFor", () => {
it("projects the active bucket and envelope threshold onto the pill", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "error_transient",
lastSuccessAt: null,
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: "upstream unavailable",
consecutiveFailuresByKind: {
transient: 2,
reauth_required: 0,
persistent: 1,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({
failureCount: 2,
failureThreshold: 3,
});
});
it("returns no ratio for a healthy zero-count status", () => {
const status = pickStatus(
{
threshold: 3,
integrations: [
{
integration: "withings",
state: "connected",
lastSuccessAt: "2026-07-28T12:00:00.000Z",
lastAttemptAt: "2026-07-28T12:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
persistent: 0,
},
},
],
},
"withings",
);
expect(pillFailurePropsFor(status)).toEqual({});
});
});
describe("structural guard — no second pill dialect", () => {
const SRC = join(process.cwd(), "src");
@@ -185,6 +185,8 @@ function toOAuthStatus(
lastAttemptAt: vm.lastAttemptAt,
lastError: vm.lastError,
legacyLastSyncedAt: vm.legacyLastSyncedAt,
consecutiveFailuresByKind: vm.consecutiveFailuresByKind,
failureThreshold: vm.failureThreshold,
syncHealth: vm.syncHealth,
metricFreshness: vm.metricFreshness,
};
@@ -310,11 +312,16 @@ export function ConnectionsPanel() {
pickStatus(integrationStatus, "strava"),
);
const nightscoutViewModel = pickStatus(integrationStatus, "nightscout");
const unclaimedIntegrations = (integrationStatus?.integrations ?? []).filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.configured || entry.connected),
);
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
.filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.configured || entry.connected),
)
.map((entry) => ({
...entry,
failureThreshold: integrationStatus?.threshold,
}));
return (
<div className="space-y-6" data-slot="connections-panel">
@@ -52,6 +52,7 @@ import {
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
@@ -237,6 +238,7 @@ export function FitbitCard({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
@@ -60,6 +60,7 @@ import {
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
@@ -245,6 +246,7 @@ export function GoogleHealthCard({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
@@ -29,6 +29,7 @@ import {
import { useTranslations } from "@/lib/i18n/context";
import {
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationKey,
@@ -89,6 +90,7 @@ export function IntegrationFallbackRow({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
{...pillFailurePropsFor(status)}
now={now}
/>
}
@@ -59,6 +59,7 @@ import {
} from "./sync-outcome";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
pillTimestampFor,
type IntegrationStatusViewModel,
@@ -210,6 +211,7 @@ export function NightscoutCard({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillTimestampFor(status)}
{...pillFailurePropsFor(status)}
/>
}
/>
@@ -58,7 +58,11 @@ import {
useSyncOutcomeMessage,
type SyncOutcomeState,
} from "./sync-outcome";
import { IntegrationErrorMessage, pillStateForVerdict } from "./shared";
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateForVerdict,
} from "./shared";
import {
IntegrationCardDescription,
IntegrationRedirectGuide,
@@ -81,6 +85,12 @@ export interface OAuthProviderStatus {
lastAttemptAt?: string | null;
lastError?: string | null;
legacyLastSyncedAt?: string | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
failureThreshold?: number;
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
@@ -270,6 +280,7 @@ export function OAuthProviderCard({
? (status?.syncHealth?.since ?? null)
: (status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null)
}
{...pillFailurePropsFor(status)}
/>
}
/>
@@ -50,6 +50,8 @@ export interface IntegrationStatusViewModel {
reauth_required: number;
persistent: number;
} | null;
/** Client-side copy of the envelope threshold, attached by `pickStatus`. */
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
connectedAt?: string | null;
@@ -110,7 +112,12 @@ export function pickStatus(
envelope: IntegrationStatusEnvelope | undefined,
integration: IntegrationKey,
): IntegrationStatusViewModel | undefined {
return envelope?.integrations.find((i) => i.integration === integration);
const status = envelope?.integrations.find(
(entry) => entry.integration === integration,
);
return status
? { ...status, failureThreshold: envelope?.threshold }
: undefined;
}
/**
@@ -158,6 +165,36 @@ export function pillStateFor(
return pillStateForVerdict(status?.syncHealth?.verdict);
}
export interface IntegrationPillFailureProps {
failureCount?: number;
failureThreshold?: number;
}
/**
* Pair the largest per-kind streak with the server-owned alert threshold. The
* alert ladder uses the same maximum, so the ratio cannot disagree with when a
* notification becomes eligible.
*/
export function pillFailurePropsFor(
status:
| Pick<
IntegrationStatusViewModel,
"consecutiveFailuresByKind" | "failureThreshold"
>
| undefined,
): IntegrationPillFailureProps {
const buckets = status?.consecutiveFailuresByKind;
const failureThreshold = status?.failureThreshold;
if (!buckets || failureThreshold === undefined) return {};
const failureCount = Math.max(
buckets.transient,
buckets.reauth_required,
buckets.persistent,
);
return failureCount > 0 ? { failureCount, failureThreshold } : {};
}
/**
* The timestamp the pill shows inline. `connected` reads the last success (a
* connection row's own `lastSyncedAt` wins when it is newer, which is what
@@ -43,6 +43,7 @@ import {
import {
IntegrationErrorMessage,
pillFailurePropsFor,
pillStateFor,
pillTimestampFor,
type IntegrationStatusViewModel,
@@ -218,6 +219,7 @@ export function WhoopCard({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>
@@ -43,6 +43,7 @@ import {
import {
IntegrationErrorMessage,
pillStateFor,
pillFailurePropsFor,
pillTimestampFor,
type IntegrationStatusViewModel,
} from "./shared";
@@ -230,6 +231,7 @@ export function WithingsCard({
<IntegrationStatusPill
state={pillState}
lastSyncAt={pillLastSyncAt}
{...pillFailurePropsFor(viewModel)}
/>
}
/>