mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'fix/v1436-oura' into release/v1.43.6
This commit is contained in:
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ const sleepScore: SleepScoreValue = {
|
||||
{ key: "composition", value: 88, weight: 0.15 },
|
||||
],
|
||||
windowNights: 20,
|
||||
rhythmNights: 20,
|
||||
series: [78, 82],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* The Sleep Score's rhythm sub-scores (consistency, timing) for a ring wearer
|
||||
* whose recent nights come from Oura while the phone's Apple Health history is
|
||||
* still being read from years back. The Oura month carries the periods Oura
|
||||
* really returns: a `long_sleep` most nights, short `sleep` naps, a `late_nap`
|
||||
* in the evening, a `rest` period the person rejected, and two days on which
|
||||
* the ring recorded no night at all and only a daytime nap. Rows go through
|
||||
* the real Oura mapper, so the fixture has the shape the sync writes.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: { findMany: vi.fn() },
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/tz/resolver", () => ({
|
||||
resolveUserTimezone: vi.fn().mockResolvedValue("Europe/Berlin"),
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { mapSleep, type OuraSleep } from "@/lib/oura/client";
|
||||
import {
|
||||
computeSleepScore,
|
||||
reconstructNights,
|
||||
rhythmGap,
|
||||
scoreConsistency,
|
||||
} from "../sleep-score";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const NOW = new Date("2026-10-01T09:00:00Z");
|
||||
const PROFILE = { ageYears: 40, sex: "MALE" as const };
|
||||
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
interface Row {
|
||||
value: number;
|
||||
measuredAt: Date;
|
||||
sleepStage: string | null;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
/** A 5-minute hypnogram: awake at both ends, light / deep / REM between. */
|
||||
function hypnogram(minutes: number): string {
|
||||
const n = Math.round(minutes / 5);
|
||||
const body = "2221133322224222113332";
|
||||
let s = "444";
|
||||
for (let i = 0; i < n - 5; i += 1) s += body[i % body.length];
|
||||
return s + "44";
|
||||
}
|
||||
|
||||
function period(
|
||||
id: string,
|
||||
type: string,
|
||||
start: Date,
|
||||
minutes: number,
|
||||
): OuraSleep {
|
||||
const end = new Date(start.getTime() + minutes * 60_000);
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
day: end.toISOString().slice(0, 10),
|
||||
bedtime_start: start.toISOString(),
|
||||
bedtime_end: end.toISOString(),
|
||||
sleep_phase_5_min: hypnogram(minutes),
|
||||
efficiency: 90,
|
||||
};
|
||||
}
|
||||
|
||||
/** Thirty Oura days ending the morning of NOW, plus Apple Health from 2024. */
|
||||
function fixture(): Row[] {
|
||||
const periods: OuraSleep[] = [];
|
||||
for (let d = 1; d <= 30; d += 1) {
|
||||
// Bedtime 23:15 to 00:05 Berlin (21:15 to 22:05 UTC), eight hours.
|
||||
const bed = new Date(Date.UTC(2026, 8, d, 21, 15 + ((d * 17) % 50)));
|
||||
const noon = (h: number, m = 0) => new Date(Date.UTC(2026, 8, d + 1, h, m));
|
||||
if (d === 9 || d === 21) {
|
||||
// The ring spent the night on the charger: the day has only a nap.
|
||||
periods.push(period(`only-${d}`, "sleep", noon(11), 60));
|
||||
} else {
|
||||
periods.push(period(`night-${d}`, "long_sleep", bed, 480));
|
||||
}
|
||||
if (d % 5 === 0) periods.push(period(`nap-${d}`, "sleep", noon(10), 40));
|
||||
if (d % 7 === 0)
|
||||
periods.push(period(`late-${d}`, "late_nap", noon(15, 30), 45));
|
||||
if (d % 6 === 0) periods.push(period(`rest-${d}`, "rest", noon(11), 90));
|
||||
}
|
||||
const oura = periods.flatMap((p) =>
|
||||
mapSleep(p, TZ)
|
||||
.filter((m) => m.type === "SLEEP_DURATION")
|
||||
.map((m) => ({
|
||||
value: m.value,
|
||||
measuredAt: m.measuredAt,
|
||||
sleepStage: m.sleepStage ?? null,
|
||||
source: "OURA",
|
||||
deviceType: null,
|
||||
})),
|
||||
);
|
||||
// The Watch history the phone is still working through, two years back.
|
||||
const apple: Row[] = [];
|
||||
for (let d = 1; d <= 20; d += 1) {
|
||||
apple.push({
|
||||
value: 420,
|
||||
measuredAt: new Date(Date.UTC(2024, 8, d, 5, 0)),
|
||||
sleepStage: "CORE",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
});
|
||||
}
|
||||
return [...apple, ...oura];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const rows = fixture();
|
||||
// Honour the window the way the database would.
|
||||
findMany.mockImplementation(
|
||||
async (args: { where: { measuredAt: { gte: Date } } }) =>
|
||||
rows
|
||||
.filter((r) => r.measuredAt >= args.where.measuredAt.gte)
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime()),
|
||||
);
|
||||
});
|
||||
|
||||
describe("Sleep Score rhythm on an Oura month", () => {
|
||||
it("keeps a nap-only day off the clock", () => {
|
||||
const nights = reconstructNights(fixture() as never, TZ).filter(
|
||||
(n) => n.night >= "2026-09-02",
|
||||
);
|
||||
const napOnly = nights.find((n) => n.night === "2026-09-10")!;
|
||||
// The day still counts its nap...
|
||||
expect(napOnly.asleepMinutes).toBeGreaterThan(0);
|
||||
// ...but a nap is no point on the sleep rhythm.
|
||||
expect(napOnly.midpoint).toBeNull();
|
||||
const regular = nights.find((n) => n.night === "2026-09-11")!;
|
||||
expect(regular.midpoint).not.toBeNull();
|
||||
});
|
||||
|
||||
it("scores the consistency of a steady sleeper, not 0", async () => {
|
||||
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
const sub = (k: string) =>
|
||||
result.value.subScores.find((s) => s.key === k)!.value;
|
||||
// Bedtimes wander within 50 minutes: a regular rhythm.
|
||||
expect(sub("consistency")).toBeGreaterThanOrEqual(70);
|
||||
expect(sub("timing")).toBeGreaterThanOrEqual(60);
|
||||
// The Apple Health rows from 2024 never enter the window.
|
||||
for (const call of findMany.mock.calls) {
|
||||
expect(call[0].where.measuredAt.gte.getTime()).toBeGreaterThan(
|
||||
Date.UTC(2026, 7, 31),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("names too few nights as a gap, never as 0", async () => {
|
||||
// Two nights on the ring so far: no rhythm yet.
|
||||
const two = fixture().filter(
|
||||
(r) =>
|
||||
r.source === "OURA" &&
|
||||
r.measuredAt >= new Date("2026-09-29T12:00:00Z") &&
|
||||
r.measuredAt < new Date("2026-10-01T06:00:00Z"),
|
||||
);
|
||||
findMany.mockResolvedValue(two);
|
||||
const result = await computeSleepScore("u1", PROFILE, { now: NOW });
|
||||
expect(result.status).toBe("ok");
|
||||
if (result.status !== "ok") return;
|
||||
expect(result.value.rhythmNights).toBe(2);
|
||||
for (const key of ["consistency", "timing"]) {
|
||||
const s = result.value.subScores.find((x) => x.key === key)!;
|
||||
expect(s.value).toBeNull();
|
||||
expect(s.weight).toBe(0);
|
||||
expect(s.gap).toBe("few_nights");
|
||||
}
|
||||
// A sub-score with a value carries no gap.
|
||||
const sufficiency = result.value.subScores.find(
|
||||
(x) => x.key === "sufficiency",
|
||||
)!;
|
||||
expect(sufficiency.value).not.toBeNull();
|
||||
expect(sufficiency.gap).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names a latest night without a main sleep for timing only", () => {
|
||||
expect(rhythmGap({ key: "timing", value: null }, 20, null)).toBe(
|
||||
"no_main_sleep",
|
||||
);
|
||||
expect(
|
||||
rhythmGap({ key: "consistency", value: null }, 20, null),
|
||||
).toBeUndefined();
|
||||
expect(rhythmGap({ key: "timing", value: 80 }, 20, 180)).toBeUndefined();
|
||||
expect(
|
||||
rhythmGap({ key: "efficiency", value: null }, 1, null),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still reads a truly irregular rhythm as irregular", () => {
|
||||
// Midpoints spread across the night: 01:00, 04:00, 07:00, repeated.
|
||||
const spread = Array.from({ length: 12 }, (_, i) => 60 + (i % 3) * 180);
|
||||
expect(scoreConsistency(spread)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -78,8 +78,22 @@ export interface SleepSubScore {
|
||||
value: number | null;
|
||||
/** Effective weight after null-redistribution, 0..1. */
|
||||
weight: number;
|
||||
/**
|
||||
* Why a rhythm sub-score (consistency, timing) has no value, set only when
|
||||
* `value` is null. `few_nights`: fewer than `RHYTHM_MIN_NIGHTS` nights in
|
||||
* the window have a main sleep to place on the clock. `no_main_sleep`: the
|
||||
* window has enough, but the latest night has none (a nap-only day). A
|
||||
* client names the gap instead of reading it as "no data", and never as 0.
|
||||
*/
|
||||
gap?: SleepSubScoreGap;
|
||||
}
|
||||
|
||||
/** The two reasons a rhythm sub-score is withheld. See `SleepSubScore.gap`. */
|
||||
export type SleepSubScoreGap = "few_nights" | "no_main_sleep";
|
||||
|
||||
/** Nights with a main sleep the rhythm sub-scores need before they score. */
|
||||
export const RHYTHM_MIN_NIGHTS = 3;
|
||||
|
||||
export interface SleepScoreValue {
|
||||
/** The composite 0..100. */
|
||||
score: number;
|
||||
@@ -95,6 +109,12 @@ export interface SleepScoreValue {
|
||||
subScores: SleepSubScore[];
|
||||
/** Nights in the window that backed the consistency/timing baseline. */
|
||||
windowNights: number;
|
||||
/**
|
||||
* Nights in the window whose main sleep places them on the clock — the
|
||||
* nights consistency and timing are computed from. Below
|
||||
* `RHYTHM_MIN_NIGHTS` both are withheld with `gap: "few_nights"`.
|
||||
*/
|
||||
rhythmNights: number;
|
||||
/**
|
||||
* v1.42 — the score of every scorable night in the window (oldest → newest,
|
||||
* capped to `SPARKLINE_MAX_POINTS`), so a surface can show the score's
|
||||
@@ -232,7 +252,7 @@ export function circularMeanMinutes(minutes: number[]): number | null {
|
||||
* not penalised for a spurious ~24-hour spread.
|
||||
*/
|
||||
export function scoreConsistency(midpointMinutes: number[]): number | null {
|
||||
if (midpointMinutes.length < 3) return null;
|
||||
if (midpointMinutes.length < RHYTHM_MIN_NIGHTS) return null;
|
||||
const mean = circularMeanMinutes(midpointMinutes);
|
||||
if (mean == null) return null;
|
||||
const variance =
|
||||
@@ -256,7 +276,11 @@ export function scoreTiming(
|
||||
habitualMidpoint: number | null,
|
||||
windowNights: number,
|
||||
): number | null {
|
||||
if (nightMidpoint == null || habitualMidpoint == null || windowNights < 3) {
|
||||
if (
|
||||
nightMidpoint == null ||
|
||||
habitualMidpoint == null ||
|
||||
windowNights < RHYTHM_MIN_NIGHTS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const FULL_SCALE = 90;
|
||||
@@ -304,6 +328,24 @@ export function blendSleepSubScores(
|
||||
return { score: clamp100(composite), subScores };
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason a rhythm sub-score came out null, or undefined when it has a
|
||||
* value or is not a rhythm sub-score. Consistency needs `RHYTHM_MIN_NIGHTS`
|
||||
* nights on the clock; timing needs those and a main sleep on the latest
|
||||
* night. Pure.
|
||||
*/
|
||||
export function rhythmGap(
|
||||
sub: Pick<SleepSubScore, "key" | "value">,
|
||||
rhythmNights: number,
|
||||
latestMidpoint: number | null,
|
||||
): SleepSubScoreGap | undefined {
|
||||
if (sub.value !== null) return undefined;
|
||||
if (sub.key !== "consistency" && sub.key !== "timing") return undefined;
|
||||
if (rhythmNights < RHYTHM_MIN_NIGHTS) return "few_nights";
|
||||
if (sub.key === "timing" && latestMidpoint == null) return "no_main_sleep";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── night reconstruction ───────────────────────────────────────────────
|
||||
|
||||
interface SleepRow {
|
||||
@@ -398,9 +440,17 @@ export function reconstructNights(
|
||||
// adds its minutes, which put the midpoint of a 23:00 to 07:00 night with a
|
||||
// 14:00 nap at about 11:00 and read every such day as a ~7-hour shift in
|
||||
// consistency and timing.
|
||||
//
|
||||
// And only when that main session is a main sleep. On a day the ring or
|
||||
// watch recorded no night (left on the charger, taken off), the day's
|
||||
// largest session is an afternoon nap, and its 14:00 midpoint sat ten
|
||||
// hours from every other night: a single such day in a month pushed the
|
||||
// midpoint spread past the 90-minute full scale and read the whole
|
||||
// rhythm as Consistency 0. Such a day still counts its minutes; it just
|
||||
// has no place on the clock.
|
||||
const main = n.mainSession;
|
||||
const midpoint =
|
||||
main && main.asleepMinutes > 0
|
||||
main && main.asleepMinutes >= MAIN_SLEEP_MIN_MINUTES
|
||||
? minutesOfDay(
|
||||
new Date(main.end.getTime() - (main.asleepMinutes / 2) * 60_000),
|
||||
tz,
|
||||
@@ -419,6 +469,15 @@ export function reconstructNights(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The least asleep time a day's main session needs before its midpoint is a
|
||||
* point on the sleep rhythm (consistency, timing, chronotype, regularity).
|
||||
* Three hours is where wearables draw the line between a nap and a main
|
||||
* sleep (Oura types a period `long_sleep` from three hours on), and well
|
||||
* above any nap, while a short night still clears it.
|
||||
*/
|
||||
const MAIN_SLEEP_MIN_MINUTES = 180;
|
||||
|
||||
function minutesOfDay(d: Date, tz: string): number {
|
||||
if (tz === "UTC") return d.getUTCHours() * 60 + d.getUTCMinutes();
|
||||
const { hour, minute } = wallClockInTz(d, tz);
|
||||
@@ -554,7 +613,12 @@ export async function computeSleepScore(
|
||||
),
|
||||
};
|
||||
|
||||
const { score, subScores } = blendSleepSubScores(raw);
|
||||
const blended = blendSleepSubScores(raw);
|
||||
const score = blended.score;
|
||||
const subScores = blended.subScores.map((s) => {
|
||||
const gap = rhythmGap(s, midpoints.length, latest.midpoint);
|
||||
return gap ? { ...s, gap } : s;
|
||||
});
|
||||
|
||||
// The same blend over every scorable night in the window. Consistency is a
|
||||
// window-level figure and timing reads against the window's habitual
|
||||
@@ -602,6 +666,7 @@ export async function computeSleepScore(
|
||||
latest.inBedMinutes === null ? null : Math.round(latest.inBedMinutes),
|
||||
subScores,
|
||||
windowNights: scorableNights.length,
|
||||
rhythmNights: midpoints.length,
|
||||
series,
|
||||
},
|
||||
coverage,
|
||||
|
||||
Reference in new issue
Block a user