feat(sleep): ground the sleep-quality assessment in the user's own scores

The sleep-quality "Einschätzung" read "no assessment yet" until an AI provider
had run, and stayed blank on an account with no provider configured. Fill that
slot with a deterministic note built from the user's own quality averages —
efficiency, performance, consistency and the headline score graded against
recognised reference bands — so the assessment is always substantive. The
richer AI narrative still wins when present; the grounded note only steps in
once the assessment query settles with no narrative, so the two never stack.
This commit is contained in:
Marc-André Bombeck committed 2026-06-18 21:17:00 +02:00
1 parent d8c139698c
commit aa9b713a66
5 files changed
+438 -12

No files matched your search

@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { buildSleepQualityAssessment } from "../sleep-quality-assessment";
/**
* v1.18.6 — the deterministic sleep-quality grading that backs the grounded
* "Einschätzung" when no AI narrative exists. Pins the band thresholds and the
* worst-grade-wins overall, and the "skip ungradable metrics" contract.
*/
describe("buildSleepQualityAssessment", () => {
it("grades efficiency on the clinical 90 / 85 / 75 floors", () => {
expect(
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 92 }])
?.lead.grade,
).toBe("excellent");
expect(
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 86 }])
?.lead.grade,
).toBe("good");
expect(
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 78 }])
?.lead.grade,
).toBe("fair");
expect(
buildSleepQualityAssessment([{ type: "SLEEP_EFFICIENCY", value: 60 }])
?.lead.grade,
).toBe("low");
});
it("grades 0–100 scores on the 90 / 85 / 70 split", () => {
expect(
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 95 }])?.lead
.grade,
).toBe("excellent");
expect(
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: 72 }])?.lead
.grade,
).toBe("fair");
});
it("opens on the headline score and keeps worst-grade-wins overall", () => {
const a = buildSleepQualityAssessment([
{ type: "SLEEP_CONSISTENCY", value: 60 },
{ type: "SLEEP_SCORE", value: 95 },
{ type: "SLEEP_EFFICIENCY", value: 92 },
]);
expect(a?.lead.type).toBe("SLEEP_SCORE");
// The low consistency pulls the overall down even though the score is high.
expect(a?.overall).toBe("low");
expect(a?.rest.length).toBe(2);
});
it("skips metrics it has no recognised band for, and returns null on none", () => {
// SLEEP_NEED (minutes) + disturbance count are not gradable here.
expect(
buildSleepQualityAssessment([
{ type: "SLEEP_NEED", value: 480 },
{ type: "SLEEP_DISTURBANCE_COUNT", value: 3 },
]),
).toBeNull();
});
it("ignores non-finite values", () => {
expect(
buildSleepQualityAssessment([{ type: "SLEEP_SCORE", value: Number.NaN }]),
).toBeNull();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { SleepQualityGroundedNote } from "../sleep-quality-grounded-note";
import type { DataSummary } from "@/lib/analytics/trends";
/**
* v1.18.6 — the grounded sleep-quality note that fills the assessment slot
* when no AI narrative is available. Pins that it builds substantive copy from
* the user's own averages and that it self-suppresses when the AI is present.
*/
function summary(avg30: number): DataSummary {
return {
count: 30,
latest: avg30,
avg7: avg30,
avg30,
} as unknown as DataSummary;
}
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("<SleepQualityGroundedNote>", () => {
const summaries = {
SLEEP_SCORE: summary(82),
SLEEP_EFFICIENCY: summary(88),
};
it("renders a substantive, grounded assessment when the AI is absent", () => {
const html = render(
<SleepQualityGroundedNote
summaries={summaries}
showWhenAiAbsent={true}
/>,
);
expect(html).toContain('data-slot="sleep-quality-grounded-note"');
// The headline score (82 → fair on the 90/85/70 split) leads the read.
expect(html).toContain("overall sleep score");
expect(html).toContain("fair");
expect(html).toContain("82");
});
it("renders nothing when the AI assessment is present", () => {
const html = render(
<SleepQualityGroundedNote
summaries={summaries}
showWhenAiAbsent={false}
/>,
);
expect(html).toBe("");
});
it("renders nothing when no quality metric is gradable", () => {
const html = render(
<SleepQualityGroundedNote
summaries={{ SLEEP_NEED: summary(480) }}
showWhenAiAbsent={true}
/>,
);
expect(html).toBe("");
});
});
@@ -0,0 +1,136 @@
/**
* v1.18.6 — deterministic, data-grounded sleep-quality assessment.
*
* The AI "Einschätzung" under the sleep-quality block reads "no assessment
* yet / unavailable" until a provider has run (and never at all on an account
* with no AI provider). That left the most universally useful read — the one
* the user's OWN numbers already support — blank. This pure helper turns the
* sleep-quality summaries that the section already has in hand into a short,
* grounded sentence: it names the night-over-night averages and grades them
* against recognised reference ranges, with NO provider call and NO recompute
* of a stored value (it only reads the server-authoritative averages).
*
* Grading thresholds are the conventional consumer-sleep-platform bands:
* - Sleep efficiency: ≥ 85 % is the textbook "good sleeper" floor (AASM);
* ≥ 90 % is excellent, < 75 % is poor.
* - Performance / consistency / headline score: 0–100, graded on the
* standard 85 / 70 split most rings use (good / fair / low).
* The copy is intentionally cautious — it describes, it never diagnoses.
*
* Kept free of React + i18n so the grading is unit-testable in isolation; the
* component maps the returned shape onto localised strings.
*/
export type QualityGrade = "excellent" | "good" | "fair" | "low";
export interface SleepQualityMetricRead {
/** Registry/measurement type, e.g. `SLEEP_EFFICIENCY`. */
type: string;
/** The averaged value the section already holds (avg30 ?? avg7 ?? latest). */
value: number;
}
export interface SleepQualityFinding {
type: string;
value: number;
grade: QualityGrade;
}
export interface SleepQualityAssessment {
/** The lead metric the assessment opens on (best-known quality signal). */
lead: SleepQualityFinding;
/** The remaining graded metrics, most-informative first. */
rest: SleepQualityFinding[];
/** Worst grade across the present metrics — drives the closing nudge. */
overall: QualityGrade;
}
/** Percent-scaled 0–100 metrics graded on the 90 / 85 / 70 ring split. */
const PERCENT_METRICS = new Set<string>([
"SLEEP_SCORE",
"SLEEP_PERFORMANCE",
"SLEEP_CONSISTENCY",
]);
/** Efficiency uses the clinical 90 / 85 / 75 floors rather than the ring split. */
function gradeEfficiency(pct: number): QualityGrade {
if (pct >= 90) return "excellent";
if (pct >= 85) return "good";
if (pct >= 75) return "fair";
return "low";
}
function gradeScore(value: number): QualityGrade {
if (value >= 90) return "excellent";
if (value >= 85) return "good";
if (value >= 70) return "fair";
return "low";
}
/**
* Grade one quality metric. Returns null for a metric the helper has no
* recognised band for (e.g. raw disturbance counts or sleep-need minutes),
* so the assessment never grades a value against a scale it does not fit.
*/
function gradeMetric(type: string, value: number): QualityGrade | null {
if (type === "SLEEP_EFFICIENCY") return gradeEfficiency(value);
if (PERCENT_METRICS.has(type)) return gradeScore(value);
return null;
}
/** Worst grade wins — a single low signal pulls the overall read down. */
const GRADE_RANK: Record<QualityGrade, number> = {
excellent: 3,
good: 2,
fair: 1,
low: 0,
};
/**
* Lead-metric preference: the headline score reads first when present, then
* efficiency, then performance, then consistency. Keeps the opening sentence
* anchored on the most holistic signal the night carries.
*/
const LEAD_ORDER = [
"SLEEP_SCORE",
"SLEEP_EFFICIENCY",
"SLEEP_PERFORMANCE",
"SLEEP_CONSISTENCY",
] as const;
/**
* Build a deterministic assessment from the present quality metrics. Returns
* null when none of the supplied metrics is gradable, so the caller falls back
* to its existing copy rather than printing an empty read.
*/
export function buildSleepQualityAssessment(
reads: readonly SleepQualityMetricRead[],
): SleepQualityAssessment | null {
const findings: SleepQualityFinding[] = [];
for (const r of reads) {
if (!Number.isFinite(r.value)) continue;
const grade = gradeMetric(r.type, r.value);
if (grade == null) continue;
findings.push({ type: r.type, value: r.value, grade });
}
if (findings.length === 0) return null;
const leadIndex = (type: string): number => {
const i = LEAD_ORDER.indexOf(type as (typeof LEAD_ORDER)[number]);
return i === -1 ? Number.MAX_SAFE_INTEGER : i;
};
const sorted = [...findings].sort(
(a, b) => leadIndex(a.type) - leadIndex(b.type),
);
const overall = sorted.reduce<QualityGrade>(
(worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst),
"excellent",
);
return {
lead: sorted[0],
rest: sorted.slice(1),
overall,
};
}
@@ -0,0 +1,112 @@
"use client";
import { Moon } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import type { DataSummary } from "@/lib/analytics/trends";
import {
buildSleepQualityAssessment,
type QualityGrade,
type SleepQualityFinding,
} from "./sleep-quality-assessment";
/**
* v1.18.6 — the grounded sleep-quality "Einschätzung".
*
* The AI assessment under the sleep-quality block reads "no assessment yet"
* until a provider has run, and stays blank forever on an account with no AI
* provider. This card fills that gap with a substantive read built ENTIRELY
* from the user's own quality averages (efficiency / performance / consistency
* / headline score) graded against recognised reference bands — no provider,
* no recompute of a stored value. It describes; it does not diagnose.
*
* Mounted only when the AI assessment is NOT present (`showWhenAiAbsent`), so
* the richer AI narrative still wins when it exists and the user never sees
* two assessments stacked.
*/
const QUALITY_TYPES = [
"SLEEP_SCORE",
"SLEEP_EFFICIENCY",
"SLEEP_PERFORMANCE",
"SLEEP_CONSISTENCY",
] as const;
/** Map a finding to its localised "{metric} is {grade} at {value}" clause. */
function clauseFor(
finding: SleepQualityFinding,
t: (key: string, vars?: Record<string, string | number>) => string,
): string {
const metricLabel = t(`insights.sleepQuality.assessment.metric.${finding.type}`);
const gradeLabel = t(`insights.sleepQuality.assessment.grade.${finding.grade}`);
// Efficiency / performance / consistency are percentages; the headline score
// is a 0–100 index. Both read naturally as a rounded integer.
const value = Math.round(finding.value);
return t("insights.sleepQuality.assessment.clause", {
metric: metricLabel,
grade: gradeLabel,
value,
});
}
export interface SleepQualityGroundedNoteProps {
/** The analytics summaries the section already holds (no extra fetch). */
summaries: Record<string, DataSummary | undefined> | undefined;
/**
* Whether the AI assessment is absent. The grounded note only renders when
* the AI narrative is not there, so the two never stack.
*/
showWhenAiAbsent: boolean;
}
export function SleepQualityGroundedNote({
summaries,
showWhenAiAbsent,
}: SleepQualityGroundedNoteProps) {
const { t } = useTranslations();
if (!showWhenAiAbsent || !summaries) return null;
const reads = QUALITY_TYPES.map((type) => {
const s = summaries[type];
const value = s?.avg30 ?? s?.avg7 ?? s?.latest ?? null;
return value == null
? null
: ({ type, value } as { type: string; value: number });
}).filter((r): r is { type: string; value: number } => r !== null);
const assessment = buildSleepQualityAssessment(reads);
if (!assessment) return null;
const clauses = [assessment.lead, ...assessment.rest].map((f) =>
clauseFor(f, t),
);
const closing = t(
`insights.sleepQuality.assessment.closing.${assessment.overall as QualityGrade}`,
);
return (
<Card
data-slot="sleep-quality-grounded-note"
className="gap-1.5 py-4 md:py-5"
>
<CardHeader className="pb-1">
<TileHeader
icon={QualityNoteIcon}
title={t("insights.assessmentTitle")}
/>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-muted-foreground text-sm leading-relaxed">
{clauses.join(" ")} {closing}
</p>
</CardContent>
</Card>
);
}
function QualityNoteIcon({ className }: { className?: string }) {
return <Moon className={className} aria-hidden="true" />;
}
@@ -3,11 +3,14 @@
import { Activity, Gauge, Moon, Repeat, Target, Waves } from "lucide-react";
import { useInsightsAnalytics } from "@/hooks/use-insights-analytics";
import { useInsightMetricStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import type { DataSummary } from "@/lib/analytics/trends";
import { SectionHeading } from "@/components/insights/section-heading";
import { DeviceScoreTile } from "@/components/insights/device-score-tile";
import { DeviceScoreGridSkeleton } from "@/components/insights/device-score-tile-skeleton";
import { MetricStatusCard } from "@/components/insights/metric-status-card";
import { SleepQualityGroundedNote } from "@/components/insights/sleep/sleep-quality-grounded-note";
/**
* v1.17.1 — "Sleep quality" block on `/insights/sleep`.
@@ -101,13 +104,6 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
);
if (present.length === 0) return null;
// v1.18.1 — the quality block is a distinct chart group, so it carries its
// OWN assessment (matching the canonical "multiple charts ⇒ multiple texts"
// rule the recovery page meets). Gated on the nightly headline sleep score
// having data so the generic assessment route only fires when there is a
// quality series to read.
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
return (
<section data-slot="sleep-quality-section" className="space-y-3">
<SectionHeading
@@ -132,11 +128,58 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
/>
))}
</div>
<MetricStatusCard
metric="SLEEP_SCORE"
icon={<Moon className="h-5 w-5" />}
enabled={hasSleepScore}
/>
<SleepQualityAssessmentBlock summaries={summaries} />
</section>
);
}
/**
* The quality block's "Einschätzung". The richer AI narrative wins when it
* exists; until then (or forever, on an account with no AI provider) a
* deterministic note grounded in the user's own quality averages fills the
* slot, so the assessment is never the bare "no analysis yet" line.
*
* Both reads are gated on the headline sleep score having data — the same gate
* the AI route used — so the assessment only surfaces with a quality series to
* describe. The grounded note reads the SLEEP_SCORE status query (already
* warmed by `<MetricStatusCard>`) to know whether the AI text is absent, so it
* adds no second round-trip and the two never stack.
*/
function SleepQualityAssessmentBlock({
summaries,
}: {
summaries: Record<string, DataSummary | undefined>;
}) {
const hasSleepScore = (summaries.SLEEP_SCORE?.count ?? 0) > 0;
const { data: status, isLoading: statusLoading } = useInsightMetricStatus(
"SLEEP_SCORE",
hasSleepScore,
);
// The AI narrative is "present" when there is text OR the worker is actively
// assembling one (preparing / revalidating). In those cases the AI card owns
// the slot. The grounded note only steps in once the status query has SETTLED
// with no narrative — so it never flashes ahead of a warm AI assessment.
const aiAbsent =
!statusLoading &&
!status?.text &&
status?.preparing !== true &&
status?.revalidating !== true;
return (
<>
<MetricStatusCard
metric="SLEEP_SCORE"
icon={<Moon className="h-5 w-5" />}
// Suppress the AI card's static "no analysis yet" / "no provider"
// empty states — the grounded note covers that ground with real
// content. The AI card still renders its text / preparing states.
enabled={hasSleepScore && !aiAbsent}
/>
<SleepQualityGroundedNote
summaries={summaries}
showWhenAiAbsent={hasSleepScore && aiAbsent}
/>
</>
);
}