From aa9b713a669de313377dda1e4c02842ac36d88d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 21:17:00 +0200 Subject: [PATCH] feat(sleep): ground the sleep-quality assessment in the user's own scores MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../sleep-quality-assessment.test.ts | 68 +++++++++ .../sleep-quality-grounded-note.test.tsx | 67 +++++++++ .../sleep/sleep-quality-assessment.ts | 136 ++++++++++++++++++ .../sleep/sleep-quality-grounded-note.tsx | 112 +++++++++++++++ .../insights/sleep/sleep-quality-section.tsx | 67 +++++++-- 5 files changed, 438 insertions(+), 12 deletions(-) create mode 100644 src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts create mode 100644 src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx create mode 100644 src/components/insights/sleep/sleep-quality-assessment.ts create mode 100644 src/components/insights/sleep/sleep-quality-grounded-note.tsx diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts new file mode 100644 index 000000000..ec27b8be4 --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts @@ -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(); + }); +}); diff --git a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx new file mode 100644 index 000000000..f02cfc52d --- /dev/null +++ b/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx @@ -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( + {node}, + ); +} + +describe("", () => { + const summaries = { + SLEEP_SCORE: summary(82), + SLEEP_EFFICIENCY: summary(88), + }; + + it("renders a substantive, grounded assessment when the AI is absent", () => { + const html = render( + , + ); + 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( + , + ); + expect(html).toBe(""); + }); + + it("renders nothing when no quality metric is gradable", () => { + const html = render( + , + ); + expect(html).toBe(""); + }); +}); diff --git a/src/components/insights/sleep/sleep-quality-assessment.ts b/src/components/insights/sleep/sleep-quality-assessment.ts new file mode 100644 index 000000000..b2799a3d3 --- /dev/null +++ b/src/components/insights/sleep/sleep-quality-assessment.ts @@ -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([ + "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 = { + 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( + (worst, f) => (GRADE_RANK[f.grade] < GRADE_RANK[worst] ? f.grade : worst), + "excellent", + ); + + return { + lead: sorted[0], + rest: sorted.slice(1), + overall, + }; +} diff --git a/src/components/insights/sleep/sleep-quality-grounded-note.tsx b/src/components/insights/sleep/sleep-quality-grounded-note.tsx new file mode 100644 index 000000000..11a1000a3 --- /dev/null +++ b/src/components/insights/sleep/sleep-quality-grounded-note.tsx @@ -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 { + 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 | 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 ( + + + + + +

+ {clauses.join(" ")} {closing} +

+
+
+ ); +} + +function QualityNoteIcon({ className }: { className?: string }) { + return