mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
A sync failure the classifier bucketed as `transient` had no exit. The park test read `kind === "persistent"` and the persistent streak anchor was only ever stamped by a persistent failure, so a streak could run for a thousand attempts with the persistent counter at zero, the state frozen at `error_transient`, and no ladder it could climb. `transient` is a hypothesis about retryability, and the classifier defaults every timeout, 429, 5xx and unrecognised response to it precisely because the wire cannot tell a reboot from a permanent break. That default is right for one attempt and wrong for a thousand: an unbroken streak retried hourly for a full day has refuted it by experiment. Record it as `persistent` from there, which feeds the park ladder that already exists — 48 h of unbroken failure end to end before an integration stops attempting. The counters could not accumulate anyway. `IntegrationStatus` is keyed (user, integration) while Withings runs four legs on four crons and WHOOP four resources on four more, and a success cleared the error, every bucket, the streak anchor and the state whatever leg had recorded them. The ECG tick at :41 wiped what the sleep tick recorded at :15 — including a reauth park — so the strike ladder was reset several times an hour and the card painted a green "connected · 12 minutes ago" over a data class that had stopped arriving. A success now clears only what it is entitled to clear. Two columns carry it: `failing_since_at` dates the streak, `failing_leg` names its owner. The migration backfills the anchor for rows already in an error state so a connection broken for weeks escalates on its next attempt rather than restarting its clock.
200 lines
7.0 KiB
TypeScript
200 lines
7.0 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Shared types + status plumbing for the Settings → Integrations cards.
|
|
* Extracted from the former 1.6k-LOC `integrations-section.tsx`
|
|
* monolith; one card per integration lives next to this module under
|
|
* `src/components/settings/integrations/`.
|
|
*/
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { AlertCircle } from "lucide-react";
|
|
|
|
import type { IntegrationPillState } from "@/components/settings/integration-status-pill";
|
|
import { apiGet } from "@/lib/api/api-fetch";
|
|
import type {
|
|
MetricFreshnessEntry,
|
|
SyncHealth,
|
|
SyncVerdict,
|
|
} from "@/lib/integrations/sync-verdict";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
|
|
// 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.
|
|
export type IntegrationKey =
|
|
| "withings"
|
|
| "whoop"
|
|
| "fitbit"
|
|
| "polar"
|
|
| "oura"
|
|
// v1.27.0 — Google Health (Fitbit + Pixel Watch + Fitbit Air).
|
|
| "google-health"
|
|
// v1.28.x — Strava OAuth workout source.
|
|
| "strava"
|
|
// Nightscout folds onto the envelope so the card stops fetching its own
|
|
// status and inherits the shared verdict.
|
|
| "nightscout";
|
|
export type IntegrationState =
|
|
"connected" | "error_transient" | "error_reauth" | "disconnected" | "parked";
|
|
|
|
export 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;
|
|
// Withings activity-scope reconnect banner.
|
|
scope?: string | null;
|
|
hasActivityScope?: boolean;
|
|
// WHOOP / Fitbit backfill-in-progress note.
|
|
backfillCompleted?: boolean | null;
|
|
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
|
// lapsed (the 7-day "Testing"-mode expiry, or a revoked grant). The card
|
|
// paints a distinct re-consent CTA off this flag, separate from the parked
|
|
// state. Server-populated on the `/api/integrations/status` envelope.
|
|
needsReauth?: boolean;
|
|
// Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
|
|
// greys out the connect button when no credentials resolve; `hasOwnCredentials`
|
|
// drives the saved-placeholder UI.
|
|
available?: boolean;
|
|
hasOwnCredentials?: boolean;
|
|
// Nightscout: a stored token, and the private-network opt-in.
|
|
hasToken?: boolean;
|
|
allowPrivateHost?: boolean;
|
|
/**
|
|
* The server-resolved liveness verdict. Present on every envelope entry;
|
|
* `undefined` only while the envelope is still loading.
|
|
*/
|
|
syncHealth?: SyncHealth;
|
|
/** Per-metric last-seen timestamps with the server-computed stale flag. */
|
|
metricFreshness?: MetricFreshnessEntry[];
|
|
}
|
|
|
|
export 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.
|
|
*/
|
|
export function useIntegrationStatuses(enabled: boolean) {
|
|
return useQuery({
|
|
queryKey: queryKeys.integrationsStatus(),
|
|
queryFn: async () => {
|
|
return apiGet<IntegrationStatusEnvelope>("/api/integrations/status");
|
|
},
|
|
enabled,
|
|
refetchOnWindowFocus: true,
|
|
});
|
|
}
|
|
|
|
export function pickStatus(
|
|
envelope: IntegrationStatusEnvelope | undefined,
|
|
integration: IntegrationKey,
|
|
): IntegrationStatusViewModel | undefined {
|
|
return envelope?.integrations.find((i) => i.integration === integration);
|
|
}
|
|
|
|
/**
|
|
* The one verdict→pill mapping in the tree.
|
|
*
|
|
* There used to be three of these, one per card family, and they disagreed:
|
|
* the same ledger state painted red on one card and orange on the next, on the
|
|
* same page. The server now resolves the verdict once and this projects it once
|
|
* — a fourth dialect cannot be minted without the structural guard noticing.
|
|
*
|
|
* `failing` deliberately lands on the warning tone, not the destructive one:
|
|
* **red is reserved for "your action fixes this"** (reconnect, resume). Telling
|
|
* a user to reconnect against a 503 sends them clicking at something they
|
|
* cannot repair; the streak detail survives in the inline error line and the
|
|
* "{n}/{threshold} consecutive failures" string the envelope single-sources.
|
|
*/
|
|
export function pillStateForVerdict(
|
|
verdict: SyncVerdict | undefined,
|
|
): IntegrationPillState {
|
|
switch (verdict) {
|
|
case "fresh":
|
|
return "connected";
|
|
case "stale":
|
|
return "stale";
|
|
case "stalled":
|
|
return "stalled";
|
|
case "failing":
|
|
return "warning";
|
|
case "reauth_required":
|
|
return "error";
|
|
case "parked":
|
|
return "parked";
|
|
case "pending_first_sync":
|
|
return "pending-setup";
|
|
case "disconnected":
|
|
case undefined:
|
|
return "disconnected";
|
|
}
|
|
}
|
|
|
|
/** Project a view-model onto its pill state. Undefined = still loading. */
|
|
export function pillStateFor(
|
|
status: IntegrationStatusViewModel | undefined,
|
|
): IntegrationPillState {
|
|
return pillStateForVerdict(status?.syncHealth?.verdict);
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* repaints a fresh "just now" straight after a manual sync); the aging and
|
|
* failing states read the instant that triggered the verdict.
|
|
*
|
|
* `failing` must NOT fall through to the `legacyLastSyncedAt` arm below. That
|
|
* field is a connection row's own clock, kept fresh by the very sync that is
|
|
* erroring, so a provider that had delivered nothing for a fortnight painted a
|
|
* "just now" beside its warning.
|
|
*/
|
|
export function pillTimestampFor(
|
|
status: IntegrationStatusViewModel | undefined,
|
|
): string | null {
|
|
const verdict = status?.syncHealth?.verdict;
|
|
if (verdict === "stale" || verdict === "stalled" || verdict === "failing") {
|
|
return status?.syncHealth?.since ?? null;
|
|
}
|
|
return status?.legacyLastSyncedAt ?? status?.lastSuccessAt ?? null;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export function IntegrationErrorMessage({ message }: { message: string }) {
|
|
return (
|
|
<p
|
|
data-testid="integration-error-message"
|
|
className="text-destructive flex items-start gap-1.5 text-sm"
|
|
>
|
|
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
<span className="min-w-0 break-words">{message}</span>
|
|
</p>
|
|
);
|
|
}
|