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 ;
+}
diff --git a/src/components/insights/sleep/sleep-quality-section.tsx b/src/components/insights/sleep/sleep-quality-section.tsx
index f791f4862..51363ba9a 100644
--- a/src/components/insights/sleep/sleep-quality-section.tsx
+++ b/src/components/insights/sleep/sleep-quality-section.tsx
@@ -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 (
))}
- }
- enabled={hasSleepScore}
- />
+
);
}
+
+/**
+ * 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 ``) 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;
+}) {
+ 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 (
+ <>
+ }
+ // 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}
+ />
+
+ >
+ );
+}