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

@@ -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>
}
/>
);