mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Connect, status, places, settings, test, sync and disconnect replace their stubs. A nightly job (03:20) and the connect backfill read the timeline, visits and residency endpoints through safeFetch and store one row of daily values per local day: distances, places visited, minutes away from the home place and the sealed country with its travel-day verdict. A private instance needs its exact origin in DAWARICH_PRIVATE_ORIGINS. The connection joins the shared ledger and the connection health model, watched for errors only. The day carries a movement block, the model projection drops the travel country, the two movement channels join the correlation matrix under the environment module, travel days become trip suggestions, and the days travel in the environment backup section.
587 lines
22 KiB
TypeScript
587 lines
22 KiB
TypeScript
/**
|
|
* 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, per-kind failure buckets, threshold)
|
|
* - integration-specific extras the UI already shows (Withings:
|
|
* credentials configured, token expiry, OAuth-connected)
|
|
*
|
|
* This is the single fetch the Settings → Integrations cards read off
|
|
* — it carries every field the cards render (Withings activity scope,
|
|
* WHOOP/Fitbit backfill state) so the per-card /api/<provider>/status
|
|
* round-trips are gone
|
|
* from the web. The legacy per-provider routes stay for the iOS/test
|
|
* callers; the web cards no longer hit 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,
|
|
type IntegrationStatusSnapshot,
|
|
} from "@/lib/integrations/status";
|
|
import {
|
|
getSourceMetricFreshness,
|
|
type MetricFreshnessEntry,
|
|
type MetricFreshnessSample,
|
|
} from "@/lib/integrations/metric-freshness";
|
|
import {
|
|
classifyMetricFreshness,
|
|
INTEGRATION_CADENCE,
|
|
resolveSyncVerdict,
|
|
type SyncHealth,
|
|
} from "@/lib/integrations/sync-verdict";
|
|
import {
|
|
getOuraClientCredentials,
|
|
readOuraScopeDenied,
|
|
} from "@/lib/oura/credentials";
|
|
import {
|
|
ouraScopeReconnectList,
|
|
type OuraScopeGatedCollection,
|
|
} from "@/lib/oura/scope";
|
|
import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
|
|
import {
|
|
withingsPlanUnavailableFeatures,
|
|
type WithingsPlanFeature,
|
|
} from "@/lib/withings/plan-features";
|
|
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
|
import { getStravaClientCredentials } from "@/lib/strava/credentials";
|
|
import { hasActivityScope } from "@/lib/withings/client";
|
|
import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
|
|
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export const GET = apiHandler(async () => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "integrations.status" } });
|
|
|
|
const [
|
|
withingsStatus,
|
|
whoopStatus,
|
|
fitbitStatus,
|
|
googleHealthStatus,
|
|
polarStatus,
|
|
ouraStatus,
|
|
stravaStatus,
|
|
nightscoutStatus,
|
|
dawarichStatus,
|
|
dbUser,
|
|
withingsConn,
|
|
whoopConn,
|
|
fitbitConn,
|
|
googleHealthConn,
|
|
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
|
|
// (per-user BYO first, then the shared env app), mirroring the per-card
|
|
// /api/<provider>/status the consolidated envelope now replaces.
|
|
polarAvailable,
|
|
ouraAvailable,
|
|
stravaAvailable,
|
|
] = await Promise.all([
|
|
getIntegrationStatus(user.id, "withings"),
|
|
getIntegrationStatus(user.id, "whoop"),
|
|
getIntegrationStatus(user.id, "fitbit"),
|
|
getIntegrationStatus(user.id, "google-health"),
|
|
getIntegrationStatus(user.id, "polar"),
|
|
getIntegrationStatus(user.id, "oura"),
|
|
getIntegrationStatus(user.id, "strava"),
|
|
getIntegrationStatus(user.id, "nightscout"),
|
|
getIntegrationStatus(user.id, "dawarich"),
|
|
prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: {
|
|
withingsClientIdEncrypted: true,
|
|
withingsClientSecretEncrypted: true,
|
|
whoopClientIdEncrypted: true,
|
|
whoopClientSecretEncrypted: true,
|
|
fitbitClientIdEncrypted: true,
|
|
fitbitClientSecretEncrypted: true,
|
|
googleHealthClientIdEncrypted: true,
|
|
googleHealthClientSecretEncrypted: true,
|
|
polarAccessTokenEncrypted: true,
|
|
polarClientIdEncrypted: true,
|
|
polarClientSecretEncrypted: true,
|
|
ouraAccessTokenEncrypted: true,
|
|
ouraClientIdEncrypted: true,
|
|
ouraClientSecretEncrypted: true,
|
|
ouraScopeDenied: true,
|
|
stravaAccessTokenEncrypted: true,
|
|
stravaClientIdEncrypted: true,
|
|
stravaClientSecretEncrypted: true,
|
|
// Nightscout folds onto the envelope; the card reads these three off
|
|
// it instead of its own /api/nightscout/status round-trip.
|
|
nightscoutUrlEncrypted: true,
|
|
nightscoutTokenEncrypted: true,
|
|
nightscoutAllowPrivateHost: true,
|
|
dawarichUrlEncrypted: true,
|
|
},
|
|
}),
|
|
prisma.withingsConnection.findUnique({
|
|
where: { userId: user.id },
|
|
select: {
|
|
tokenExpiresAt: true,
|
|
lastSyncedAt: true,
|
|
createdAt: true,
|
|
scope: true,
|
|
apiPlanState: true,
|
|
},
|
|
}),
|
|
prisma.whoopConnection.findUnique({
|
|
where: { userId: user.id },
|
|
select: {
|
|
tokenExpiresAt: true,
|
|
whoopUserId: true,
|
|
lastSyncedAt: true,
|
|
createdAt: true,
|
|
backfillCompletedAt: true,
|
|
},
|
|
}),
|
|
prisma.fitbitConnection.findUnique({
|
|
where: { userId: user.id },
|
|
select: {
|
|
tokenExpiresAt: true,
|
|
lastSyncedAt: true,
|
|
createdAt: true,
|
|
backfillCompletedAt: true,
|
|
},
|
|
}),
|
|
prisma.googleHealthConnection.findUnique({
|
|
where: { userId: user.id },
|
|
select: {
|
|
tokenExpiresAt: true,
|
|
lastSyncedAt: true,
|
|
createdAt: true,
|
|
backfillCompletedAt: true,
|
|
needsReauth: true,
|
|
},
|
|
}),
|
|
getPolarClientCredentials(user.id).then((c) => !!c),
|
|
getOuraClientCredentials(user.id).then((c) => !!c),
|
|
getStravaClientCredentials(user.id).then((c) => !!c),
|
|
]);
|
|
|
|
// The unified per-connection state. Resolved by the same loader the
|
|
// per-provider routes and the integration watchdog use, so the envelope and
|
|
// every other surface say the same thing about a connection.
|
|
const health = await loadConnectionHealth(user.id, [
|
|
"withings",
|
|
"whoop",
|
|
"fitbit",
|
|
"google-health",
|
|
"polar",
|
|
"oura",
|
|
"strava",
|
|
"nightscout",
|
|
"dawarich",
|
|
]);
|
|
|
|
// F-SYNC-1 — per-metric-type last-value timestamps so the card can show that a
|
|
// single metric type has gone silent even while the integration reads green
|
|
// ("connected · 5 min ago"). Fail-soft: this is an honesty signal, never worth
|
|
// 500-ing the whole Settings card, so a groupBy hiccup degrades to no data.
|
|
//
|
|
// The degradation is REPORTED rather than swallowed. Falling back to `{}`
|
|
// makes every entry's `metricFreshness` an empty array, which is exactly what
|
|
// a user with nothing recorded also sees — so "no metric has gone quiet" and
|
|
// "we could not tell whether a metric has gone quiet" were the same answer on
|
|
// the wire, and the honesty signal quietly became the thing it was built to
|
|
// prevent. `metricFreshnessDegraded` separates them. One flag rather than a
|
|
// nullable array per entry, because this is ONE query for all eight providers
|
|
// — it fails for all of them or for none.
|
|
let metricFreshnessDegraded = false;
|
|
const metricFreshness = await getSourceMetricFreshness(user.id).catch(() => {
|
|
metricFreshnessDegraded = true;
|
|
return {} as Partial<Record<IntegrationKey, MetricFreshnessSample[]>>;
|
|
});
|
|
|
|
const now = Date.now();
|
|
const whoopConnected = !!whoopConn?.whoopUserId;
|
|
|
|
/**
|
|
* The ledger fields the envelope publishes, named one by one.
|
|
*
|
|
* `failingSince` is deliberately not among them. It is the input to
|
|
* `syncHealth.since`, which every consumer already reads, and putting both on
|
|
* the wire would be two names for one fact — the second of which nothing
|
|
* renders.
|
|
*/
|
|
function publish(snapshot: IntegrationStatusSnapshot) {
|
|
return {
|
|
integration: snapshot.integration,
|
|
state: snapshot.state,
|
|
lastSuccessAt: snapshot.lastSuccessAt,
|
|
lastAttemptAt: snapshot.lastAttemptAt,
|
|
lastError: snapshot.lastError,
|
|
consecutiveFailuresByKind: snapshot.consecutiveFailuresByKind,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The sync-health verdict + the per-metric staleness flags, resolved from
|
|
* data already in hand — no additional query. Every entry carries it,
|
|
* whether or not a card exists for the provider: a card-level verdict misses
|
|
* a card-less provider by construction, which is how a month-dead sync went
|
|
* unnoticed.
|
|
*/
|
|
function resolve(
|
|
integration: IntegrationKey,
|
|
snapshot: {
|
|
state: string;
|
|
lastSuccessAt: string | null;
|
|
lastAttemptAt: string | null;
|
|
failingSince: string | null;
|
|
},
|
|
opts: {
|
|
connected?: boolean;
|
|
configured?: boolean;
|
|
legacyLastSyncedAt?: string | null;
|
|
},
|
|
): { syncHealth: SyncHealth; metricFreshness: MetricFreshnessEntry[] } {
|
|
const syncHealth = resolveSyncVerdict({
|
|
connected: opts.connected,
|
|
configured: opts.configured,
|
|
state: snapshot.state,
|
|
lastSuccessAt: snapshot.lastSuccessAt,
|
|
lastAttemptAt: snapshot.lastAttemptAt,
|
|
failingSinceAt: snapshot.failingSince,
|
|
legacyLastSyncedAt: opts.legacyLastSyncedAt ?? null,
|
|
cadence: INTEGRATION_CADENCE[integration],
|
|
now,
|
|
});
|
|
return {
|
|
syncHealth,
|
|
metricFreshness: classifyMetricFreshness(
|
|
metricFreshness[integration] ?? [],
|
|
syncHealth.verdict,
|
|
now,
|
|
),
|
|
};
|
|
}
|
|
|
|
const withingsConfigured =
|
|
!!dbUser?.withingsClientIdEncrypted &&
|
|
!!dbUser?.withingsClientSecretEncrypted;
|
|
const withingsLegacyLastSyncedAt =
|
|
withingsConn?.lastSyncedAt?.toISOString() ?? null;
|
|
const whoopConfigured =
|
|
!!dbUser?.whoopClientIdEncrypted && !!dbUser?.whoopClientSecretEncrypted;
|
|
const whoopLegacyLastSyncedAt = whoopConnected
|
|
? (whoopConn?.lastSyncedAt?.toISOString() ?? null)
|
|
: null;
|
|
const fitbitConnected = !!fitbitConn;
|
|
const fitbitConfigured =
|
|
!!dbUser?.fitbitClientIdEncrypted && !!dbUser?.fitbitClientSecretEncrypted;
|
|
const fitbitLegacyLastSyncedAt =
|
|
fitbitConn?.lastSyncedAt?.toISOString() ?? null;
|
|
const googleHealthConnected = !!googleHealthConn;
|
|
const googleHealthConfigured =
|
|
!!dbUser?.googleHealthClientIdEncrypted &&
|
|
!!dbUser?.googleHealthClientSecretEncrypted;
|
|
const googleHealthLegacyLastSyncedAt =
|
|
googleHealthConn?.lastSyncedAt?.toISOString() ?? null;
|
|
const polarConnected = !!dbUser?.polarAccessTokenEncrypted;
|
|
const ouraConnected = !!dbUser?.ouraAccessTokenEncrypted;
|
|
const stravaConnected = !!dbUser?.stravaAccessTokenEncrypted;
|
|
const nightscoutConfigured = !!dbUser?.nightscoutUrlEncrypted;
|
|
const dawarichConfigured = !!dbUser?.dawarichUrlEncrypted;
|
|
|
|
if (metricFreshnessDegraded) {
|
|
annotate({ meta: { integrations_metric_freshness_degraded: true } });
|
|
}
|
|
|
|
return apiSuccess({
|
|
threshold: getPersistentFailureThreshold(),
|
|
metricFreshnessDegraded,
|
|
integrations: [
|
|
{
|
|
...publish(withingsStatus),
|
|
configured: withingsConfigured,
|
|
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: withingsLegacyLastSyncedAt,
|
|
tokenExpiresAt: withingsConn?.tokenExpiresAt?.toISOString() ?? null,
|
|
tokenExpired: withingsConn
|
|
? withingsConn.tokenExpiresAt.getTime() <= now
|
|
: null,
|
|
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string;
|
|
// `hasActivityScope` is the derived flag the reconnect banner reads.
|
|
// Null `scope` = legacy connection that predates activity-scope reads.
|
|
scope: withingsConn?.scope ?? null,
|
|
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
|
|
// What this connection's Withings API plan withholds (status 277),
|
|
// resolved here so the card only renders it.
|
|
planUnavailable: withingsConn
|
|
? withingsPlanUnavailableFeatures(
|
|
parseWithingsApiPlanState(withingsConn.apiPlanState),
|
|
)
|
|
: [],
|
|
health: health["withings"],
|
|
...resolve("withings", withingsStatus, {
|
|
connected: !!withingsConn,
|
|
configured: withingsConfigured,
|
|
legacyLastSyncedAt: withingsLegacyLastSyncedAt,
|
|
}),
|
|
} satisfies IntegrationViewModel & WithingsExtras,
|
|
{
|
|
...publish(whoopStatus),
|
|
configured: whoopConfigured,
|
|
connected: whoopConnected,
|
|
connectedAt: whoopConnected
|
|
? (whoopConn?.createdAt.toISOString() ?? null)
|
|
: null,
|
|
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
|
|
tokenExpiresAt: whoopConnected
|
|
? (whoopConn?.tokenExpiresAt.toISOString() ?? null)
|
|
: null,
|
|
tokenExpired: whoopConnected
|
|
? (whoopConn?.tokenExpiresAt.getTime() ?? 0) <= now
|
|
: null,
|
|
backfillCompleted: whoopConnected
|
|
? !!whoopConn?.backfillCompletedAt
|
|
: null,
|
|
health: health["whoop"],
|
|
...resolve("whoop", whoopStatus, {
|
|
connected: whoopConnected,
|
|
configured: whoopConfigured,
|
|
legacyLastSyncedAt: whoopLegacyLastSyncedAt,
|
|
}),
|
|
} satisfies IntegrationViewModel & WhoopExtras,
|
|
{
|
|
...publish(fitbitStatus),
|
|
configured: fitbitConfigured,
|
|
connected: fitbitConnected,
|
|
connectedAt: fitbitConn?.createdAt?.toISOString() ?? null,
|
|
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
|
|
tokenExpiresAt: fitbitConn?.tokenExpiresAt?.toISOString() ?? null,
|
|
tokenExpired: fitbitConn
|
|
? fitbitConn.tokenExpiresAt.getTime() <= now
|
|
: null,
|
|
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
|
|
health: health["fitbit"],
|
|
...resolve("fitbit", fitbitStatus, {
|
|
connected: fitbitConnected,
|
|
configured: fitbitConfigured,
|
|
legacyLastSyncedAt: fitbitLegacyLastSyncedAt,
|
|
}),
|
|
} satisfies IntegrationViewModel & FitbitExtras,
|
|
{
|
|
...publish(googleHealthStatus),
|
|
configured: googleHealthConfigured,
|
|
connected: googleHealthConnected,
|
|
connectedAt: googleHealthConn?.createdAt?.toISOString() ?? null,
|
|
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
|
|
tokenExpiresAt: googleHealthConn?.tokenExpiresAt?.toISOString() ?? null,
|
|
tokenExpired: googleHealthConn
|
|
? googleHealthConn.tokenExpiresAt.getTime() <= now
|
|
: null,
|
|
backfillCompleted: googleHealthConn
|
|
? !!googleHealthConn.backfillCompletedAt
|
|
: null,
|
|
// v1.27.0 — `needsReauth` is the 7-day Testing-mode refresh expiry (or a
|
|
// user-revoked grant) surfaced from the connection row; the card reads it
|
|
// to raise a distinct "Reconnect" CTA separate from parked/disconnected.
|
|
needsReauth: googleHealthConn ? googleHealthConn.needsReauth : false,
|
|
health: health["google-health"],
|
|
...resolve("google-health", googleHealthStatus, {
|
|
connected: googleHealthConnected,
|
|
configured: googleHealthConfigured,
|
|
legacyLastSyncedAt: googleHealthLegacyLastSyncedAt,
|
|
}),
|
|
} satisfies IntegrationViewModel & GoogleHealthExtras,
|
|
{
|
|
...publish(polarStatus),
|
|
// `connected` = a stored access token; `configured` mirrors it (the
|
|
// OAuth card has no separate "credentials saved but disconnected" view
|
|
// beyond `hasOwnCredentials`). The card greys out the connect button
|
|
// when no usable credentials resolve (`available`).
|
|
connected: polarConnected,
|
|
configured: polarConnected,
|
|
available: polarAvailable,
|
|
hasOwnCredentials:
|
|
!!dbUser?.polarClientIdEncrypted &&
|
|
!!dbUser?.polarClientSecretEncrypted,
|
|
health: health["polar"],
|
|
...resolve("polar", polarStatus, {
|
|
connected: polarConnected,
|
|
configured: polarConnected,
|
|
}),
|
|
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
|
{
|
|
...publish(ouraStatus),
|
|
connected: ouraConnected,
|
|
configured: ouraConnected,
|
|
available: ouraAvailable,
|
|
hasOwnCredentials:
|
|
!!dbUser?.ouraClientIdEncrypted &&
|
|
!!dbUser?.ouraClientSecretEncrypted,
|
|
// Collections the stored grant does not cover; one reconnect asks for
|
|
// the missing scopes. Not a failure, so it never colours the pill.
|
|
scopeReconnect: ouraConnected
|
|
? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
|
|
: [],
|
|
health: health["oura"],
|
|
...resolve("oura", ouraStatus, {
|
|
connected: ouraConnected,
|
|
configured: ouraConnected,
|
|
}),
|
|
} satisfies IntegrationViewModel &
|
|
OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
|
|
{
|
|
...publish(stravaStatus),
|
|
connected: stravaConnected,
|
|
configured: stravaConnected,
|
|
available: stravaAvailable,
|
|
hasOwnCredentials:
|
|
!!dbUser?.stravaClientIdEncrypted &&
|
|
!!dbUser?.stravaClientSecretEncrypted,
|
|
// Strava writes Workout rows, not Measurements — its freshness list
|
|
// carries the `WORKOUTS` pseudo-entry alone.
|
|
health: health["strava"],
|
|
...resolve("strava", stravaStatus, {
|
|
connected: stravaConnected,
|
|
configured: stravaConnected,
|
|
}),
|
|
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
|
{
|
|
...publish(nightscoutStatus),
|
|
// Nightscout is a URL the self-hoster pastes once; `configured` (a
|
|
// stored URL) is the connect marker, and a fully-public instance has
|
|
// no token. The card reads these off the envelope now instead of its
|
|
// own status round-trip, so the whole page reads from one source.
|
|
connected: nightscoutConfigured,
|
|
configured: nightscoutConfigured,
|
|
hasToken: !!dbUser?.nightscoutTokenEncrypted,
|
|
allowPrivateHost: dbUser?.nightscoutAllowPrivateHost ?? false,
|
|
health: health["nightscout"],
|
|
...resolve("nightscout", nightscoutStatus, {
|
|
connected: nightscoutConfigured,
|
|
configured: nightscoutConfigured,
|
|
}),
|
|
} satisfies IntegrationViewModel & NightscoutExtras,
|
|
{
|
|
...publish(dawarichStatus),
|
|
// v1.43 — Dawarich, read by the card through `health`. The ledger core
|
|
// rides along like on every entry, and goes with the rest in v1.44.
|
|
connected: dawarichConfigured,
|
|
configured: dawarichConfigured,
|
|
health: health["dawarich"],
|
|
...resolve("dawarich", dawarichStatus, {
|
|
connected: dawarichConfigured,
|
|
configured: dawarichConfigured,
|
|
}),
|
|
} satisfies IntegrationViewModel & DawarichExtras,
|
|
],
|
|
});
|
|
});
|
|
|
|
interface IntegrationViewModel {
|
|
integration: IntegrationKey;
|
|
/**
|
|
* The one server-resolved state per connection (ok / limited / degraded /
|
|
* action_required), null when the provider is not connected. Every other
|
|
* health-shaped field on this entry (`state`, `lastError`,
|
|
* `consecutiveFailuresByKind`, `syncHealth`, `tokenExpired`, `needsReauth`,
|
|
* `hasActivityScope`, `planUnavailable`, `scopeReconnect`) is folded into
|
|
* it and goes once the settings cards render `health`; no other client
|
|
* reads this envelope.
|
|
*/
|
|
health: ConnectionHealth | null;
|
|
state: string;
|
|
lastSuccessAt: string | null;
|
|
lastAttemptAt: string | null;
|
|
lastError: string | null;
|
|
/**
|
|
* The server-resolved liveness verdict. Present on EVERY entry, whether or
|
|
* not a card renders it — the unit of coverage is the envelope entry, not
|
|
* the card.
|
|
*/
|
|
syncHealth: SyncHealth;
|
|
/**
|
|
* Per-metric-type last-value timestamps for the integration's synced data,
|
|
* each with the server-computed `stale` flag. Lets the card flag a silently
|
|
* dead metric the green integration state hides.
|
|
*/
|
|
metricFreshness: MetricFreshnessEntry[];
|
|
}
|
|
|
|
interface WithingsExtras {
|
|
configured: boolean;
|
|
connected: boolean;
|
|
connectedAt: string | null;
|
|
legacyLastSyncedAt: string | null;
|
|
tokenExpiresAt: string | null;
|
|
tokenExpired: boolean | null;
|
|
scope: string | null;
|
|
hasActivityScope: boolean;
|
|
planUnavailable: WithingsPlanFeature[];
|
|
}
|
|
|
|
interface WhoopExtras {
|
|
configured: boolean;
|
|
connected: boolean;
|
|
connectedAt: string | null;
|
|
legacyLastSyncedAt: string | null;
|
|
tokenExpiresAt: string | null;
|
|
tokenExpired: boolean | null;
|
|
backfillCompleted: boolean | null;
|
|
}
|
|
|
|
interface FitbitExtras {
|
|
configured: boolean;
|
|
connected: boolean;
|
|
connectedAt: string | null;
|
|
legacyLastSyncedAt: string | null;
|
|
tokenExpiresAt: string | null;
|
|
tokenExpired: boolean | null;
|
|
backfillCompleted: boolean | null;
|
|
}
|
|
|
|
// v1.27.0 — Google Health mirrors the Fitbit shape plus `needsReauth`: Google
|
|
// expires the refresh token after 7 days in "Testing" publishing mode, so a
|
|
// connected user is periodically pushed back through OAuth. The card reads the
|
|
// flag to raise a distinct reconnect banner.
|
|
interface GoogleHealthExtras {
|
|
configured: boolean;
|
|
connected: boolean;
|
|
connectedAt: string | null;
|
|
legacyLastSyncedAt: string | null;
|
|
tokenExpiresAt: string | null;
|
|
tokenExpired: boolean | null;
|
|
backfillCompleted: boolean | null;
|
|
needsReauth: boolean;
|
|
}
|
|
|
|
// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
|
|
// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
|
|
// /api/<provider>/status round-trip the page used to fire per card.
|
|
interface OAuthProviderExtras {
|
|
connected: boolean;
|
|
configured: boolean;
|
|
available: boolean;
|
|
hasOwnCredentials: boolean;
|
|
}
|
|
|
|
// Nightscout folds onto the envelope so the card stops firing its own status
|
|
// round-trip and so the provider inherits the shared verdict. `hasToken` and
|
|
// `allowPrivateHost` are the two fields the card's form placeholders read.
|
|
interface NightscoutExtras {
|
|
connected: boolean;
|
|
configured: boolean;
|
|
hasToken: boolean;
|
|
allowPrivateHost: boolean;
|
|
}
|
|
|
|
// v1.43 — Dawarich: the connect marker only; the card reads `health` and its
|
|
// own status route.
|
|
interface DawarichExtras {
|
|
connected: boolean;
|
|
configured: boolean;
|
|
}
|