diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 1754488a8..a6c71eef8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -75326,6 +75326,36 @@ components: hasActivityScope: description: Withings only. type: boolean + planUnavailable: + description: Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 + plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these + and keeps importing everything else; the list refreshes when the weekly re-probe asks again. + type: array + items: + type: string + enum: + - spo2 + - vascularAge + - pulseWaveVelocity + - vo2Max + - visceralFat + - skinTemperature + - ecg + - sleepScore + - sleepHrv + - sleepHeartRate + - sleepRespiratoryRate + - sleepSpo2 + - bodyTemperature + - bodyFat + - fatMass + - fatFreeMass + - muscleMass + - bodyWater + - boneMass + - heartRate + - bloodPressure + - weight needsReauth: description: Google Health only. type: boolean @@ -75335,6 +75365,18 @@ components: hasOwnCredentials: description: Oura / Polar / Strava only. type: boolean + scopeReconnect: + description: "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections + succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect + asks for the missing scopes. Empty when the grant covers everything HealthLog reads." + type: array + items: + type: string + enum: + - spo2 + - vo2max + - cardio_age + - resilience hasToken: description: Nightscout only. type: boolean diff --git a/messages/de.json b/messages/de.json index 118df8494..001763d15 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6505,6 +6505,47 @@ "body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.", "action": "Withings neu verbinden" } + }, + "plan": { + "title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings", + "body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.", + "link": "Mehr zu den API-Plänen von Withings", + "items": { + "spo2": "SpO2", + "vascularAge": "Gefäßalter", + "pulseWaveVelocity": "Pulswellengeschwindigkeit", + "vo2Max": "VO2max", + "visceralFat": "Viszeralfett", + "skinTemperature": "Hauttemperatur", + "ecg": "EKG und Vorhofflimmern", + "sleepScore": "Schlaf-Score", + "sleepHrv": "nächtliche HRV", + "sleepHeartRate": "nächtliche Herzfrequenz", + "sleepRespiratoryRate": "nächtliche Atemfrequenz", + "sleepSpo2": "nächtliche SpO2", + "bodyTemperature": "Körpertemperatur", + "bodyFat": "Körperfett", + "fatMass": "Fettmasse", + "fatFreeMass": "fettfreie Masse", + "muscleMass": "Muskelmasse", + "bodyWater": "Körperwasser", + "boneMass": "Knochenmasse", + "heartRate": "Herzfrequenz", + "bloodPressure": "Blutdruck", + "weight": "Gewicht" + } + } + }, + "oura": { + "scope": { + "body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.", + "action": "Oura neu verbinden", + "items": { + "spo2": "SpO2", + "vo2max": "VO2max", + "cardio_age": "kardiovaskuläres Alter", + "resilience": "Resilienz" + } } } }, diff --git a/messages/en.json b/messages/en.json index 7191e99c1..c70292f31 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6505,6 +6505,47 @@ "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", "action": "Reconnect Withings" } + }, + "plan": { + "title": "Some readings need Withings' paid API plan", + "body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.", + "link": "About the Withings API plans", + "items": { + "spo2": "SpO2", + "vascularAge": "vascular age", + "pulseWaveVelocity": "pulse wave velocity", + "vo2Max": "VO2 max", + "visceralFat": "visceral fat", + "skinTemperature": "skin temperature", + "ecg": "ECG and AFib", + "sleepScore": "sleep score", + "sleepHrv": "overnight HRV", + "sleepHeartRate": "overnight heart rate", + "sleepRespiratoryRate": "overnight breathing rate", + "sleepSpo2": "overnight SpO2", + "bodyTemperature": "body temperature", + "bodyFat": "body fat", + "fatMass": "fat mass", + "fatFreeMass": "fat-free mass", + "muscleMass": "muscle mass", + "bodyWater": "body water", + "boneMass": "bone mass", + "heartRate": "heart rate", + "bloodPressure": "blood pressure", + "weight": "weight" + } + } + }, + "oura": { + "scope": { + "body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.", + "action": "Reconnect Oura", + "items": { + "spo2": "SpO2", + "vo2max": "VO2 max", + "cardio_age": "cardiovascular age", + "resilience": "resilience" + } } } }, diff --git a/messages/es.json b/messages/es.json index 7ce217112..a7b2b87ed 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6505,6 +6505,47 @@ "body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.", "action": "Reconectar Withings" } + }, + "plan": { + "title": "Algunos valores requieren el plan de API de pago de Withings", + "body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.", + "link": "Sobre los planes de API de Withings", + "items": { + "spo2": "SpO2", + "vascularAge": "edad vascular", + "pulseWaveVelocity": "velocidad de onda de pulso", + "vo2Max": "VO2 máx.", + "visceralFat": "grasa visceral", + "skinTemperature": "temperatura de la piel", + "ecg": "ECG y fibrilación auricular", + "sleepScore": "puntuación de sueño", + "sleepHrv": "VFC nocturna", + "sleepHeartRate": "frecuencia cardíaca nocturna", + "sleepRespiratoryRate": "frecuencia respiratoria nocturna", + "sleepSpo2": "SpO2 nocturna", + "bodyTemperature": "temperatura corporal", + "bodyFat": "grasa corporal", + "fatMass": "masa grasa", + "fatFreeMass": "masa libre de grasa", + "muscleMass": "masa muscular", + "bodyWater": "agua corporal", + "boneMass": "masa ósea", + "heartRate": "frecuencia cardíaca", + "bloodPressure": "presión arterial", + "weight": "peso" + } + } + }, + "oura": { + "scope": { + "body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.", + "action": "Volver a conectar Oura", + "items": { + "spo2": "SpO2", + "vo2max": "VO2 máx.", + "cardio_age": "edad cardiovascular", + "resilience": "resiliencia" + } } } }, diff --git a/messages/fr.json b/messages/fr.json index 5181f03ba..b47dc29fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6505,6 +6505,47 @@ "body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.", "action": "Reconnecter Withings" } + }, + "plan": { + "title": "Certaines mesures nécessitent l’offre API payante de Withings", + "body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.", + "link": "En savoir plus sur les offres API de Withings", + "items": { + "spo2": "la SpO2", + "vascularAge": "l’âge vasculaire", + "pulseWaveVelocity": "la vitesse de l’onde de pouls", + "vo2Max": "la VO2 max", + "visceralFat": "la graisse viscérale", + "skinTemperature": "la température cutanée", + "ecg": "l’ECG et la fibrillation auriculaire", + "sleepScore": "le score de sommeil", + "sleepHrv": "la VFC nocturne", + "sleepHeartRate": "la fréquence cardiaque nocturne", + "sleepRespiratoryRate": "la fréquence respiratoire nocturne", + "sleepSpo2": "la SpO2 nocturne", + "bodyTemperature": "la température corporelle", + "bodyFat": "le taux de graisse corporelle", + "fatMass": "la masse grasse", + "fatFreeMass": "la masse maigre", + "muscleMass": "la masse musculaire", + "bodyWater": "l’eau corporelle", + "boneMass": "la masse osseuse", + "heartRate": "la fréquence cardiaque", + "bloodPressure": "la tension artérielle", + "weight": "le poids" + } + } + }, + "oura": { + "scope": { + "body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.", + "action": "Reconnecter Oura", + "items": { + "spo2": "la SpO2", + "vo2max": "la VO2 max", + "cardio_age": "l’âge cardiovasculaire", + "resilience": "la résilience" + } } } }, diff --git a/messages/it.json b/messages/it.json index 102b09984..e7231fe3a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6505,6 +6505,47 @@ "body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.", "action": "Riconnetti Withings" } + }, + "plan": { + "title": "Alcuni valori richiedono il piano API a pagamento di Withings", + "body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.", + "link": "Informazioni sui piani API di Withings", + "items": { + "spo2": "SpO2", + "vascularAge": "età vascolare", + "pulseWaveVelocity": "velocità dell’onda di polso", + "vo2Max": "VO2 max", + "visceralFat": "grasso viscerale", + "skinTemperature": "temperatura cutanea", + "ecg": "ECG e fibrillazione atriale", + "sleepScore": "punteggio del sonno", + "sleepHrv": "HRV notturna", + "sleepHeartRate": "frequenza cardiaca notturna", + "sleepRespiratoryRate": "frequenza respiratoria notturna", + "sleepSpo2": "SpO2 notturna", + "bodyTemperature": "temperatura corporea", + "bodyFat": "grasso corporeo", + "fatMass": "massa grassa", + "fatFreeMass": "massa magra", + "muscleMass": "massa muscolare", + "bodyWater": "acqua corporea", + "boneMass": "massa ossea", + "heartRate": "frequenza cardiaca", + "bloodPressure": "pressione arteriosa", + "weight": "peso" + } + } + }, + "oura": { + "scope": { + "body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.", + "action": "Riconnetti Oura", + "items": { + "spo2": "SpO2", + "vo2max": "VO2 max", + "cardio_age": "età cardiovascolare", + "resilience": "resilienza" + } } } }, diff --git a/messages/ko.json b/messages/ko.json index d05ccb066..292fd4013 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6505,6 +6505,47 @@ "body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.", "action": "Withings 다시 연결" } + }, + "plan": { + "title": "일부 측정값은 Withings 유료 API 요금제가 필요해요", + "body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.", + "link": "Withings API 요금제 알아보기", + "items": { + "spo2": "SpO2", + "vascularAge": "혈관 나이", + "pulseWaveVelocity": "맥파 속도", + "vo2Max": "VO2 max", + "visceralFat": "내장 지방", + "skinTemperature": "피부 온도", + "ecg": "심전도와 심방세동", + "sleepScore": "수면 점수", + "sleepHrv": "야간 HRV", + "sleepHeartRate": "야간 심박수", + "sleepRespiratoryRate": "야간 호흡수", + "sleepSpo2": "야간 SpO2", + "bodyTemperature": "체온", + "bodyFat": "체지방률", + "fatMass": "체지방량", + "fatFreeMass": "제지방량", + "muscleMass": "근육량", + "bodyWater": "체수분", + "boneMass": "골량", + "heartRate": "심박수", + "bloodPressure": "혈압", + "weight": "체중" + } + } + }, + "oura": { + "scope": { + "body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.", + "action": "Oura 다시 연결", + "items": { + "spo2": "SpO2", + "vo2max": "VO2 max", + "cardio_age": "심혈관 나이", + "resilience": "회복탄력성" + } } } }, diff --git a/messages/pl.json b/messages/pl.json index a8d64dd6c..c22982d53 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6505,6 +6505,47 @@ "body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.", "action": "Połącz ponownie Withings" } + }, + "plan": { + "title": "Niektóre pomiary wymagają płatnego planu API Withings", + "body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.", + "link": "Więcej o planach API Withings", + "items": { + "spo2": "SpO2", + "vascularAge": "wiek naczyniowy", + "pulseWaveVelocity": "prędkość fali tętna", + "vo2Max": "VO2 max", + "visceralFat": "tłuszcz trzewny", + "skinTemperature": "temperatura skóry", + "ecg": "EKG i migotanie przedsionków", + "sleepScore": "wynik snu", + "sleepHrv": "nocne HRV", + "sleepHeartRate": "nocne tętno", + "sleepRespiratoryRate": "nocna częstość oddechów", + "sleepSpo2": "nocne SpO2", + "bodyTemperature": "temperatura ciała", + "bodyFat": "tkanka tłuszczowa", + "fatMass": "masa tłuszczowa", + "fatFreeMass": "beztłuszczowa masa ciała", + "muscleMass": "masa mięśniowa", + "bodyWater": "woda w organizmie", + "boneMass": "masa kostna", + "heartRate": "tętno", + "bloodPressure": "ciśnienie krwi", + "weight": "waga" + } + } + }, + "oura": { + "scope": { + "body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.", + "action": "Połącz ponownie Oura", + "items": { + "spo2": "SpO2", + "vo2max": "VO2 max", + "cardio_age": "wiek sercowo-naczyniowy", + "resilience": "odporność" + } } } }, diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index 49f7a32d9..dfd9cbdf4 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -103,6 +103,8 @@ const LEGIT_IDENTICAL = new Set([ // "{hours} h {minutes} min": the SI symbols for hour and minute. "measurements.durationHoursMinutes", "settings.sections.sources.metrics.vo2Max", + "settings.integrations.withings.plan.items.vo2Max", + "settings.integrations.oura.scope.items.vo2max", // Brand / product / proper nouns. "settings.googleHealth", "admin.glitchtipDsn", diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts index af6c7243c..fe97a54a2 100644 --- a/src/app/api/integrations/status/route.ts +++ b/src/app/api/integrations/status/route.ts @@ -35,7 +35,19 @@ import { resolveSyncVerdict, type SyncHealth, } from "@/lib/integrations/sync-verdict"; -import { getOuraClientCredentials } from "@/lib/oura/credentials"; +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"; @@ -92,6 +104,7 @@ export const GET = apiHandler(async () => { ouraAccessTokenEncrypted: true, ouraClientIdEncrypted: true, ouraClientSecretEncrypted: true, + ouraScopeDenied: true, stravaAccessTokenEncrypted: true, stravaClientIdEncrypted: true, stravaClientSecretEncrypted: true, @@ -109,6 +122,7 @@ export const GET = apiHandler(async () => { lastSyncedAt: true, createdAt: true, scope: true, + apiPlanState: true, }, }), prisma.whoopConnection.findUnique({ @@ -280,6 +294,13 @@ export const GET = apiHandler(async () => { // 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), + ) + : [], ...resolve("withings", withingsStatus, { connected: !!withingsConn, configured: withingsConfigured, @@ -374,11 +395,17 @@ export const GET = apiHandler(async () => { 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)) + : [], ...resolve("oura", ouraStatus, { connected: ouraConnected, configured: ouraConnected, }), - } satisfies IntegrationViewModel & OAuthProviderExtras, + } satisfies IntegrationViewModel & + OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] }, { ...publish(stravaStatus), connected: stravaConnected, @@ -442,6 +469,7 @@ interface WithingsExtras { tokenExpired: boolean | null; scope: string | null; hasActivityScope: boolean; + planUnavailable: WithingsPlanFeature[]; } interface WhoopExtras { diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 89f4284ba..b23802541 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; @@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => { data: { ouraAccessTokenEncrypted: encrypt(tokens.access_token), ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token), + // A new grant carries the scopes asked for now; whatever the old one + // lacked is found out again by the first sync. + ouraScopeDenied: Prisma.DbNull, }, }); diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index b5bb0a6e9..d6b696c67 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiSuccess, @@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => { data: { ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, + ouraScopeDenied: Prisma.DbNull, }, }); await clearOuraClientCredentials(user.id); diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts index a5daf5f97..56b4e3e8c 100644 --- a/src/app/api/oura/disconnect/route.ts +++ b/src/app/api/oura/disconnect/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, apiError } from "@/lib/api-response"; @@ -37,6 +38,7 @@ export const POST = apiHandler(async () => { data: { ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, + ouraScopeDenied: Prisma.DbNull, }, }); diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index 74d4bbad9..2811fd460 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -54,6 +54,7 @@ import { queryKeys, } from "@/lib/query-keys"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { OuraScopeNote } from "./oura-scope-note"; import { readSyncOutcome, useSyncOutcomeMessage, @@ -72,6 +73,7 @@ import { type IntegrationDocsProvider, } from "./setup-guide-link"; import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; +import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; import { loadDocument } from "@/lib/navigation/load-document"; export interface OAuthProviderStatus { @@ -99,6 +101,8 @@ export interface OAuthProviderStatus { /** The server-resolved liveness verdict; the pill is a projection of it. */ syncHealth?: SyncHealth; metricFreshness?: MetricFreshnessEntry[]; + /** Oura: collections the stored grant does not cover. */ + scopeReconnect?: OuraScopeGatedCollection[]; } export interface OAuthProviderCardProps { @@ -330,6 +334,14 @@ export function OAuthProviderCard({ )} + {provider === "oura" && status?.connected && ( + + )} + {status?.connected && ( void; + disabled?: boolean; +}) { + const { t, locale } = useTranslations(); + if (!collections || collections.length === 0) return null; + + const labels: Record = { + spo2: t("settings.integrations.oura.scope.items.spo2"), + vo2max: t("settings.integrations.oura.scope.items.vo2max"), + cardio_age: t("settings.integrations.oura.scope.items.cardio_age"), + resilience: t("settings.integrations.oura.scope.items.resilience"), + }; + const list = new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(collections.map((c) => labels[c])); + + return ( +
+

+ {t("settings.integrations.oura.scope.body", { list })} +

+ +
+ ); +} diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx index 61f759367..396a7b97d 100644 --- a/src/components/settings/integrations/shared.tsx +++ b/src/components/settings/integrations/shared.tsx @@ -18,6 +18,8 @@ import type { SyncVerdict, } from "@/lib/integrations/sync-verdict"; import { queryKeys } from "@/lib/query-keys"; +import type { WithingsPlanFeature } from "@/lib/withings/plan-features"; +import type { OuraScopeGatedCollection } from "@/lib/oura/scope"; // v1.4.15 Phase B2: shared status payload for both integration cards. // v1.4.19 Phase A5: the redundant in-card status banner is gone — the @@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel { // Withings activity-scope reconnect banner. scope?: string | null; hasActivityScope?: boolean; + // Withings: readings the account's API plan withholds (server-resolved). + planUnavailable?: WithingsPlanFeature[]; + // Oura: collections the stored grant does not cover (server-resolved). + scopeReconnect?: OuraScopeGatedCollection[]; // WHOOP / Fitbit backfill-in-progress note. backfillCompleted?: boolean | null; // v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index 57111589a..b58e7f9da 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -49,6 +49,7 @@ import { type IntegrationStatusViewModel, } from "./shared"; import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure"; +import { WithingsPlanNote } from "./withings-plan-note"; import { readSyncOutcome, useSyncOutcomeMessage, @@ -316,6 +317,10 @@ export function WithingsCard({

)} + {status?.connected && ( + + )} + {status?.connected && ( = { + spo2: t("settings.integrations.withings.plan.items.spo2"), + vascularAge: t("settings.integrations.withings.plan.items.vascularAge"), + pulseWaveVelocity: t( + "settings.integrations.withings.plan.items.pulseWaveVelocity", + ), + vo2Max: t("settings.integrations.withings.plan.items.vo2Max"), + visceralFat: t("settings.integrations.withings.plan.items.visceralFat"), + skinTemperature: t( + "settings.integrations.withings.plan.items.skinTemperature", + ), + ecg: t("settings.integrations.withings.plan.items.ecg"), + sleepScore: t("settings.integrations.withings.plan.items.sleepScore"), + sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"), + sleepHeartRate: t( + "settings.integrations.withings.plan.items.sleepHeartRate", + ), + sleepRespiratoryRate: t( + "settings.integrations.withings.plan.items.sleepRespiratoryRate", + ), + sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"), + bodyTemperature: t( + "settings.integrations.withings.plan.items.bodyTemperature", + ), + bodyFat: t("settings.integrations.withings.plan.items.bodyFat"), + fatMass: t("settings.integrations.withings.plan.items.fatMass"), + fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"), + muscleMass: t("settings.integrations.withings.plan.items.muscleMass"), + bodyWater: t("settings.integrations.withings.plan.items.bodyWater"), + boneMass: t("settings.integrations.withings.plan.items.boneMass"), + heartRate: t("settings.integrations.withings.plan.items.heartRate"), + bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"), + weight: t("settings.integrations.withings.plan.items.weight"), + }; + const list = new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(features.map((f) => labels[f])); + + return ( +
+

+ {t("settings.integrations.withings.plan.title")} +

+

{t("settings.integrations.withings.plan.body", { list })}

+ + {t("settings.integrations.withings.plan.link")} + +
+ ); +} diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index d0cd9958a..da0dbb4af 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -439,6 +439,7 @@ export const USER_RESET = { ouraRefreshTokenEncrypted: null, ouraClientIdEncrypted: null, ouraClientSecretEncrypted: null, + ouraScopeDenied: Prisma.DbNull, stravaClientIdEncrypted: null, stravaClientSecretEncrypted: null, stravaAccessTokenEncrypted: null, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index e33dfac32..340e4b68e 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -885,6 +885,9 @@ export const USER_COLUMN_BACKUP_CLASS = { ouraRefreshTokenEncrypted: "CREDENTIAL", ouraClientIdEncrypted: "CREDENTIAL", ouraClientSecretEncrypted: "CREDENTIAL", + // What the Oura grant on this host does not cover; meaningless beside a + // grant that does not travel, and found out again by the first sync. + ouraScopeDenied: "CREDENTIAL", stravaClientIdEncrypted: "CREDENTIAL", stravaClientSecretEncrypted: "CREDENTIAL", stravaAccessTokenEncrypted: "CREDENTIAL", diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 0967516c9..eb8be83ed 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -34,6 +34,8 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { fitbitCredentialsSchema } from "@/lib/validations/fitbit"; import { googleHealthCredentialsSchema } from "@/lib/validations/google-health"; +import { WITHINGS_PLAN_FEATURES } from "@/lib/withings/plan-features"; +import { OURA_SCOPE_GATED_COLLECTIONS } from "@/lib/oura/scope"; import { ouraCredentialsSchema } from "@/lib/validations/oura"; import { polarCredentialsSchema } from "@/lib/validations/polar"; import { stravaCredentialsSchema } from "@/lib/validations/strava"; @@ -925,12 +927,24 @@ const integrationEnvelopeEntry = z backfillCompleted: z.boolean().nullable().optional(), scope: z.string().nullable().optional().describe("Withings only."), hasActivityScope: z.boolean().optional().describe("Withings only."), + planUnavailable: z + .array(z.enum(WITHINGS_PLAN_FEATURES)) + .optional() + .describe( + "Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12 plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these and keeps importing everything else; the list refreshes when the weekly re-probe asks again.", + ), needsReauth: z.boolean().optional().describe("Google Health only."), available: z.boolean().optional().describe("Oura / Polar / Strava only."), hasOwnCredentials: z .boolean() .optional() .describe("Oura / Polar / Strava only."), + scopeReconnect: z + .array(z.enum(OURA_SCOPE_GATED_COLLECTIONS)) + .optional() + .describe( + "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect asks for the missing scopes. Empty when the grant covers everything HealthLog reads.", + ), hasToken: z.boolean().optional().describe("Nightscout only."), allowPrivateHost: z.boolean().optional().describe("Nightscout only."), }) diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index d34407fe9..ec294e1f9 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -670,8 +670,13 @@ describe("mapResilience", () => { }); describe("OURA_OAUTH_SCOPE", () => { - it("requests daily + personal", () => { - expect(OURA_OAUTH_SCOPE).toBe("daily personal"); + it("requests daily, personal and the scopes behind SpO2 and heart health", () => { + expect(OURA_OAUTH_SCOPE.split(" ")).toEqual([ + "daily", + "personal", + "spo2", + "heart_health", + ]); }); }); diff --git a/src/lib/oura/__tests__/sync.test.ts b/src/lib/oura/__tests__/sync.test.ts index 0cddccc1b..7079809ad 100644 --- a/src/lib/oura/__tests__/sync.test.ts +++ b/src/lib/oura/__tests__/sync.test.ts @@ -34,6 +34,7 @@ const { invalidateMock, transactionMock, reconcileMock, + scopeDeniedMock, } = vi.hoisted(() => ({ getConnMock: vi.fn(), storeTokensMock: vi.fn(), @@ -60,12 +61,14 @@ const { invalidateMock: vi.fn(), transactionMock: vi.fn(), reconcileMock: vi.fn(), + scopeDeniedMock: vi.fn(), })); vi.mock("../credentials", () => ({ getOuraConnection: getConnMock, storeOuraTokens: storeTokensMock, getOuraClientCredentials: getCredsMock, + syncOuraScopeDenied: scopeDeniedMock, })); vi.mock("@/lib/db", () => ({ @@ -159,6 +162,7 @@ beforeEach(() => { fetchResilienceMock.mockReset().mockResolvedValue([]); fetchCyclePhasesMock.mockReset().mockResolvedValue([]); refreshMock.mockReset(); + scopeDeniedMock.mockReset().mockResolvedValue(undefined); transactionMock .mockReset() .mockImplementation(async (run: (tx: unknown) => unknown) => run({})); diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 073c6a858..44b9e9087 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -109,8 +109,24 @@ export function getOuraRedirectUri(): string { ); } -/** `daily` covers sleep / readiness / activity; `personal` is profile info. */ -export const OURA_OAUTH_SCOPE = "daily personal" as const; +/** + * The scopes HealthLog asks Oura for. + * + * - `daily` : readiness, sleep, daily sleep, daily activity + * - `personal` : profile info + * - `spo2` : `daily_spo2` + * - `heart_health` : `vO2_max`, `daily_cardiovascular_age`, + * `daily_resilience` + * + * Oura's current authorization server enforces these per collection: a grant + * without `spo2` or `heart_health` answers 401 on those collections while every + * other one succeeds. The published OpenAPI security scheme (openapi-1.41) + * lists `spo2` and `heart_health` but maps no endpoint to them; the mapping + * above is what the live API enforces, as recorded by integrations that probed + * it. Grants issued before these were requested keep the old set until the + * user reconnects once — see `syncOuraScopeDenied`. + */ +export const OURA_OAUTH_SCOPE = "daily personal spo2 heart_health" as const; export function getAuthorizationUrl( state: string, diff --git a/src/lib/oura/credentials.ts b/src/lib/oura/credentials.ts index bcf74f3a6..d1d2c5809 100644 --- a/src/lib/oura/credentials.ts +++ b/src/lib/oura/credentials.ts @@ -11,6 +11,7 @@ * tokens). */ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { decrypt, encrypt } from "@/lib/crypto"; import { persistRotatedToken } from "@/lib/integrations/oauth-refresh"; import { getOuraCredentials, type OuraCredentials } from "./client"; @@ -84,6 +85,8 @@ export interface OuraConnection { * concurrent sync that already rotated the token is not clobbered. */ refreshTokenCiphertext: string; + /** Collections the grant was last seen not to cover (`ouraScopeDenied`). */ + scopeDenied?: string[]; } export async function getOuraConnection( @@ -94,6 +97,7 @@ export async function getOuraConnection( select: { ouraAccessTokenEncrypted: true, ouraRefreshTokenEncrypted: true, + ouraScopeDenied: true, }, }); if (!user?.ouraAccessTokenEncrypted || !user?.ouraRefreshTokenEncrypted) { @@ -103,6 +107,7 @@ export async function getOuraConnection( accessToken: decrypt(user.ouraAccessTokenEncrypted), refreshToken: decrypt(user.ouraRefreshTokenEncrypted), refreshTokenCiphertext: user.ouraRefreshTokenEncrypted, + scopeDenied: readOuraScopeDenied(user.ouraScopeDenied), }; } @@ -148,3 +153,37 @@ export async function storeOuraTokens( }, }); } + +/** + * Collections the current Oura grant does not cover, by collection name. Read + * by the settings envelope; an absent or malformed value reads as none. + */ +export function readOuraScopeDenied(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter( + (v): v is string => typeof v === "string" && /^[a-z0-9_]{1,40}$/.test(v), + ); +} + +/** + * Record which collections the grant does not cover after a sync, writing only + * when the set changed so an hourly poll does not touch the user row for + * nothing. An empty set clears the column. + */ +export async function syncOuraScopeDenied( + userId: string, + denied: readonly string[], +): Promise { + const next = [...new Set(denied)].sort(); + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { ouraScopeDenied: true }, + }); + const current = readOuraScopeDenied(row?.ouraScopeDenied).sort(); + if (current.length === next.length && current.every((v, i) => v === next[i])) + return; + await prisma.user.update({ + where: { id: userId }, + data: { ouraScopeDenied: next.length > 0 ? next : Prisma.DbNull }, + }); +} diff --git a/src/lib/oura/scope.ts b/src/lib/oura/scope.ts new file mode 100644 index 000000000..e1c74f11b --- /dev/null +++ b/src/lib/oura/scope.ts @@ -0,0 +1,23 @@ +/** + * Oura collections that sit behind a scope beyond `daily` (`spo2`, + * `heart_health`). A grant issued before HealthLog requested those scopes + * answers 401 on exactly these, so they are the only names the settings card + * can be asked to explain. Each has a label under + * `settings.integrations.oura.scope.items`. + */ +export const OURA_SCOPE_GATED_COLLECTIONS = [ + "spo2", + "vo2max", + "cardio_age", + "resilience", +] as const; + +export type OuraScopeGatedCollection = + (typeof OURA_SCOPE_GATED_COLLECTIONS)[number]; + +/** Narrow a stored list to the known names, in display order. */ +export function ouraScopeReconnectList( + denied: readonly string[], +): OuraScopeGatedCollection[] { + return OURA_SCOPE_GATED_COLLECTIONS.filter((c) => denied.includes(c)); +} diff --git a/src/lib/oura/sync.ts b/src/lib/oura/sync.ts index 194aaa626..c1c8d4b5d 100644 --- a/src/lib/oura/sync.ts +++ b/src/lib/oura/sync.ts @@ -35,7 +35,7 @@ */ import { prisma } from "@/lib/db"; import type { MeasurementType } from "@/generated/prisma/client"; -import { getEvent } from "@/lib/logging/context"; +import { annotate, getEvent } from "@/lib/logging/context"; import { markSyncFailureRecorded, recordSyncFailure, @@ -87,6 +87,7 @@ import { getOuraClientCredentials, getOuraConnection, storeOuraTokens, + syncOuraScopeDenied, } from "./credentials"; import { OuraApiError, classifyOuraError } from "./response-classifier"; import { syncUserOuraCyclePhases } from "./cycle-sync"; @@ -243,6 +244,42 @@ export interface OuraFetchResult { * run-indexed `seg:` row — before the fresh set upserts. */ sleepSweeps: SleepSegmentSweep[]; + /** + * Collections refused with 401/403 while a sibling succeeded with the same + * token: the token is fine, the grant does not cover them. Not failures; the + * card asks for one reconnect instead. + */ + scopeDenied: string[]; +} + +/** + * A 401 from Oura: the token, or the grant's scope, was refused. A 403 stays a + * plain failure: Oura also answers 403 for a lapsed membership, which a + * reconnect does not fix. + */ +function isAuthRefusal(err: unknown): boolean { + return err instanceof OuraApiError && err.httpStatus === 401; +} + +/** + * Split the auth refusals out of a run's failures once a sibling collection has + * proven the token. Oura's authorization server enforces scopes per collection, + * so a grant issued without `spo2` / `heart_health` answers 401 on exactly the + * collections behind them and 200 everywhere else. Counting those toward the + * reconnect ladder painted a working connection as broken on every sync, and + * refreshing the token over them changed nothing. + */ +function splitScopeDenied( + failures: OuraCollectionFailure[], + tokenProven: boolean, +): { failures: OuraCollectionFailure[]; scopeDenied: string[] } { + if (!tokenProven) return { failures, scopeDenied: [] }; + return { + failures: failures.filter((f) => !isAuthRefusal(f.err)), + scopeDenied: failures + .filter((f) => isAuthRefusal(f.err)) + .map((f) => f.name), + }; } /** @@ -267,6 +304,7 @@ async function fetchAll( refreshToken: string, refreshTokenCiphertext: string, lookbackDays: number, + knownScopeDenied: readonly string[] = [], ): Promise { // Oura keys its daily collections by the user's calendar date, so the // window is cut on the user's own days, and date-only records are anchored @@ -386,6 +424,7 @@ async function fetchAll( failed: OuraCollectionSpec[]; failures: OuraCollectionFailure[]; auth401: boolean; + succeeded: number; }> => { const settled = await Promise.allSettled( specs.map((spec) => spec.collect(token)), @@ -394,9 +433,11 @@ async function fetchAll( const failed: OuraCollectionSpec[] = []; const failures: OuraCollectionFailure[] = []; let auth401 = false; + let succeeded = 0; settled.forEach((res, i) => { const spec = specs[i]!; if (res.status === "fulfilled") { + succeeded++; readings.push(...res.value); } else { const err = res.reason; @@ -406,12 +447,24 @@ async function fetchAll( failures.push({ name: spec.name, err }); } }); - return { readings, failed, failures, auth401 }; + return { readings, failed, failures, auth401, succeeded }; }; const first = await attempt(accessToken, collections); - if (!first.auth401) { - return { readings: first.readings, failures: first.failures, sleepSweeps }; + // A 401 limited to collections already known to sit outside the grant, next + // to collections that succeeded, is the grant's scope again: a refresh cannot + // widen it, so none is spent. Any other 401 still takes the one refresh, and + // whatever 401s after it beside a success is recorded as scope. + const knownDenied = new Set(knownScopeDenied); + const onlyKnownScope401 = first.failures + .filter((f) => isAuthRefusal(f.err)) + .every((f) => knownDenied.has(f.name)); + if (!first.auth401 || (first.succeeded > 0 && onlyKnownScope401)) { + return { + readings: first.readings, + ...splitScopeDenied(first.failures, first.succeeded > 0), + sleepSweeps, + }; } // Reactive refresh: the access token expired (a 401 on the shared token). @@ -419,7 +472,12 @@ async function fetchAll( // failed. A failed refresh throws so the caller parks reauth_required. const creds = await getOuraClientCredentials(userId); if (!creds) - return { readings: first.readings, failures: first.failures, sleepSweeps }; + return { + readings: first.readings, + failures: first.failures, + sleepSweeps, + scopeDenied: [], + }; const rotated = await refreshAccessToken(refreshToken, creds); // Compare-and-swap persist: on a lost race against a concurrent sync this @@ -432,12 +490,19 @@ async function fetchAll( refreshTokenCiphertext, ); if (!usableToken) - return { readings: first.readings, failures: first.failures, sleepSweeps }; + return { + readings: first.readings, + failures: first.failures, + sleepSweeps, + scopeDenied: [], + }; const retry = await attempt(usableToken, first.failed); return { readings: [...first.readings, ...retry.readings], - failures: retry.failures, + // Proven by either pass: a collection that succeeded before the refresh + // proves the grant as much as one that succeeded after it. + ...splitScopeDenied(retry.failures, first.succeeded + retry.succeeded > 0), sleepSweeps, }; } @@ -472,6 +537,7 @@ export async function syncUserOura( conn.refreshToken, conn.refreshTokenCiphertext, lookbackDays, + conn.scopeDenied, ); } catch (err) { // Only a whole-connection failure (a failed token refresh → reauth) reaches @@ -482,6 +548,20 @@ export async function syncUserOura( throw markSyncFailureRecorded(err); } + // Remember which collections the grant does not cover, so the settings card + // can ask for one reconnect. Best-effort: the note is a convenience, the + // sync outcome does not depend on it. + if (result.scopeDenied.length > 0) { + annotate({ + meta: { oura_scope_denied: result.scopeDenied }, + }); + } + try { + await syncOuraScopeDenied(userId, result.scopeDenied); + } catch (err) { + getEvent()?.addWarning(`oura: scope note write failed: ${err}`); + } + // Clear whatever an earlier scoring left under the re-fetched sleep records // before the fresh set upserts (mirrors Google Health's replace-by-window // order). Best-effort inside the helper — a sweep failure never fails the diff --git a/src/lib/withings/api-plan.ts b/src/lib/withings/api-plan.ts index 75453a0cd..f7fc39d77 100644 --- a/src/lib/withings/api-plan.ts +++ b/src/lib/withings/api-plan.ts @@ -69,10 +69,6 @@ export const WITHINGS_ADVANCED_SLEEP_FIELDS: readonly string[] = [ "rr_average", ]; -/** The Withings plans page, linked from the settings card and the docs. */ -export const WITHINGS_API_PLANS_URL = - "https://static.withings.com/content/api/plans/plans.html"; - // ── error recognition ────────────────────────────────────────────────────── /** @@ -327,90 +323,6 @@ export function shouldPersistPlanLeg(opts: { return opts.gated || opts.reprobed; } -// ── what the settings card names ─────────────────────────────────────────── - -/** - * The closed set of reading names the Withings card can show for a plan - * refusal, in display order. Server-resolved so every client renders the same - * list; each has a label under `settings.integrations.withings.plan.items`. - */ -export const WITHINGS_PLAN_FEATURES = [ - "spo2", - "vascularAge", - "pulseWaveVelocity", - "vo2Max", - "visceralFat", - "skinTemperature", - "ecg", - "sleepScore", - "sleepHrv", - "sleepHeartRate", - "sleepRespiratoryRate", - "sleepSpo2", - "bodyTemperature", - "bodyFat", - "fatMass", - "fatFreeMass", - "muscleMass", - "bodyWater", - "boneMass", - "heartRate", - "bloodPressure", - "weight", -] as const; - -export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number]; - -const MEASTYPE_FEATURE: Record = { - 1: "weight", - 5: "fatFreeMass", - 6: "bodyFat", - 8: "fatMass", - 9: "bloodPressure", - 10: "bloodPressure", - 11: "heartRate", - 12: "bodyTemperature", - 35: "spo2", - 54: "spo2", - 71: "bodyTemperature", - 73: "skinTemperature", - 76: "muscleMass", - 77: "bodyWater", - 88: "boneMass", - 91: "pulseWaveVelocity", - 123: "vo2Max", - 155: "vascularAge", - 170: "visceralFat", -}; - -const SLEEP_FIELD_FEATURE: Record = { - hr_average: "sleepHeartRate", - rr_average: "sleepRespiratoryRate", - sdnn_1: "sleepHrv", - spo2_average: "sleepSpo2", - sleep_score: "sleepScore", -}; - -/** - * The readings this connection's plan currently withholds, deduplicated and in - * display order. Empty when nothing is refused. - */ -export function withingsPlanUnavailableFeatures( - state: WithingsApiPlanState, -): WithingsPlanFeature[] { - const found = new Set(); - for (const t of state.measures?.unavailable ?? []) { - const f = MEASTYPE_FEATURE[t]; - if (f) found.add(f); - } - for (const field of state.sleepSummary?.unavailable ?? []) { - const f = SLEEP_FIELD_FEATURE[field]; - if (f) found.add(f); - } - if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg"); - return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f)); -} - // ── lifting parks an earlier build left ────────────────────────────────────── /** diff --git a/src/lib/withings/plan-features.ts b/src/lib/withings/plan-features.ts new file mode 100644 index 000000000..aaa0d0457 --- /dev/null +++ b/src/lib/withings/plan-features.ts @@ -0,0 +1,92 @@ +/** + * The readings a Withings API-plan refusal can withhold, as the settings card + * names them. Pure: no database, no network, so the OpenAPI registry and the + * client bundle can import it. + */ +import type { WithingsApiPlanState } from "./api-plan"; + +/** The Withings plans page, linked from the settings card and the docs. */ +export const WITHINGS_API_PLANS_URL = + "https://static.withings.com/content/api/plans/plans.html"; + +/** + * The closed set of reading names the Withings card can show for a plan + * refusal, in display order. Server-resolved so every client renders the same + * list; each has a label under `settings.integrations.withings.plan.items`. + */ +export const WITHINGS_PLAN_FEATURES = [ + "spo2", + "vascularAge", + "pulseWaveVelocity", + "vo2Max", + "visceralFat", + "skinTemperature", + "ecg", + "sleepScore", + "sleepHrv", + "sleepHeartRate", + "sleepRespiratoryRate", + "sleepSpo2", + "bodyTemperature", + "bodyFat", + "fatMass", + "fatFreeMass", + "muscleMass", + "bodyWater", + "boneMass", + "heartRate", + "bloodPressure", + "weight", +] as const; + +export type WithingsPlanFeature = (typeof WITHINGS_PLAN_FEATURES)[number]; + +const MEASTYPE_FEATURE: Record = { + 1: "weight", + 5: "fatFreeMass", + 6: "bodyFat", + 8: "fatMass", + 9: "bloodPressure", + 10: "bloodPressure", + 11: "heartRate", + 12: "bodyTemperature", + 35: "spo2", + 54: "spo2", + 71: "bodyTemperature", + 73: "skinTemperature", + 76: "muscleMass", + 77: "bodyWater", + 88: "boneMass", + 91: "pulseWaveVelocity", + 123: "vo2Max", + 155: "vascularAge", + 170: "visceralFat", +}; + +const SLEEP_FIELD_FEATURE: Record = { + hr_average: "sleepHeartRate", + rr_average: "sleepRespiratoryRate", + sdnn_1: "sleepHrv", + spo2_average: "sleepSpo2", + sleep_score: "sleepScore", +}; + +/** + * The readings this connection's plan currently withholds, deduplicated and in + * display order. Empty when nothing is refused. + */ +export function withingsPlanUnavailableFeatures( + state: WithingsApiPlanState, +): WithingsPlanFeature[] { + const found = new Set(); + for (const t of state.measures?.unavailable ?? []) { + const f = MEASTYPE_FEATURE[t]; + if (f) found.add(f); + } + for (const field of state.sleepSummary?.unavailable ?? []) { + const f = SLEEP_FIELD_FEATURE[field]; + if (f) found.add(f); + } + if ((state.ecg?.unavailable.length ?? 0) > 0) found.add("ecg"); + return WITHINGS_PLAN_FEATURES.filter((f) => found.has(f)); +}