diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index af613a5d8..eabd0e3e3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -62980,14 +62980,30 @@ components: - type: "null" description: Non-null when two writer buckets reported clearly different asleep totals for the latest night's main session. Observational only — the served summary stays the winning writer's totals. + recoveryEmpty: + anyOf: + - type: string + enum: + - general + - oura_permission + - oura_no_resilience + - type: "null" + description: "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type + (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, + ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves + out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected + and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and + never work it out from the connection status." required: - summaries - bmi - lastSeenByType - sleepSourceDiscrepancy + - recoveryEmpty additionalProperties: false - description: "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, - and the caller gets them from two SQL passes rather than the default slice's chain." + description: "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty + reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default + slice's chain." InsightsGenerateReadEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index dfbda8953..4647bb6f3 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4666,9 +4666,9 @@ "description": "Wie gut sich dein Körper erholt hat: die Erholungs-, Belastungs- und Load-Signale, die dein Wearable über Nacht aufzeichnet.", "scoreCrossLink": "Den zusammengesetzten Erholungs-Score ansehen", "emptyTitle": "Noch keine Erholungsdaten", - "empty": "Hier ist noch nichts zu sehen. Diese Seite zeigt die Werte zu Aufladung, Belastung und Trainingslast, die WHOOP und Polar aufzeichnen, und Ouras Resilienz-Stufe. Ouras Readiness erscheint als dein Erholungs-Score, oben verlinkt.", - "emptyOuraPermission": "Oura ist verbunden, aber die erteilte Berechtigung umfasst keine Herzgesundheitsdaten, und genau dahinter liegt Ouras Resilienz-Stufe. Verbinde Oura unter Einstellungen, Integrationen einmal neu und erlaube den Zugriff auf Herzgesundheit; die Stufe erscheint dann mit der nächsten Synchronisierung hier. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", - "emptyOuraNoResilience": "Oura ist verbunden, aber für dieses Konto ist noch keine Resilienz-Stufe angekommen. HealthLog fragt bei jeder Synchronisierung danach, sie erscheint hier, sobald Oura eine liefert. Zeigt die Oura-App eine Stufe, nennt die Oura-Karte unter Einstellungen, Integrationen alles, was die Verbindung nicht liefern kann. Ouras Readiness ist bereits dein Erholungs-Score, oben verlinkt.", + "empty": "Diese Seite füllt sich mit den Erholungs-, Belastungs- und Trainingslastwerten von WHOOP oder Polar und mit Ouras Resilienz-Stufe.", + "emptyOuraPermission": "Ouras Resilienz-Stufe braucht Zugriff auf Herzgesundheitsdaten, also verbinde Oura einmal neu unter Integrationen und erlaube ihn.", + "emptyOuraNoResilience": "Oura ist verbunden, und seine Resilienz-Stufe erscheint hier, sobald Oura eine liefert.", "emptyAction": "Integrationen öffnen", "block": { "ansChargeExplainer": "Wie aufgeladen dein autonomes Nervensystem in den Tag startet, ein Recharge-Wert aus deinen nächtlichen Herz- und Atemsignalen.", diff --git a/messages/en.json b/messages/en.json index db1048e6e..fcd8a18f4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4666,9 +4666,9 @@ "description": "How well your body has bounced back: the recovery, strain and load signals your wearable records overnight.", "scoreCrossLink": "See the composite recovery score", "emptyTitle": "No recovery data yet", - "empty": "Nothing to chart here yet. This page shows the recharge, strain and load readings that WHOOP and Polar record, and Oura's resilience level. Oura's readiness appears as your recovery score, linked above.", - "emptyOuraPermission": "Oura is connected, but the permission it was given leaves out heart-health data, and Oura's resilience level sits behind it. Reconnect Oura once under Settings, Integrations and allow heart-health access; the level then appears here with the next sync. Oura's readiness is already your recovery score, linked above.", - "emptyOuraNoResilience": "Oura is connected, but no resilience level has arrived for this account yet. HealthLog asks Oura for it on every sync, so it appears here as soon as Oura provides one. If the Oura app shows a level, the Oura card under Settings, Integrations names anything the connection cannot deliver. Oura's readiness is already your recovery score, linked above.", + "empty": "This page fills with the recharge, strain and load readings from WHOOP or Polar and with Oura's resilience level.", + "emptyOuraPermission": "Oura's resilience level needs heart-health access, so reconnect Oura once under Integrations and allow it.", + "emptyOuraNoResilience": "Oura is connected, and its resilience level appears here as soon as Oura provides one.", "emptyAction": "Open integrations", "block": { "ansChargeExplainer": "How charged your autonomic nervous system is for the day ahead, a recharge reading drawn from your overnight heart and breathing signals.", diff --git a/messages/es.json b/messages/es.json index 0172de547..d561a693d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4666,9 +4666,9 @@ "description": "Cómo se ha recuperado tu cuerpo: las señales de recuperación, esfuerzo y carga que tu wearable registra durante la noche.", "scoreCrossLink": "Ver la puntuación de recuperación combinada", "emptyTitle": "Aún no hay datos de recuperación", - "empty": "Aún no hay nada que mostrar aquí. Esta página muestra las lecturas de recarga, esfuerzo y carga que registran WHOOP y Polar, y el nivel de resiliencia de Oura. La preparación (readiness) de Oura aparece como tu puntuación de recuperación, enlazada arriba.", - "emptyOuraPermission": "Oura está conectado, pero el permiso concedido no incluye los datos de salud cardíaca, y el nivel de resiliencia de Oura depende de ellos. Vuelve a conectar Oura una vez en Ajustes, Integraciones y permite el acceso a la salud cardíaca; el nivel aparecerá aquí con la próxima sincronización. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", - "emptyOuraNoResilience": "Oura está conectado, pero todavía no ha llegado ningún nivel de resiliencia para esta cuenta. HealthLog lo pide en cada sincronización, así que aparecerá aquí en cuanto Oura lo proporcione. Si la app de Oura muestra un nivel, la tarjeta de Oura en Ajustes, Integraciones indica todo lo que la conexión no puede entregar. La preparación (readiness) de Oura ya es tu puntuación de recuperación, enlazada arriba.", + "empty": "Esta página se llena con los valores de recuperación, esfuerzo y carga de WHOOP o Polar y con el nivel de resiliencia de Oura.", + "emptyOuraPermission": "El nivel de resiliencia de Oura necesita acceso a los datos de salud cardíaca, así que vuelve a conectar Oura una vez en Integraciones y permítelo.", + "emptyOuraNoResilience": "Oura está conectado y su nivel de resiliencia aparecerá aquí en cuanto Oura proporcione uno.", "emptyAction": "Abrir integraciones", "block": { "ansChargeExplainer": "Cuán cargado está tu sistema nervioso autónomo para el día: una lectura de recarga obtenida de tus señales nocturnas de corazón y respiración.", diff --git a/messages/fr.json b/messages/fr.json index 96ca2aa61..cca3e02ab 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4666,9 +4666,9 @@ "description": "À quel point votre corps a récupéré : les signaux de récupération, de charge et d'effort que votre wearable enregistre la nuit.", "scoreCrossLink": "Voir le score de récupération composite", "emptyTitle": "Pas encore de données de récupération", - "empty": "Rien à afficher ici pour l'instant. Cette page montre les mesures de recharge, d'effort et de charge qu'enregistrent WHOOP et Polar, ainsi que le niveau de résilience d'Oura. La disponibilité (readiness) d'Oura apparaît comme votre score de récupération, en lien ci-dessus.", - "emptyOuraPermission": "Oura est connecté, mais l'autorisation accordée n'inclut pas les données de santé cardiaque, dont dépend le niveau de résilience d'Oura. Reconnectez Oura une fois dans Réglages, Intégrations et autorisez l'accès à la santé cardiaque ; le niveau apparaîtra ici à la prochaine synchronisation. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", - "emptyOuraNoResilience": "Oura est connecté, mais aucun niveau de résilience n'est encore arrivé pour ce compte. HealthLog le demande à chaque synchronisation, il apparaîtra ici dès qu'Oura en fournira un. Si l'app Oura affiche un niveau, la carte Oura dans Réglages, Intégrations indique tout ce que la connexion ne peut pas fournir. La disponibilité (readiness) d'Oura est déjà votre score de récupération, en lien ci-dessus.", + "empty": "Cette page se remplit avec les valeurs de récupération, d'effort et de charge de WHOOP ou Polar et avec le niveau de résilience d'Oura.", + "emptyOuraPermission": "Le niveau de résilience d'Oura demande l'accès aux données de santé cardiaque : reconnecte Oura une fois dans Intégrations et autorise-le.", + "emptyOuraNoResilience": "Oura est connecté, et son niveau de résilience apparaîtra ici dès qu'Oura en fournira un.", "emptyAction": "Ouvrir les intégrations", "block": { "ansChargeExplainer": "À quel point votre système nerveux autonome est rechargé pour la journée : une mesure de recharge issue de vos signaux nocturnes de cœur et de respiration.", diff --git a/messages/it.json b/messages/it.json index 60f79a6a9..a53890b65 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4666,9 +4666,9 @@ "description": "Quanto bene il tuo corpo si è ripreso: i segnali di recupero, sforzo e carico che il tuo wearable registra durante la notte.", "scoreCrossLink": "Vedi il punteggio di recupero composito", "emptyTitle": "Ancora nessun dato di recupero", - "empty": "Qui non c'è ancora nulla da mostrare. Questa pagina mostra i valori di ricarica, sforzo e carico che registrano WHOOP e Polar, e il livello di resilienza di Oura. La prontezza (readiness) di Oura compare come punteggio di recupero, collegato qui sopra.", - "emptyOuraPermission": "Oura è collegato, ma l'autorizzazione concessa non include i dati sulla salute cardiaca, da cui dipende il livello di resilienza di Oura. Ricollega Oura una volta in Impostazioni, Integrazioni e consenti l'accesso alla salute cardiaca; il livello comparirà qui con la prossima sincronizzazione. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", - "emptyOuraNoResilience": "Oura è collegato, ma per questo account non è ancora arrivato alcun livello di resilienza. HealthLog lo richiede a ogni sincronizzazione, quindi comparirà qui non appena Oura ne fornirà uno. Se l'app Oura mostra un livello, la scheda Oura in Impostazioni, Integrazioni indica tutto ciò che la connessione non può fornire. La prontezza (readiness) di Oura è già il tuo punteggio di recupero, collegato qui sopra.", + "empty": "Questa pagina si riempie con i valori di recupero, sforzo e carico di WHOOP o Polar e con il livello di resilienza di Oura.", + "emptyOuraPermission": "Il livello di resilienza di Oura richiede l'accesso ai dati sulla salute del cuore, quindi ricollega Oura una volta in Integrazioni e consentilo.", + "emptyOuraNoResilience": "Oura è collegato e il suo livello di resilienza apparirà qui non appena Oura ne fornirà uno.", "emptyAction": "Apri integrazioni", "block": { "ansChargeExplainer": "Quanto è carico il tuo sistema nervoso autonomo per la giornata: una lettura di ricarica ricavata dai segnali notturni di cuore e respiro.", diff --git a/messages/ko.json b/messages/ko.json index 8a49a3d3a..24fecff40 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4666,9 +4666,9 @@ "description": "몸이 얼마나 회복했는지: 웨어러블이 밤사이 기록한 회복·부하 신호예요.", "scoreCrossLink": "종합 회복 점수 보기", "emptyTitle": "아직 회복 데이터가 없어요", - "empty": "아직 여기에 표시할 내용이 없어요. 이 페이지는 WHOOP과 Polar가 기록하는 충전, 부하, 운동량 값과 Oura의 회복 탄력성 단계를 보여줘요. Oura의 준비도(Readiness)는 위에 연결된 회복 점수에 표시돼요.", - "emptyOuraPermission": "Oura는 연결되어 있지만 허용된 권한에 심장 건강 데이터가 빠져 있어요. Oura의 회복 탄력성 단계는 바로 이 데이터에 속해요. 설정, 통합에서 Oura를 한 번 다시 연결하고 심장 건강 접근을 허용하면 다음 동기화 때 여기에 단계가 표시돼요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", - "emptyOuraNoResilience": "Oura가 연결되어 있지만 이 계정의 회복 탄력성 단계가 아직 도착하지 않았어요. HealthLog는 동기화할 때마다 요청하므로 Oura가 보내는 즉시 여기에 표시돼요. Oura 앱에 단계가 보인다면 설정, 통합의 Oura 카드에서 이 연결이 가져올 수 없는 항목을 확인할 수 있어요. Oura의 준비도(Readiness)는 이미 위에 연결된 회복 점수에 표시돼요.", + "empty": "이 페이지에는 WHOOP 또는 Polar의 회복, 부하, 훈련 부하 값과 Oura의 회복탄력성 수준이 표시돼요.", + "emptyOuraPermission": "Oura의 회복탄력성 수준에는 심장 건강 데이터 접근 권한이 필요하니, 연동에서 Oura를 한 번 다시 연결하고 허용해 주세요.", + "emptyOuraNoResilience": "Oura가 연결되어 있으며, Oura가 회복탄력성 수준을 제공하는 즉시 여기에 표시돼요.", "emptyAction": "통합 열기", "block": { "ansChargeExplainer": "오늘 하루를 맞을 자율신경계의 충전 상태예요. 밤사이 심장·호흡 신호에서 읽어낸 재충전 지표예요.", diff --git a/messages/pl.json b/messages/pl.json index ddd745519..17e130e90 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4666,9 +4666,9 @@ "description": "Jak dobrze Twój organizm się zregenerował: sygnały regeneracji, obciążenia i load, które Twoje urządzenie rejestruje w nocy.", "scoreCrossLink": "Zobacz łączny wynik regeneracji", "emptyTitle": "Brak danych o regeneracji", - "empty": "Na razie nie ma tu nic do pokazania. Ta strona pokazuje odczyty ładowania, wysiłku i obciążenia, które rejestrują WHOOP i Polar, oraz poziom odporności z Oura. Gotowość (readiness) z Oura pojawia się jako wynik regeneracji, link powyżej.", - "emptyOuraPermission": "Oura jest połączona, ale udzielone uprawnienie nie obejmuje danych o zdrowiu serca, od których zależy poziom odporności z Oura. Połącz Oura ponownie raz w Ustawieniach, Integracjach i zezwól na dostęp do zdrowia serca; poziom pojawi się tutaj przy następnej synchronizacji. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", - "emptyOuraNoResilience": "Oura jest połączona, ale dla tego konta nie dotarł jeszcze żaden poziom odporności. HealthLog pyta o niego przy każdej synchronizacji, więc pojawi się tutaj, gdy tylko Oura go dostarczy. Jeśli aplikacja Oura pokazuje poziom, karta Oura w Ustawieniach, Integracjach wymienia wszystko, czego połączenie nie może dostarczyć. Gotowość (readiness) z Oura jest już Twoim wynikiem regeneracji, link powyżej.", + "empty": "Ta strona wypełnia się wartościami regeneracji, obciążenia i obciążenia treningowego z WHOOP lub Polar oraz poziomem odporności z Oura.", + "emptyOuraPermission": "Poziom odporności Oura wymaga dostępu do danych o zdrowiu serca, więc połącz Oura ponownie raz w Integracjach i zezwól na niego.", + "emptyOuraNoResilience": "Oura jest połączona, a jej poziom odporności pojawi się tutaj, gdy tylko Oura go dostarczy.", "emptyAction": "Otwórz integracje", "block": { "ansChargeExplainer": "Jak naładowany jest Twój autonomiczny układ nerwowy na dany dzień: odczyt regeneracji oparty na nocnych sygnałach serca i oddechu.", diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index 9760ad9f1..d7d906d52 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -364,8 +364,12 @@ describe("GET /api/analytics", () => { string, { lastSeenAt: string; daysAgo: number } | null >; + recoveryEmpty: string | null; }; }; + // A weight-only account without Oura: the recovery page's empty reason + // is resolved here, not by the client. + expect(body.data.recoveryEmpty).toBe("general"); // The slim slice produced WEIGHT from the SQL pass; no chunked // findMany was called. expect(prisma.measurement.findMany).not.toHaveBeenCalled(); diff --git a/src/app/api/analytics/route.ts b/src/app/api/analytics/route.ts index de8e909bd..c76b4c33b 100644 --- a/src/app/api/analytics/route.ts +++ b/src/app/api/analytics/route.ts @@ -7,6 +7,7 @@ import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers"; import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { summarize, type DataPoint } from "@/lib/analytics/trends"; import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { loadRecoveryEmpty } from "@/lib/insights/recovery-empty-read"; import { resolveEffectiveBpTargets } from "@/lib/analytics/effective-range"; import { bpTargetsEqual, getBpTargets } from "@/lib/analytics/bp-targets"; import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver"; @@ -136,6 +137,9 @@ export const GET = apiHandler(async (request?: Request) => { ...slim, summaries: slimGated.summaries, lastSeenByType: slimGated.lastSeenByType, + // Resolved here, from the gated summaries and the Oura connection, so + // a client renders the recovery page's empty note without deciding it. + recoveryEmpty: await loadRecoveryEmpty(user.id, slimGated.summaries), }); slimRes.headers.set("Cache-Control", NO_STORE_BUT_BFCACHE); return slimRes; diff --git a/src/components/insights/__tests__/device-score-surfaces.test.tsx b/src/components/insights/__tests__/device-score-surfaces.test.tsx index e36b88076..5a997be2c 100644 --- a/src/components/insights/__tests__/device-score-surfaces.test.tsx +++ b/src/components/insights/__tests__/device-score-surfaces.test.tsx @@ -3,7 +3,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; /** @@ -46,12 +45,6 @@ function render(node: React.ReactNode) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); - // The recovery page's empty note waits for the connection status; an - // account with no integrations is the settled answer here. - queryClient.setQueryData(queryKeys.integrationsStatus(), { - threshold: 3, - integrations: [], - }); return renderToStaticMarkup( {node} @@ -204,21 +197,20 @@ describe(" data-gating", () => { describe(" data-gating", () => { it("shows the calm empty note when there is no recovery data at all", () => { - analyticsMock.mockReturnValue( - analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }), - ); + // The server resolves the empty reason beside the summaries. + const empty = analyticsWith({ SLEEP_DURATION: summary({ count: 10 }) }); + analyticsMock.mockReturnValue({ + ...empty, + data: { ...empty.data, recoveryEmpty: "general" }, + }); const html = render(); expect(html).toContain('data-slot="recovery-empty"'); // Routed through the shared (dashed bordered card), not the // old hand-rolled div — the empty title comes from the unified primitive. expect(html).toContain("border-dashed"); expect(html).not.toContain('data-slot="recovery-block-DAY_STRAIN"'); - // An Oura account sees this note too: it must say where Oura's readiness - // lives rather than tell a connected user to connect Oura. expect(html).not.toContain("Connect a WHOOP, Polar or Oura account"); - expect(html).toContain( - "Oura's readiness appears as your recovery score", - ); + expect(html).toContain("WHOOP or Polar"); }); it("renders a canonical chart block for the present signal", () => { diff --git a/src/components/insights/__tests__/recovery-empty-state.test.tsx b/src/components/insights/__tests__/recovery-empty-state.test.tsx index 06f4f5240..57036b5f0 100644 --- a/src/components/insights/__tests__/recovery-empty-state.test.tsx +++ b/src/components/insights/__tests__/recovery-empty-state.test.tsx @@ -3,16 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; -import { queryKeys } from "@/lib/query-keys"; import type { DataSummary } from "@/lib/analytics/trends"; /** - * The recovery page's empty note says what is missing for THIS account. An - * Oura ring fills the page through its resilience level alone, so a connected - * ring with an empty page asks for a reconnect only when the server recorded - * that the grant leaves resilience out, and otherwise says only that no - * level has arrived. The connection facts come from the server's - * integrations read; the page only picks the sentence, and waits for it. + * The recovery page's empty note says what is missing for THIS account, and + * the reason is the server's (`recoveryEmpty` on the analytics slice): the + * page only picks the sentence for it and never reads the connection status + * itself. Every reason offers the same way on, the integrations settings. */ vi.mock("next/dynamic", () => ({ @@ -50,26 +47,24 @@ function summary(count: number): DataSummary { }; } -/** An Oura-only account: readiness, HRV and sleep, no resilience level. */ +/** An Oura-only account: readiness, HRV and sleep, no recovery type. */ const OURA_SUMMARIES = { SLEEP_DURATION: summary(30), RECOVERY_SCORE: summary(30), HRV_RMSSD: summary(30), }; -function render( - oura: Record | null, - opts: { statusLoaded?: boolean } = {}, -) { +function render(recoveryEmpty: string | null) { + analyticsMock.mockReturnValue({ + data: { summaries: OURA_SUMMARIES, recoveryEmpty }, + isLoading: false, + isEmpty: false, + error: null, + refetch: () => {}, + }); const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + defaultOptions: { queries: { retry: false, enabled: false } }, }); - if (opts.statusLoaded !== false) { - queryClient.setQueryData(queryKeys.integrationsStatus(), { - threshold: 3, - integrations: oura ? [{ integration: "oura", ...oura }] : [], - }); - } return renderToStaticMarkup( @@ -79,67 +74,24 @@ function render( ); } -function health(unavailable: Array<{ item: string; cause: string }>) { - return { - state: unavailable.length > 0 ? "limited" : "ok", - reason: unavailable.length > 0 ? "permission_limited" : "data_arriving", - unavailable, - }; -} - beforeEach(() => { analyticsMock.mockReset(); - analyticsMock.mockReturnValue({ - data: { summaries: OURA_SUMMARIES }, - isLoading: false, - isEmpty: false, - error: null, - refetch: () => {}, - }); }); describe(" empty note", () => { - it("asks for the heart-health permission when the grant withholds resilience", () => { - const html = render({ - connected: true, - health: health([{ item: "resilience", cause: "permission" }]), - }); - expect(html).toContain('data-empty-case="oura-permission"'); - expect(html).toContain("allow heart-health access"); + it.each([ + ["oura_permission", "heart-health access"], + ["oura_no_resilience", "as soon as Oura provides one"], + ["general", "WHOOP or Polar"], + ])("renders the server's %s reason with the one action", (reason, text) => { + const html = render(reason); + expect(html).toContain(`data-empty-case="${reason}"`); + expect(html).toContain(text); expect(html).toContain('href="/settings/integrations"'); }); - it("says only that no level has arrived when no withheld permission is recorded", () => { - const html = render({ - connected: true, - health: health([{ item: "spo2", cause: "permission" }]), - }); - expect(html).toContain('data-empty-case="oura-no-resilience"'); - expect(html).toContain("no resilience level has arrived"); - // The server cannot prove the grant covers resilience, so the note - // never says it does. - expect(html).not.toContain("permission covers"); - expect(html).not.toContain('href="/settings/integrations"'); - }); - - it("stays neutral when the connection carries no health block", () => { - const html = render({ connected: true }); - expect(html).toContain('data-empty-case="oura-no-resilience"'); - expect(html).not.toContain("permission covers"); - }); - - it("shows no note until the connection status has loaded", () => { - const html = render( - { connected: true, health: health([]) }, - { statusLoaded: false }, - ); - expect(html).toContain('data-slot="recovery-loading"'); + it("shows no note when the server says the page has data", () => { + const html = render(null); expect(html).not.toContain("data-empty-case"); }); - - it("keeps the general note for an account without Oura", () => { - const html = render(null); - expect(html).toContain('data-empty-case="general"'); - expect(html).toContain("WHOOP and Polar record"); - }); }); diff --git a/src/components/insights/recovery/recovery-section.tsx b/src/components/insights/recovery/recovery-section.tsx index 4a2718ade..060b067ab 100644 --- a/src/components/insights/recovery/recovery-section.tsx +++ b/src/components/insights/recovery/recovery-section.tsx @@ -16,11 +16,10 @@ import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query"; import { useTranslations } from "@/lib/i18n/context"; import { EmptyState } from "@/components/ui/empty-state"; import { Button } from "@/components/ui/button"; -import { - pickStatus, - useIntegrationStatuses, - type IntegrationStatusViewModel, -} from "@/components/settings/integrations/shared"; +import type { + RECOVERY_SUMMARY_TYPES, + RecoveryEmptyReason, +} from "@/lib/insights/recovery-empty"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { RestModeBanner } from "@/components/insights/rest-mode-banner"; import type { RestModeAnnotation } from "@/lib/analytics/score/types"; @@ -30,7 +29,8 @@ import { RecoveryMetricBlock } from "@/components/insights/recovery/recovery-met import { ResilienceTile } from "@/components/insights/recovery/resilience-tile"; interface BlockSpec { - type: string; + /** One of the types the server's empty decision counts, by construction. */ + type: (typeof RECOVERY_SUMMARY_TYPES)[number]; chartKey: ChartOverlayKey; statusMetric: MetricStatusMetricId; title: string; @@ -47,32 +47,14 @@ interface BlockGroup { } /** - * Why this account's recovery page is empty, from the server's view of its - * Oura connection. An Oura ring feeds this page through its resilience level - * alone. When the server records that the grant leaves resilience out, the - * note asks for one reconnect. Otherwise it says only that no level has - * arrived: the server cannot tell "the grant covers it" from "not recorded - * yet" (the scope note is cleared on every reconnect and written only by a - * sync that reached Oura), so the note never claims coverage. Everyone else - * gets the general note naming what fills the page. + * The empty note per server-resolved reason (`recoveryEmpty` on the + * analytics slice). The page never works the reason out itself. */ -type RecoveryEmptyCase = "general" | "oura-permission" | "oura-no-resilience"; - const EMPTY_COPY = { general: "insights.recovery.empty", - "oura-permission": "insights.recovery.emptyOuraPermission", - "oura-no-resilience": "insights.recovery.emptyOuraNoResilience", -} as const satisfies Record; - -function recoveryEmptyCase( - oura: IntegrationStatusViewModel | undefined, -): RecoveryEmptyCase { - if (!oura?.connected) return "general"; - const withheld = oura.health?.unavailable.some( - (u) => u.item === "resilience" && u.cause === "permission", - ); - return withheld ? "oura-permission" : "oura-no-resilience"; -} + oura_permission: "insights.recovery.emptyOuraPermission", + oura_no_resilience: "insights.recovery.emptyOuraNoResilience", +} as const satisfies Record; /** * v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery` @@ -204,28 +186,11 @@ export function RecoverySection() { }, ]; - // v1.19.2 — resilience is an ordinal band (limited … exceptional), surfaced - // as a calm dedicated tile rather than a chart block, so it counts toward the - // data gate independently of `groups` (a chart-block list). - const hasResilience = (summaries?.["RESILIENCE"]?.count ?? 0) > 0; + // Whether the page has anything to show, and why not, is the server's + // answer (`recoveryEmpty`), so web and iOS read one decision. + const emptyReason = data?.recoveryEmpty ?? null; - const hasAny = - summaries != null && - (hasResilience || - groups.some((group) => - group.blocks.some((block) => (summaries[block.type]?.count ?? 0) > 0), - )); - - // Only an empty page asks which connection could fill it, so a page with - // data never pays for the integrations read. - const statuses = useIntegrationStatuses( - !isLoading && summaries != null && !hasAny, - ); - const emptyCase = recoveryEmptyCase(pickStatus(statuses.data, "oura")); - - // The empty note waits for the connection status, so it never reads as - // the general note first and then changes under the reader. - if (isLoading || summaries == null || (!hasAny && statuses.isPending)) { + if (isLoading || summaries == null) { return (
@@ -233,22 +198,20 @@ export function RecoverySection() { ); } - if (!hasAny) { + if (emptyReason) { return ( } title={t("insights.recovery.emptyTitle")} - description={t(EMPTY_COPY[emptyCase])} + description={t(EMPTY_COPY[emptyReason])} action={ - emptyCase === "oura-permission" ? ( - - ) : undefined + } /> ); diff --git a/src/lib/insights/__tests__/recovery-empty.test.ts b/src/lib/insights/__tests__/recovery-empty.test.ts new file mode 100644 index 000000000..58ed90a3b --- /dev/null +++ b/src/lib/insights/__tests__/recovery-empty.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveRecoveryEmpty } from "../recovery-empty"; + +/** + * The recovery page's empty reason is decided here, on the server, from the + * summaries and the Oura connection; clients render the answer. + */ +describe("resolveRecoveryEmpty", () => { + const none = { connected: false, scopeDenied: [] }; + + it("has no reason when any recovery type has a reading", () => { + expect(resolveRecoveryEmpty({ RESILIENCE: { count: 1 } }, none)).toBeNull(); + expect(resolveRecoveryEmpty({ DAY_STRAIN: { count: 3 } }, none)).toBeNull(); + }); + + it("does not count types the page does not chart", () => { + expect(resolveRecoveryEmpty({ RECOVERY_SCORE: { count: 30 } }, none)).toBe( + "general", + ); + }); + + it("asks for a reconnect only when the grant leaves resilience out", () => { + expect( + resolveRecoveryEmpty( + {}, + { connected: true, scopeDenied: ["resilience"] }, + ), + ).toBe("oura_permission"); + expect( + resolveRecoveryEmpty({}, { connected: true, scopeDenied: ["spo2"] }), + ).toBe("oura_no_resilience"); + }); +}); diff --git a/src/lib/insights/recovery-empty-read.ts b/src/lib/insights/recovery-empty-read.ts new file mode 100644 index 000000000..75c22ae82 --- /dev/null +++ b/src/lib/insights/recovery-empty-read.ts @@ -0,0 +1,27 @@ +import { prisma } from "@/lib/db"; +import { readOuraScopeDenied } from "@/lib/oura/credentials"; +import { + hasRecoveryData, + resolveRecoveryEmpty, + type RecoveryEmptyReason, +} from "./recovery-empty"; + +/** + * The recovery page's empty reason for one account. Reads the Oura + * connection only when no recovery type has a reading, so a page with data + * never pays for it. + */ +export async function loadRecoveryEmpty( + userId: string, + summaries: Record, +): Promise { + if (hasRecoveryData(summaries)) return null; + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { ouraAccessTokenEncrypted: true, ouraScopeDenied: true }, + }); + return resolveRecoveryEmpty(summaries, { + connected: !!user?.ouraAccessTokenEncrypted, + scopeDenied: readOuraScopeDenied(user?.ouraScopeDenied), + }); +} diff --git a/src/lib/insights/recovery-empty.ts b/src/lib/insights/recovery-empty.ts new file mode 100644 index 000000000..c824d300e --- /dev/null +++ b/src/lib/insights/recovery-empty.ts @@ -0,0 +1,60 @@ +/** + * Why the recovery page is empty, decided once on the server. + * + * The page charts the device-native recovery signals below. When none of + * them has a reading, the analytics slice publishes the reason beside the + * summaries (`recoveryEmpty`), and every client renders from it rather than + * working it out from the connection status itself: + * + * - `oura_permission`: Oura is connected and the stored grant leaves out + * the scope its resilience level sits behind; one reconnect fixes it. + * - `oura_no_resilience`: Oura is connected and no level has arrived. The + * server cannot tell "the grant covers it" from "not recorded yet" (the + * scope note is cleared on every reconnect and written only by a sync + * that reached Oura), so this case never claims coverage. + * - `general`: anyone else. + * + * `null` means the page has something to chart. + * + * Pure, so the page can share the type list it charts from. + */ + +/** Every summary type the recovery page charts or shows as a tile. */ +export const RECOVERY_SUMMARY_TYPES = [ + "ANS_CHARGE", + "DAY_STRAIN", + "WORKOUT_STRAIN", + "CARDIO_LOAD", + "AVERAGE_HEART_RATE", + "MAX_HEART_RATE", + "ENERGY_EXPENDITURE_KJ", + "RESILIENCE", +] as const; + +export const RECOVERY_EMPTY_REASONS = [ + "general", + "oura_permission", + "oura_no_resilience", +] as const; + +export type RecoveryEmptyReason = (typeof RECOVERY_EMPTY_REASONS)[number]; + +/** True when any recovery type has a reading. */ +export function hasRecoveryData( + summaries: Record, +): boolean { + return RECOVERY_SUMMARY_TYPES.some( + (type) => (summaries[type]?.count ?? 0) > 0, + ); +} + +export function resolveRecoveryEmpty( + summaries: Record, + oura: { connected: boolean; scopeDenied: readonly string[] }, +): RecoveryEmptyReason | null { + if (hasRecoveryData(summaries)) return null; + if (!oura.connected) return "general"; + return oura.scopeDenied.includes("resilience") + ? "oura_permission" + : "oura_no_resilience"; +} diff --git a/src/lib/openapi/routes/analytics.ts b/src/lib/openapi/routes/analytics.ts index bbdda4373..5bbe0f52a 100644 --- a/src/lib/openapi/routes/analytics.ts +++ b/src/lib/openapi/routes/analytics.ts @@ -18,6 +18,7 @@ * to the derived contract cannot leave this document behind. */ import { z } from "zod/v4"; +import { recoveryEmptySchema } from "./recovery-empty"; import type { ZodOpenApiObject } from "zod-openapi"; import { SCORE_PILLAR_IDS } from "@/lib/analytics/score/types"; @@ -140,11 +141,12 @@ const analyticsSummariesSlice = z ), lastSeenByType, sleepSourceDiscrepancy, + recoveryEmpty: recoveryEmptySchema, }) .meta({ id: "AnalyticsSummariesSlice", description: - "What `?slice=summaries` answers: the per-type summaries and their freshness, and nothing thick. Four keys, and the caller gets them from two SQL passes rather than the default slice's chain.", + "What `?slice=summaries` answers: the per-type summaries and their freshness, the recovery page's empty reason, and nothing thick. Five keys, and the caller gets them from two SQL passes rather than the default slice's chain.", }); // ── Correlations ───────────────────────────────────────────────────── diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index c18ba6769..61bbf1870 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -5,6 +5,7 @@ * Schemas come from `src/lib/validations/*` where shared with the * runtime request parsing, so the wire contract stays single-source. */ +import { recoveryEmptySchema } from "../recovery-empty"; import { trendSentiment, trendVerdict } from "../trend-verdict"; import { z } from "zod/v4"; import { measurementSourceEnum, measurementTypeEnum } from "../shared"; @@ -2032,6 +2033,7 @@ export const analyticsSummariesSliceResponse = z ), lastSeenByType: z.record(z.string(), lastSeenSlot), sleepSourceDiscrepancy, + recoveryEmpty: recoveryEmptySchema, }) .meta({ id: "AnalyticsSummariesSlice", diff --git a/src/lib/openapi/routes/recovery-empty.ts b/src/lib/openapi/routes/recovery-empty.ts new file mode 100644 index 000000000..ae89f5fc2 --- /dev/null +++ b/src/lib/openapi/routes/recovery-empty.ts @@ -0,0 +1,10 @@ +import { z } from "zod/v4"; +import { RECOVERY_EMPTY_REASONS } from "@/lib/insights/recovery-empty"; + +/** Why the recovery page is empty; shared by both slim-slice schemas. */ +export const recoveryEmptySchema = z + .enum(RECOVERY_EMPTY_REASONS) + .nullable() + .describe( + "Why the recovery page has nothing to chart, resolved on the server; null when any recovery type (ANS_CHARGE, DAY_STRAIN, WORKOUT_STRAIN, CARDIO_LOAD, AVERAGE_HEART_RATE, MAX_HEART_RATE, ENERGY_EXPENDITURE_KJ, RESILIENCE) has a reading. `oura_permission`: Oura is connected and its grant leaves out the scope the resilience level needs, one reconnect fixes it. `oura_no_resilience`: Oura is connected and no level has arrived yet. `general`: anyone else. Clients render the empty note from this value and never work it out from the connection status.", + ); diff --git a/src/types/analytics.ts b/src/types/analytics.ts index f0628c71d..16e9e91bc 100644 --- a/src/types/analytics.ts +++ b/src/types/analytics.ts @@ -33,6 +33,7 @@ * surface. Three named shapes capture the contract; one swiss-army * shape loses it. */ +import type { RecoveryEmptyReason } from "@/lib/insights/recovery-empty"; import type { DataSummary } from "@/lib/analytics/trends"; import type { SleepSourceDiscrepancy } from "@/lib/analytics/sleep-night"; import type { CorrelationResult } from "@/lib/insights/correlations"; @@ -53,6 +54,11 @@ export interface SubPageAnalyticsData { string, { lastSeenAt: string; daysAgo: number } | null >; + /** + * Why the recovery page has nothing to chart, resolved on the server + * (`resolveRecoveryEmpty`); null when it has. Slim slice only. + */ + recoveryEmpty?: RecoveryEmptyReason | null; } /**