Merge branch 'v1424/cards' into release/v1.42.4

This commit is contained in:
Marc-André Bombeck committed 2026-10-15 15:22:12 +02:00
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({
+18 -9
View File
@@ -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 },
+1 -1
View File
@@ -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)
+8 -2
View File
@@ -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": {