mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge branch 'v1424/cards' into release/v1.42.4
This commit is contained in:
commit
f95c2bfd41
60 files changed
+5248
-5087
No files matched your search
@@ -47,7 +47,8 @@ const openRow = (overrides: Partial<WatchRow> = {}): WatchRow => ({
|
||||
});
|
||||
|
||||
describe("classifySilence", () => {
|
||||
const oura = SOURCE_PROFILES.oura;
|
||||
// A wearable whose cloud receives data on its own: the 24 h window.
|
||||
const wearable = SOURCE_PROFILES.fitbit;
|
||||
const withings = SOURCE_PROFILES.withings;
|
||||
|
||||
it("never flags an on-demand source for silence, however long and however regular", () => {
|
||||
@@ -67,7 +68,7 @@ describe("classifySilence", () => {
|
||||
it("is clear while a continuous source delivers inside its window", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(20),
|
||||
deliveredDays: null,
|
||||
currentSilentSince: null,
|
||||
@@ -79,7 +80,7 @@ describe("classifySilence", () => {
|
||||
it("asks for the history before flagging (the cheap hourly pass reads it lazily)", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(30),
|
||||
deliveredDays: null,
|
||||
currentSilentSince: null,
|
||||
@@ -91,7 +92,7 @@ describe("classifySilence", () => {
|
||||
it("flags a silent source that delivered on most of the previous 14 days", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(30),
|
||||
deliveredDays: NORMAL_DELIVERY_MIN_DAYS,
|
||||
currentSilentSince: null,
|
||||
@@ -103,7 +104,7 @@ describe("classifySilence", () => {
|
||||
it("does not flag a source its owner wears only now and then", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(30),
|
||||
deliveredDays: NORMAL_DELIVERY_MIN_DAYS - 1,
|
||||
currentSilentSince: null,
|
||||
@@ -115,7 +116,7 @@ describe("classifySilence", () => {
|
||||
it("keeps a standing flag on the hourly pass without re-reading history", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(50),
|
||||
deliveredDays: null,
|
||||
currentSilentSince: ago(50),
|
||||
@@ -127,7 +128,7 @@ describe("classifySilence", () => {
|
||||
it("drops a standing flag on the daily re-check once the history no longer says it delivers", () => {
|
||||
expect(
|
||||
classifySilence({
|
||||
profile: oura,
|
||||
profile: wearable,
|
||||
lastDataAt: ago(24 * 6),
|
||||
deliveredDays: 8,
|
||||
currentSilentSince: ago(24 * 6),
|
||||
@@ -136,7 +137,55 @@ describe("classifySilence", () => {
|
||||
).toEqual({ kind: "not_expected" });
|
||||
});
|
||||
|
||||
it("waits 48 h for Oura and WHOOP, which upload only when their phone app is opened", () => {
|
||||
for (const key of ["oura", "whoop"] as const) {
|
||||
const profile = SOURCE_PROFILES[key];
|
||||
expect(profile.kind).toBe("continuous");
|
||||
// A day and a half without opening the app is ordinary.
|
||||
expect(
|
||||
classifySilence({
|
||||
profile,
|
||||
lastDataAt: ago(36),
|
||||
deliveredDays: 14,
|
||||
currentSilentSince: null,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ kind: "clear" });
|
||||
// Past two days it is not.
|
||||
expect(
|
||||
classifySilence({
|
||||
profile,
|
||||
lastDataAt: ago(49),
|
||||
deliveredDays: 14,
|
||||
currentSilentSince: null,
|
||||
now: NOW,
|
||||
}),
|
||||
).toEqual({ kind: "flag", silentSince: ago(49) });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the 24 h window for wearables whose cloud receives data on its own", () => {
|
||||
for (const key of [
|
||||
"fitbit",
|
||||
"google-health",
|
||||
"polar",
|
||||
"nightscout",
|
||||
] as const) {
|
||||
expect(SOURCE_PROFILES[key]).toEqual({
|
||||
kind: "continuous",
|
||||
silenceWindowMs: 24 * HOUR,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("never alerts on silence for scales, cuffs and workout logs", () => {
|
||||
for (const key of ["withings", "strava"] as const) {
|
||||
expect(SOURCE_PROFILES[key].kind).toBe("on_demand");
|
||||
}
|
||||
});
|
||||
|
||||
it("gives Apple Health a wider window than a wearable", () => {
|
||||
expect(SOURCE_PROFILES["apple-health"].silenceWindowMs).toBe(48 * HOUR);
|
||||
const apple = SOURCE_PROFILES["apple-health"];
|
||||
expect(
|
||||
classifySilence({
|
||||
|
||||
@@ -50,20 +50,29 @@ export const INCIDENT_AFTER_MS = DAY_MS;
|
||||
|
||||
/**
|
||||
* Per connection. Withings is mostly scales and cuffs, Strava is workouts:
|
||||
* both are on-demand. Everything worn or attached around the clock is
|
||||
* continuous.
|
||||
* both are on-demand and never alert on silence, only on errors. Everything
|
||||
* worn or attached around the clock is continuous.
|
||||
*
|
||||
* Apple Health gets 48 h instead of 24 h. iOS defers background delivery when
|
||||
* the phone is low on storage, in low-power mode or simply not unlocked, and a
|
||||
* day of that is ordinary; the history gate already limits the check to
|
||||
* phones that deliver almost daily, and the longer window keeps an ordinary
|
||||
* quiet day from paging anyone.
|
||||
* The window is how long a continuous source may stay quiet before silence
|
||||
* counts, and it follows how each source actually delivers:
|
||||
*
|
||||
* - 24 h for wearables and CGMs whose cloud receives data on its own
|
||||
* (Fitbit, Google Health, Polar, Nightscout).
|
||||
* - 48 h for Oura and WHOOP. Their rings and straps upload only when the
|
||||
* phone app is opened, so a day without opening it is ordinary and must
|
||||
* not page anyone.
|
||||
* - 48 h for Apple Health. iOS defers background delivery when the phone is
|
||||
* low on storage, in low-power mode or simply not unlocked, and a day of
|
||||
* that is ordinary too.
|
||||
*
|
||||
* The history gate already limits the check to sources that deliver almost
|
||||
* daily; the window only decides how long a gap is ordinary.
|
||||
*/
|
||||
export const SOURCE_PROFILES: Record<ConnectionKey, SourceProfile> = {
|
||||
withings: { kind: "on_demand", silenceWindowMs: DAY_MS },
|
||||
strava: { kind: "on_demand", silenceWindowMs: DAY_MS },
|
||||
whoop: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
oura: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
whoop: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
oura: { kind: "continuous", silenceWindowMs: 2 * DAY_MS },
|
||||
fitbit: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
"google-health": { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
polar: { kind: "continuous", silenceWindowMs: DAY_MS },
|
||||
|
||||
@@ -105,7 +105,7 @@ const syncHealth = z
|
||||
|
||||
// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`:
|
||||
// the one server-resolved state per connection every client renders.
|
||||
const connectionHealth = z
|
||||
export const connectionHealth = z
|
||||
.object({
|
||||
state: z
|
||||
.enum(CONNECTION_HEALTH_STATES)
|
||||
|
||||
@@ -25,6 +25,7 @@ import { z } from "zod/v4";
|
||||
|
||||
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
|
||||
import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings";
|
||||
import { connectionHealth } from "./integrations";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z
|
||||
]),
|
||||
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
lastAttemptAt: z.iso.datetime({ offset: true }).nullable(),
|
||||
health: connectionHealth
|
||||
.nullable()
|
||||
.describe(
|
||||
"The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
@@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z
|
||||
.meta({
|
||||
id: "RecordSettingsIntegrationsResponse",
|
||||
description:
|
||||
"Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
|
||||
"Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
|
||||
});
|
||||
|
||||
// ── The six family DTOs ──────────────────────────────────────────────
|
||||
@@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Account sharing"],
|
||||
summary: "Read the managed record's integration connection states",
|
||||
description:
|
||||
"Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
|
||||
"Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
|
||||
Reference in new issue
Block a user