mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Weight, blood pressure and blood glucose are written back to the phone while their entry is bidirectional. A reading entered here goes to Apple Health and never arrives back, so a person who keeps those readings here saw the type go quiet after two weeks. The Apple Health status read now adds `lastEnteredHereAt` to each written-back type, the newest reading from any source other than Apple Health, and `stale` counts it; the card shows it as "Last entered here". The value is resolved on the server so a client renders it as sent.
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
/**
|
|
* Server-side sync-health verdict — the one place liveness is decided.
|
|
*
|
|
* The ledger (`src/lib/integrations/status.ts`) records what the last sync
|
|
* attempt DID. It has no way to express what an attempt is no longer doing:
|
|
* `error_transient` documents itself as "the sync entry-point still attempts on
|
|
* the next run", yet a row can sit at that value for a month while nothing runs
|
|
* at all — and nothing anywhere read the AGE of `lastAttemptAt`, so the state
|
|
* was unrepresentable. A provider that had stopped even trying looked exactly
|
|
* like one that was retrying every hour.
|
|
*
|
|
* This module derives the missing verdict as a read-side projection. It writes
|
|
* nothing, adds no cron, and cannot desynchronise from the row it reads. The
|
|
* status envelope computes it per entry from data it already has in hand (zero
|
|
* additional queries) and every consumer — the Settings cards, the fallback
|
|
* row, the MCP status tool — renders the same answer.
|
|
*
|
|
* The module is deliberately pure: no Prisma import, no I/O, `import type` only.
|
|
* That keeps it importable from client components (the pill mapper reads the
|
|
* verdict union) without dragging the database client into the browser bundle.
|
|
*/
|
|
import type { IntegrationKey } from "./status";
|
|
|
|
/**
|
|
* The eight verdicts, from healthiest to least.
|
|
*
|
|
* fresh → syncing, data is current.
|
|
* stale → syncing (or push-fed) but no new data for a while.
|
|
* The primary arm for push-based Apple Health.
|
|
* stalled → no sync ATTEMPT for longer than the cadence allows.
|
|
* Not "failing" — not being tried at all.
|
|
* failing → attempting and erroring (transient or persistent).
|
|
* reauth_required → the grant is gone; the user must reconnect.
|
|
* parked → paused after a persistent failure streak; needs a
|
|
* manual resume.
|
|
* pending_first_sync → connected/configured but no attempt has been made yet.
|
|
* disconnected → no live connection.
|
|
*/
|
|
export type SyncVerdict =
|
|
| "fresh"
|
|
| "stale"
|
|
| "stalled"
|
|
| "failing"
|
|
| "reauth_required"
|
|
| "parked"
|
|
| "pending_first_sync"
|
|
| "disconnected";
|
|
|
|
/** The verdict plus the instant that triggered it (null when nothing did). */
|
|
export interface SyncHealth {
|
|
verdict: SyncVerdict;
|
|
since: string | null;
|
|
}
|
|
|
|
/**
|
|
* How long a polled integration may go without a sync ATTEMPT before it counts
|
|
* as stalled. 24 h is roughly 24 missed hourly ticks — unambiguous, and
|
|
* tolerant of a maintenance window or a slow redeploy.
|
|
*/
|
|
export const ATTEMPT_STALE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* How long a polled integration may go without new DATA before it counts as
|
|
* stale. Near-unreachable for the hourly cohort by construction (every tick
|
|
* either stamps a success or flips the row to an error state), so it stands as
|
|
* defence rather than as the primary signal.
|
|
*/
|
|
export const DATA_STALE_AFTER_MS = 48 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* Apple Health is push-based: the iOS app delivers, nothing here polls. Seven
|
|
* days is the "this pipe is not delivering" line. Shorter windows false-alarm
|
|
* on normal iOS background-delivery gaps (low storage, app unopened, budget
|
|
* exhausted), and a wrong warning on the flagship integration costs more trust
|
|
* than a few days of latency on a genuinely dead one.
|
|
*/
|
|
export const APPLE_HEALTH_DATA_STALE_AFTER_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
/**
|
|
* A metric type that has not been seen for this long, while the integration
|
|
* around it reads healthy, is the dead-pipe signature. Deliberately generous:
|
|
* a weekly weigher must not be warned after a holiday.
|
|
*/
|
|
export const METRIC_QUIET_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
|
/** Per-cohort expectation. `attemptStaleAfterMs: null` = no attempt concept. */
|
|
export interface SyncCadence {
|
|
attemptStaleAfterMs: number | null;
|
|
dataStaleAfterMs: number;
|
|
}
|
|
|
|
/** Hourly cron cohort — every integration with a ledger row. */
|
|
export const POLLED_CADENCE: SyncCadence = {
|
|
attemptStaleAfterMs: ATTEMPT_STALE_AFTER_MS,
|
|
dataStaleAfterMs: DATA_STALE_AFTER_MS,
|
|
};
|
|
|
|
/** Apple Health — the iOS app pushes; there is nothing to "attempt". */
|
|
export const PUSH_CADENCE: SyncCadence = {
|
|
attemptStaleAfterMs: null,
|
|
dataStaleAfterMs: APPLE_HEALTH_DATA_STALE_AFTER_MS,
|
|
};
|
|
|
|
/**
|
|
* Dawarich runs once a night (`dawarich-sync`, 03:20). A run is overdue after
|
|
* two days, and a successful run is the freshness: a phone that recorded
|
|
* nothing is not a failing connection.
|
|
*/
|
|
const NIGHTLY_CADENCE: SyncCadence = {
|
|
attemptStaleAfterMs: 2 * ATTEMPT_STALE_AFTER_MS,
|
|
dataStaleAfterMs: DATA_STALE_AFTER_MS,
|
|
};
|
|
|
|
/**
|
|
* Every `IntegrationKey` but Dawarich is polled by an hourly cron registered
|
|
* in `src/lib/jobs/reminder/register-integration-sync.ts`; Dawarich nightly.
|
|
* Apple Health is not an `IntegrationKey` — it takes `PUSH_CADENCE` directly.
|
|
*/
|
|
export const INTEGRATION_CADENCE: Record<IntegrationKey, SyncCadence> = {
|
|
withings: POLLED_CADENCE,
|
|
whoop: POLLED_CADENCE,
|
|
fitbit: POLLED_CADENCE,
|
|
nightscout: POLLED_CADENCE,
|
|
polar: POLLED_CADENCE,
|
|
oura: POLLED_CADENCE,
|
|
"google-health": POLLED_CADENCE,
|
|
strava: POLLED_CADENCE,
|
|
dawarich: NIGHTLY_CADENCE,
|
|
};
|
|
|
|
export interface SyncVerdictInput {
|
|
/**
|
|
* Whether a live connection exists (an OAuth token, a connection row). Pass
|
|
* `undefined` for providers that carry no connection concept, so the ladder
|
|
* falls through to `configured`.
|
|
*/
|
|
connected?: boolean;
|
|
/** Whether credentials / a URL are stored for this provider. */
|
|
configured?: boolean;
|
|
/** The ledger `state` string. */
|
|
state?: string | null;
|
|
lastSuccessAt?: string | Date | null;
|
|
lastAttemptAt?: string | Date | null;
|
|
/** A connection row's own `lastSyncedAt`, for rows predating the ledger. */
|
|
legacyLastSyncedAt?: string | Date | null;
|
|
/**
|
|
* Start of the current unbroken failure streak, from the ledger. This is what
|
|
* the error verdicts date themselves by. `lastAttemptAt` is the wrong anchor
|
|
* — it is refreshed every hour by the retry itself, so a connection dead for
|
|
* a fortnight reported "failing, just now". `lastSuccessAt` is nearly right
|
|
* but a multi-leg provider can advance it while one leg stays broken.
|
|
*/
|
|
failingSinceAt?: string | Date | null;
|
|
/** Newest observed data instant, when the caller knows it (Apple Health). */
|
|
lastDataAt?: string | Date | null;
|
|
cadence: SyncCadence;
|
|
now?: Date | number;
|
|
}
|
|
|
|
function toMillis(value: string | Date | null | undefined): number | null {
|
|
if (value == null) return null;
|
|
const ms = value instanceof Date ? value.getTime() : Date.parse(value);
|
|
return Number.isFinite(ms) ? ms : null;
|
|
}
|
|
|
|
/** Newest of the supplied instants, or null when they are all absent. */
|
|
function newest(...values: Array<number | null>): number | null {
|
|
let max: number | null = null;
|
|
for (const value of values) {
|
|
if (value == null) continue;
|
|
if (max == null || value > max) max = value;
|
|
}
|
|
return max;
|
|
}
|
|
|
|
function iso(ms: number | null): string | null {
|
|
return ms == null ? null : new Date(ms).toISOString();
|
|
}
|
|
|
|
/**
|
|
* Resolve the sync-health verdict. First match wins.
|
|
*
|
|
* The `stalled` arm deliberately outranks `failing`: a row frozen at
|
|
* `error_transient` for a month is not failing, it is not being tried — the
|
|
* exact contradiction of the state's own documented contract.
|
|
*
|
|
* Two arms are refinements over the pure-ledger ladder, and they preserve what
|
|
* the cards already rendered: an explicit `connected === false` and an explicit
|
|
* `state === "disconnected"` both short-circuit to `disconnected`. Without them
|
|
* a provider whose credentials are still stored after a disconnect would age
|
|
* into `stalled` and claim its sync had stopped, when the user stopped it.
|
|
*/
|
|
export function resolveSyncVerdict(input: SyncVerdictInput): SyncHealth {
|
|
const nowMs =
|
|
input.now == null
|
|
? Date.now()
|
|
: input.now instanceof Date
|
|
? input.now.getTime()
|
|
: input.now;
|
|
|
|
const lastSuccess = toMillis(input.lastSuccessAt);
|
|
const lastAttempt = toMillis(input.lastAttemptAt);
|
|
const legacy = toMillis(input.legacyLastSyncedAt);
|
|
const lastData = toMillis(input.lastDataAt);
|
|
const failingSince = toMillis(input.failingSinceAt);
|
|
|
|
// 1. No live connection, or one the user explicitly ended.
|
|
if (input.connected === false)
|
|
return { verdict: "disconnected", since: null };
|
|
if (!input.connected && !input.configured) {
|
|
return { verdict: "disconnected", since: null };
|
|
}
|
|
if (input.state === "disconnected") {
|
|
return { verdict: "disconnected", since: null };
|
|
}
|
|
|
|
// 2. States the user has to act on, in order of specificity.
|
|
if (input.state === "parked") {
|
|
return { verdict: "parked", since: iso(failingSince ?? lastAttempt) };
|
|
}
|
|
if (input.state === "error_reauth") {
|
|
return {
|
|
verdict: "reauth_required",
|
|
since: iso(failingSince ?? lastAttempt),
|
|
};
|
|
}
|
|
|
|
// 3. Nothing has ever run and nothing has ever arrived — the honest reading
|
|
// of the "connected, never attempted" synthetic ledger snapshot, and of a
|
|
// push source that has yet to deliver its first sample.
|
|
if (newest(lastAttempt, lastSuccess, legacy, lastData) == null) {
|
|
return { verdict: "pending_first_sync", since: null };
|
|
}
|
|
|
|
// 4. Polled cohort only: has it even tried lately?
|
|
const attemptWindow = input.cadence.attemptStaleAfterMs;
|
|
if (attemptWindow != null) {
|
|
const lastTouch = newest(lastAttempt, lastSuccess);
|
|
if (lastTouch != null && nowMs - lastTouch > attemptWindow) {
|
|
return { verdict: "stalled", since: iso(lastTouch) };
|
|
}
|
|
}
|
|
|
|
// 5. Trying and erroring. `since` dates the streak, not the last retry: this
|
|
// is the field the card renders, and "failing since a minute ago" on a
|
|
// connection that last delivered a fortnight back is the sentence this
|
|
// whole path exists to stop.
|
|
if (input.state === "error_transient") {
|
|
return { verdict: "failing", since: iso(failingSince ?? lastSuccess) };
|
|
}
|
|
|
|
// 6. Running, but nothing new is arriving. The primary arm for push sources.
|
|
const lastFresh = newest(lastSuccess, legacy, lastData);
|
|
if (lastFresh != null && nowMs - lastFresh > input.cadence.dataStaleAfterMs) {
|
|
return { verdict: "stale", since: iso(lastFresh) };
|
|
}
|
|
|
|
return { verdict: "fresh", since: null };
|
|
}
|
|
|
|
/**
|
|
* Metric types that are irregular by nature. A fixed quiet-window would
|
|
* false-positive on every one of them, so they render their honest timestamp
|
|
* and never tint. Starting list, tunable — the fuller per-type cadence model
|
|
* stays the deferred follow-up.
|
|
*/
|
|
export const SPARSE_METRIC_TYPES: ReadonlySet<string> = new Set([
|
|
// Measured only when the user steps on a body-composition scale that
|
|
// supports them, or during an occasional fitness test.
|
|
"VO2_MAX",
|
|
"VASCULAR_AGE",
|
|
"PULSE_WAVE_VELOCITY",
|
|
"SIX_MINUTE_WALK_DISTANCE",
|
|
"FALL_COUNT",
|
|
// Event types: fired by the device only when something happens. Silence is
|
|
// the healthy reading.
|
|
"IRREGULAR_RHYTHM_NOTIFICATION",
|
|
"HIGH_HEART_RATE_EVENT",
|
|
"LOW_HEART_RATE_EVENT",
|
|
"WALKING_STEADINESS_EVENT",
|
|
"BREATHING_DISTURBANCE_EVENT",
|
|
"AUDIO_EXPOSURE_EVENT",
|
|
// People train irregularly; a quiet training week is not a broken pipe.
|
|
"WORKOUTS",
|
|
// As-needed and paused medications leave weeks without a dose by design.
|
|
"MEDICATION_DOSE",
|
|
]);
|
|
|
|
/** The pseudo-type carrying a source's newest workout, next to its metrics. */
|
|
export const WORKOUT_FRESHNESS_TYPE = "WORKOUTS";
|
|
|
|
/**
|
|
* The pseudo-type carrying Apple Health medication doses on the Apple Health
|
|
* freshness list (v1.42.4), and their key in the per-type arrival ledger.
|
|
*/
|
|
export const MEDICATION_DOSE_FRESHNESS_TYPE = "MEDICATION_DOSE";
|
|
|
|
/** Newest recorded reading for one `(source, type)` pair, as queried. */
|
|
export interface MetricFreshnessSample {
|
|
/**
|
|
* The `MeasurementType` (e.g. "RESPIRATORY_RATE"), `WORKOUTS`, or
|
|
* `MEDICATION_DOSE` on the Apple Health list.
|
|
*/
|
|
type: string;
|
|
/** ISO timestamp of the newest (non-deleted) reading for this metric. */
|
|
lastSeenAt: string;
|
|
}
|
|
|
|
/** A sample plus the server's staleness verdict for it. */
|
|
export interface MetricFreshnessEntry extends MetricFreshnessSample {
|
|
/**
|
|
* The metric has gone quiet while the integration around it reads healthy.
|
|
* Only ever true when the provider verdict is `fresh` — see
|
|
* `classifyMetricFreshness`.
|
|
*/
|
|
stale: boolean;
|
|
/**
|
|
* Apple Health types the app writes back to the phone only (#1247): the
|
|
* newest reading entered here rather than arriving from Apple Health, or
|
|
* null when there is none. Absent on every other entry.
|
|
*/
|
|
lastEnteredHereAt?: string | null;
|
|
}
|
|
|
|
/**
|
|
* Attach the per-metric staleness flag.
|
|
*
|
|
* A metric silent for weeks WHILE the pipe is otherwise green is the dead-pipe
|
|
* signature: a per-collection 403, an upstream envelope drift, a revoked
|
|
* per-type permission. When the provider itself is stalled / failing / stale
|
|
* every metric ages together, so tinting them would only restate what the pill
|
|
* already says — the entries stay neutral and the provider verdict carries the
|
|
* story.
|
|
*/
|
|
export function classifyMetricFreshness(
|
|
samples: readonly MetricFreshnessSample[],
|
|
verdict: SyncVerdict,
|
|
now: Date | number = Date.now(),
|
|
enteredHere?: ReadonlyMap<string, string | null>,
|
|
): MetricFreshnessEntry[] {
|
|
const nowMs = now instanceof Date ? now.getTime() : now;
|
|
return samples.map((sample) => {
|
|
// A type the app writes back to the phone (#1247): what is entered here
|
|
// goes to Apple Health and never arrives back, so a person who keeps
|
|
// their readings here would otherwise watch the type go quiet. The newer
|
|
// of the two is what says whether the type is still in use.
|
|
const written = enteredHere?.has(sample.type)
|
|
? { lastEnteredHereAt: enteredHere.get(sample.type) ?? null }
|
|
: {};
|
|
if (verdict !== "fresh" || SPARSE_METRIC_TYPES.has(sample.type)) {
|
|
return { ...sample, ...written, stale: false };
|
|
}
|
|
const seen = newest(
|
|
toMillis(sample.lastSeenAt),
|
|
toMillis(written.lastEnteredHereAt),
|
|
);
|
|
return {
|
|
...sample,
|
|
...written,
|
|
stale: seen != null && nowMs - seen > METRIC_QUIET_AFTER_MS,
|
|
};
|
|
});
|
|
}
|