Merge branch 'fix/v1436-oura' into release/v1.43.6

This commit is contained in:
Marc-André Bombeck committed 2026-10-19 17:04:29 +02:00
commit 09c93cd547
16 files changed
+707 -6

No files matched your search

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
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 is either a grant that leaves out the heart-health
* data the level sits behind, or Oura not having sent a level yet. The
* connection facts come from the server's integrations read; the page only
* picks the sentence.
*/
vi.mock("next/dynamic", () => ({
default: () => {
const Stub = () => <div data-slot="mini-chart-stub" />;
Stub.displayName = "MiniChartStub";
return Stub;
},
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({ user: { timezone: "UTC" }, isAuthenticated: true }),
}));
const analyticsMock = vi.fn();
vi.mock("@/hooks/use-insights-analytics", () => ({
useInsightsAnalytics: () => analyticsMock(),
}));
import { RecoverySection } from "../recovery/recovery-section";
function summary(count: number): DataSummary {
return {
count,
latest: null,
min: null,
max: null,
mean: null,
median: null,
avg7: null,
avg30: null,
slope7: null,
slope30: null,
anomalyCount: 0,
};
}
/** An Oura-only account: readiness, HRV and sleep, no resilience level. */
const OURA_SUMMARIES = {
SLEEP_DURATION: summary(30),
RECOVERY_SCORE: summary(30),
HRV_RMSSD: summary(30),
};
function render(oura: Record<string, unknown> | null) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
});
queryClient.setQueryData(queryKeys.integrationsStatus(), {
threshold: 3,
integrations: oura ? [{ integration: "oura", ...oura }] : [],
});
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<RecoverySection />
</I18nProvider>
</QueryClientProvider>,
);
}
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");
expect(html).toContain('href="/settings/integrations"');
});
it("says Oura has sent no level yet when the grant covers it", () => {
const html = render({
connected: true,
health: health([{ item: "spo2", cause: "permission" }]),
});
expect(html).toContain('data-empty-case="oura-no-resilience"');
expect(html).toContain("has not sent a resilience level");
expect(html).not.toContain('href="/settings/integrations"');
});
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");
});
});
@@ -67,6 +67,35 @@ describe("<ScoreAnatomyView>", () => {
expect(html).not.toContain("absolute top-3 right-3");
});
it("names why a contributor is missing when the caller knows", () => {
const html = render(
<ScoreAnatomyView
title="Sleep score"
score={70}
contributors={[
{ key: "sufficiency", label: "Sufficiency", value: 90, weight: 1 },
{
key: "consistency",
label: "Consistency",
value: null,
weight: 0,
missingLabel: "not enough nights yet (2 of 3)",
},
{ key: "composition", label: "Composition", value: null, weight: 0 },
]}
coverage={COVERAGE}
confidence={CONFIDENCE}
provenance={PROVENANCE}
method="m"
/>,
);
expect(html).toContain("not enough nights yet (2 of 3)");
// A missing contributor without a reason keeps the general note.
expect(html).toContain("no data");
// Neither reads as a 0 score.
expect(html).not.toMatch(/>0, weighted/);
});
it("tints the card + leans the ring hue when a hue is passed (F1)", () => {
const html = render(
<ScoreAnatomyView
@@ -238,6 +238,14 @@ export function CompositeScoreAnatomy({
label: t(`insights.derived.composite.SLEEP_SCORE.sub.${s.key}`),
value: s.value,
weight: s.weight,
missingLabel:
s.gap === "few_nights"
? t("insights.derived.composite.SLEEP_SCORE.gap.fewNights", {
count: v.rhythmNights ?? 0,
})
: s.gap === "no_main_sleep"
? t("insights.derived.composite.SLEEP_SCORE.gap.noMainSleep")
: undefined,
}))
.sort(rankByImpact);
} else if (metric === "READINESS") {
@@ -58,6 +58,12 @@ export interface AnatomyContributor {
value: number | null;
/** Effective weight share after null-redistribution, 0..1. */
weight: number;
/**
* What a missing contributor still needs, when the server says why it is
* missing (the sleep score's rhythm sub-scores). Shown in place of the
* generic "no data".
*/
missingLabel?: string;
}
export interface ScoreAnatomyViewProps {
@@ -238,7 +244,8 @@ function ContributorRow({ contributor }: { contributor: AnatomyContributor }) {
value: Math.round(clamped),
weight: weightPct,
})
: t("insights.derived.anatomy.contributorMissing")}
: (contributor.missingLabel ??
t("insights.derived.anatomy.contributorMissing"))}
</span>
</div>
<div
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import {
Activity,
Battery,
@@ -14,6 +15,12 @@ import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
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 { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { RestModeBanner } from "@/components/insights/rest-mode-banner";
import type { RestModeAnnotation } from "@/lib/analytics/score/types";
@@ -39,6 +46,33 @@ interface BlockGroup {
blocks: BlockSpec[];
}
/**
* 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, so a connected ring with an empty page has exactly two causes: the
* grant does not cover the heart-health data the level sits behind (the
* server records that per collection and publishes it as an unavailable
* item), or Oura has not sent a level for this account. Everyone else gets
* the general note naming what fills the page.
*/
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";
}
/**
* v1.17.1 → rebuilt v1.18.1 (B1–B3) — body of the `/insights/recovery`
* sub-page.
@@ -181,6 +215,13 @@ export function RecoverySection() {
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"));
if (isLoading || summaries == null) {
return (
<div className="space-y-3" data-slot="recovery-loading">
@@ -193,9 +234,19 @@ export function RecoverySection() {
return (
<EmptyState
data-slot="recovery-empty"
data-empty-case={emptyCase}
icon={<Heart className="size-6" />}
title={t("insights.recovery.emptyTitle")}
description={t("insights.recovery.empty")}
description={t(EMPTY_COPY[emptyCase])}
action={
emptyCase === "oura-permission" ? (
<Button size="sm" variant="outline" asChild>
<Link href="/settings/integrations">
{t("insights.recovery.emptyAction")}
</Link>
</Button>
) : undefined
}
/>
);
}