Files
HealthLog/src/components/settings/integrations/shared.tsx
T
Marc-André Bombeck 83c06051fe fix(garmin): read weigh-ins in both shapes, keep refused resources out of the way
Weigh-ins: Garmin's document has no samplePk and gives kilograms, the live
service sends a samplePk and grams. A weigh-in now takes its id from
samplePk or else its own GMT instant, reads each mass by magnitude (above
1000 is grams), and is written whole or not at all: a missing weight, a
missing instant or one implausible value drops every row of it, counted
on the wide event by reason.

Refused resources: a lasting 4xx on one resource (a cycle summary for an
account that tracks no cycle) is noted once on the progress column,
counts as done for the backfill, is skipped by the hourly sync and is
listed in the card's details. It no longer holds the backfill open or
turns the connection red. The cycle leg never parks the connection,
whatever it is refused with.

Backfill: weigh-ins walk back to the start of Garmin Connect on their
own, so history before the first watch day and a scale-only account
arrive. Progress and the completion stamp are written only while the
connection the slice started on is still the stored one.

Key: connect sends the key to the one service the person picks (Garmin
Connect or Garmin Connect China), never to both. A parked key is asked
about again once a day with a single token exchange and reconnected when
Garmin accepts it. The key's end is read from the token's own iat and
exp, an estimate the key outlived is cleared after an accepted exchange,
and the validity can be corrected from the card's menu (PATCH
/api/garmin/key-expiry, with the day the key was created) without
pasting it again.

Sync: an incremental run reaches back to the last successful sync, up to
a year, so an outage longer than the 30-day window leaves no gap; a night
Garmin no longer reports (deleted, or turned into a nap) is retired.

Card: connect errors are translated from meta.errorCode in every locale.

Types: BODY_BATTERY, STRESS_LEVEL, TRAINING_READINESS and
ACUTE_TRAINING_LOAD get source ladders of their own (the device or the
bridge first, then the hand-entered sources in their usual order), join
the ranked types and the provenance picker, and the recovery-empty
description names them.
2026-11-04 10:56:52 +01:00

143 lines
5.1 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 { apiGet } from "@/lib/api/api-fetch";
import type { GoogleHealthBackfillEndedEntry } from "@/lib/google-health/backfill-progress";
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
import type { PublishedFitbitRetirement } from "@/lib/fitbit/retirement";
import type { GarminKeyExpiry } from "@/lib/garmin/key-expiry";
import type { GarminBackfillStep } from "@/lib/garmin/backfill-progress";
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"
// v1.43 — Dawarich movement context; publishes `health` only.
| "dawarich"
// Garmin Personal API; a pasted key, like Nightscout.
| "garmin";
/**
* One envelope entry as the cards read it. Since v1.42.4 the cards render
* `health` alone; the older health-shaped fields (ledger `state`, `lastError`,
* `syncHealth`, token expiry, plan and scope lists, `needsReauth`) stay on the
* wire for one transition release and are deliberately not declared here, so
* the compiler refuses a card that starts reading them again.
*/
export interface IntegrationStatusViewModel {
integration: IntegrationKey;
/**
* The one server-resolved state (ok / limited / degraded / action_required),
* null while the provider is not connected.
*/
health?: ConnectionHealth | null;
consecutiveFailuresByKind?: {
transient: number;
reauth_required: number;
persistent: number;
} | null;
/** Client-side copy of the envelope threshold, attached by `pickStatus`. */
failureThreshold?: number;
configured?: boolean;
connected?: boolean;
/** WHOOP / Fitbit / Google Health / Garmin: older history still loading. */
backfillCompleted?: boolean | null;
/**
* Google Health: data types whose history backfill ended before the start
* of their history, with where and why.
*/
backfillEnded?: GoogleHealthBackfillEndedEntry[];
/**
* Polar / Oura / Strava: usable credentials resolve (BYO or the shared
* app). Greys out "Connect" when false.
*/
available?: boolean;
/** Polar / Oura / Strava: the person stored their own client id/secret. */
hasOwnCredentials?: boolean;
/** Nightscout: a stored token. */
hasToken?: boolean;
/** Garmin: when the stored key stops working, resolved on the server. */
keyExpiry?: GarminKeyExpiry | null;
/** Garmin: resources Garmin refuses for this account, for the details. */
unavailableResources?: GarminBackfillStep[] | null;
/** Per-metric last-seen timestamps with the server-computed stale flag. */
metricFreshness?: MetricFreshnessEntry[];
/** Fitbit: the end of the legacy Web API, resolved on the server. */
retirement?: PublishedFitbitRetirement;
}
export interface IntegrationStatusEnvelope {
threshold: number;
integrations: IntegrationStatusViewModel[];
}
/**
* Shared status fetch for the Settings → Integrations cards. Returns the
* per-integration view-model and the global failure threshold.
*/
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 {
const status = envelope?.integrations.find(
(entry) => entry.integration === integration,
);
return status
? { ...status, failureThreshold: envelope?.threshold }
: undefined;
}
/**
* The current failure streak paired with the server-owned alert threshold, for
* the card's copied diagnostics. The card itself shows no counter: whether a
* streak matters is already in `health`. The alert ladder uses the same
* maximum, so the ratio cannot disagree with when a notification goes out.
*/
export function failureStreakFor(
status:
| Pick<
IntegrationStatusViewModel,
"consecutiveFailuresByKind" | "failureThreshold"
>
| undefined,
): { count: number; threshold: number } | null {
const buckets = status?.consecutiveFailuresByKind;
const threshold = status?.failureThreshold;
if (!buckets || threshold === undefined) return null;
const count = Math.max(
buckets.transient,
buckets.reauth_required,
buckets.persistent,
);
return count > 0 ? { count, threshold } : null;
}