fix(recovery): publish why the recovery page is empty from the server

The page worked out its empty note on the client from the integrations
status, so every client had to repeat the rule. The analytics summaries
slice now carries `recoveryEmpty` (general, oura_permission or
oura_no_resilience, null when any recovery type has a reading), decided
once in resolveRecoveryEmpty from the gated summaries and the stored
Oura grant. The page renders that value only, and its block list is
typed to the server's recovery types so the two cannot drift.

Each reason now reads as one sentence in every locale, and every reason
offers the same way on, the integrations settings.
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 05:01:18 +02:00
1 parent 0ea8880923
commit 862361a224
20 files changed
+243 -171

No files matched your search

@@ -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();
+4
View File
@@ -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;
@@ -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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
@@ -204,21 +197,20 @@ describe("<SleepQualitySection> data-gating", () => {
describe("<RecoverySection> 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(<RecoverySection />);
expect(html).toContain('data-slot="recovery-empty"');
// Routed through the shared <EmptyState> (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&#x27;s readiness appears as your recovery score",
);
expect(html).toContain("WHOOP or Polar");
});
it("renders a canonical chart block for the present signal", () => {
@@ -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<string, unknown> | 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(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
@@ -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("<RecoverySection> 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");
});
});
@@ -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<RecoveryEmptyCase, string>;
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<RecoveryEmptyReason, string>;
/**
* 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 (
<div className="space-y-3" data-slot="recovery-loading">
<ChartSkeleton />
@@ -233,22 +198,20 @@ export function RecoverySection() {
);
}
if (!hasAny) {
if (emptyReason) {
return (
<EmptyState
data-slot="recovery-empty"
data-empty-case={emptyCase}
data-empty-case={emptyReason}
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t(EMPTY_COPY[emptyCase])}
description={t(EMPTY_COPY[emptyReason])}
action={
emptyCase === "oura-permission" ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
) : undefined
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
}
/>
);
@@ -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");
});
});
+27
View File
@@ -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<string, { count?: number } | null | undefined>,
): Promise<RecoveryEmptyReason | null> {
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),
});
}
+60
View File
@@ -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<string, { count?: number } | null | undefined>,
): boolean {
return RECOVERY_SUMMARY_TYPES.some(
(type) => (summaries[type]?.count ?? 0) > 0,
);
}
export function resolveRecoveryEmpty(
summaries: Record<string, { count?: number } | null | undefined>,
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";
}
+3 -1
View File
@@ -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 ─────────────────────────────────────────────────────
@@ -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",
+10
View File
@@ -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.",
);
+6
View File
@@ -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;
}
/**