From 73ec05de2696cf2eae0c7e4b0ee542e2a4950594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 28 Jun 2026 15:13:32 +0200 Subject: [PATCH] fix(insights): merge the sleep page's two assessments into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sleep page rendered an assessment under the duration/architecture charts and a second one under the sleep-quality scores. The quality assessment mostly never generated — most accounts carry no wearable sleep score — so it sat at the foot of the page as a duplicate empty card or a redundant connect-a-provider prompt. Drop the quality-block assessment so the duration card is the page's single assessment; the quality block keeps its score tiles. Remove the now-unused grounded-note fallback and its deterministic grading helper, and the orphaned quality-assessment message keys across every locale. --- messages/de.json | 23 +-- messages/en.json | 23 +-- messages/es.json | 23 +-- messages/fr.json | 23 +-- messages/it.json | 23 +-- messages/pl.json | 23 +-- src/app/insights/sleep/page.tsx | 19 +-- .../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 | 116 --------------- .../insights/sleep/sleep-quality-section.tsx | 73 ---------- 12 files changed, 17 insertions(+), 600 deletions(-) delete mode 100644 src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts delete mode 100644 src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx delete mode 100644 src/components/insights/sleep/sleep-quality-assessment.ts delete mode 100644 src/components/insights/sleep/sleep-quality-grounded-note.tsx diff --git a/messages/de.json b/messages/de.json index e2576c2c3..146bbed3a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Schlafqualität", - "subtitle": "Nächtliche Werte deines Wearables", - "assessment": { - "metric": { - "SLEEP_SCORE": "dein Gesamt-Schlafscore", - "SLEEP_EFFICIENCY": "die Schlafeffizienz", - "SLEEP_PERFORMANCE": "die Schlafleistung", - "SLEEP_CONSISTENCY": "die Schlafkonstanz" - }, - "grade": { - "excellent": "ausgezeichnet", - "good": "solide", - "fair": "durchschnittlich", - "low": "eher niedrig" - }, - "clause": "Über die letzten 30 Nächte ist {metric} mit {value} {grade}.", - "closing": { - "excellent": "Behalte die Routine bei, die dich hierher bringt.", - "good": "Konstantere Zubett- und Aufstehzeiten sind meist der nächste Gewinn.", - "fair": "Ein konstantes Schlaffenster hebt diesen Wert erfahrungsgemäß am stärksten.", - "low": "Ein festes Herunterfahren am Abend und eine feste Aufstehzeit sind die ersten Hebel." - } - } + "subtitle": "Nächtliche Werte deines Wearables" }, "recovery": { "title": "Erholung", diff --git a/messages/en.json b/messages/en.json index dc3001c27..23ae4f2b7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Sleep quality", - "subtitle": "Nightly scores from your wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "your overall sleep score", - "SLEEP_EFFICIENCY": "sleep efficiency", - "SLEEP_PERFORMANCE": "sleep performance", - "SLEEP_CONSISTENCY": "sleep consistency" - }, - "grade": { - "excellent": "excellent", - "good": "solid", - "fair": "fair", - "low": "on the low side" - }, - "clause": "Over the last 30 nights {metric} is {grade} at {value}.", - "closing": { - "excellent": "Keep the routine that is getting you here.", - "good": "A steadier bed and wake time is the usual next gain.", - "fair": "Protecting a consistent sleep window tends to lift this most.", - "low": "A regular wind-down and a fixed wake time are the first levers to pull." - } - } + "subtitle": "Nightly scores from your wearable" }, "recovery": { "title": "Recovery", diff --git a/messages/es.json b/messages/es.json index 410d5be30..e2f12b186 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Calidad del sueño", - "subtitle": "Puntuaciones nocturnas de tu wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "tu puntuación global de sueño", - "SLEEP_EFFICIENCY": "la eficiencia del sueño", - "SLEEP_PERFORMANCE": "el rendimiento del sueño", - "SLEEP_CONSISTENCY": "la regularidad del sueño" - }, - "grade": { - "excellent": "excelente", - "good": "sólida", - "fair": "aceptable", - "low": "más bien baja" - }, - "clause": "En las últimas 30 noches {metric} es {grade}, en {value}.", - "closing": { - "excellent": "Mantén la rutina que te trae hasta aquí.", - "good": "Acostarte y levantarte a horas más estables suele ser la siguiente mejora.", - "fair": "Proteger una franja de sueño constante es lo que más suele subir esto.", - "low": "Una rutina de relajación regular y una hora fija para despertar son las primeras palancas." - } - } + "subtitle": "Puntuaciones nocturnas de tu wearable" }, "recovery": { "title": "Recuperación", diff --git a/messages/fr.json b/messages/fr.json index 35bccb939..5b953e533 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Qualité du sommeil", - "subtitle": "Scores nocturnes de votre wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "votre score de sommeil global", - "SLEEP_EFFICIENCY": "l’efficacité du sommeil", - "SLEEP_PERFORMANCE": "la performance de sommeil", - "SLEEP_CONSISTENCY": "la régularité du sommeil" - }, - "grade": { - "excellent": "excellente", - "good": "solide", - "fair": "correcte", - "low": "plutôt faible" - }, - "clause": "Sur les 30 dernières nuits, {metric} est {grade}, à {value}.", - "closing": { - "excellent": "Gardez la routine qui vous mène jusque-là.", - "good": "Des heures de coucher et de lever plus stables sont souvent le prochain gain.", - "fair": "Protéger une fenêtre de sommeil constante est ce qui fait le plus progresser ce point.", - "low": "Un rituel d’endormissement régulier et une heure de réveil fixe sont les premiers leviers." - } - } + "subtitle": "Scores nocturnes de votre wearable" }, "recovery": { "title": "Récupération", diff --git a/messages/it.json b/messages/it.json index a79038358..1c446bda3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Qualità del sonno", - "subtitle": "Punteggi notturni del tuo wearable", - "assessment": { - "metric": { - "SLEEP_SCORE": "il tuo punteggio di sonno complessivo", - "SLEEP_EFFICIENCY": "l’efficienza del sonno", - "SLEEP_PERFORMANCE": "la prestazione del sonno", - "SLEEP_CONSISTENCY": "la regolarità del sonno" - }, - "grade": { - "excellent": "eccellente", - "good": "solida", - "fair": "discreta", - "low": "piuttosto bassa" - }, - "clause": "Nelle ultime 30 notti {metric} è {grade}, a {value}.", - "closing": { - "excellent": "Mantieni la routine che ti porta fin qui.", - "good": "Orari di sonno e risveglio più stabili sono di solito il prossimo guadagno.", - "fair": "Proteggere una finestra di sonno costante è ciò che alza di più questo valore.", - "low": "Una routine serale regolare e un orario di sveglia fisso sono le prime leve." - } - } + "subtitle": "Punteggi notturni del tuo wearable" }, "recovery": { "title": "Recupero", diff --git a/messages/pl.json b/messages/pl.json index dc40c714a..cb6fd0d43 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3606,28 +3606,7 @@ }, "sleepQuality": { "title": "Jakość snu", - "subtitle": "Nocne wyniki z Twojego urządzenia", - "assessment": { - "metric": { - "SLEEP_SCORE": "Twój ogólny wynik snu", - "SLEEP_EFFICIENCY": "efektywność snu", - "SLEEP_PERFORMANCE": "wydajność snu", - "SLEEP_CONSISTENCY": "regularność snu" - }, - "grade": { - "excellent": "doskonała", - "good": "solidna", - "fair": "przeciętna", - "low": "raczej niska" - }, - "clause": "W ciągu ostatnich 30 nocy {metric} jest {grade} i wynosi {value}.", - "closing": { - "excellent": "Trzymaj się rutyny, która Cię tu doprowadziła.", - "good": "Bardziej stałe pory zasypiania i wstawania to zwykle następny zysk.", - "fair": "Utrzymanie stałego okna snu zwykle podnosi ten wynik najbardziej.", - "low": "Regularne wyciszenie wieczorem i stała pora pobudki to pierwsze dźwignie." - } - } + "subtitle": "Nocne wyniki z Twojego urządzenia" }, "recovery": { "title": "Regeneracja", diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx index fda0a981f..cb261e133 100644 --- a/src/app/insights/sleep/page.tsx +++ b/src/app/insights/sleep/page.tsx @@ -104,12 +104,15 @@ export default function InsightsSchlafPage() { the HealthKit scaffold (Moon vs Sparkles). v1.18.1 — moved up to sit DIRECTLY with the duration/architecture chart - group it describes (``), rather than trailing the whole - page. The sleep-quality block below now carries its OWN assessment, so - each distinct chart group reads its own matching text — the canonical - "multiple charts ⇒ multiple texts" rule the recovery page meets. The - card mounts only on this data-bearing branch, so a source-less account - never fetches. + group it describes (``). + + v1.25 — this is now the page's SINGLE assessment. The sleep-quality + block below used to carry a second SLEEP_SCORE assessment that mostly + never generated (no wearable score), leaving a duplicate "connect an AI + provider" / empty card at the foot of the page. That bottom assessment + is gone; the quality block keeps its score tiles only. The card mounts + only on this data-bearing branch, so a source-less account never + fetches. */} ` assessment internally. + — the tiles render stored values. v1.25 — score tiles only; the page's + single assessment is the SLEEP_DURATION card above. */} diff --git a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts b/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts deleted file mode 100644 index ec27b8be4..000000000 --- a/src/components/insights/sleep/__tests__/sleep-quality-assessment.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index f02cfc52d..000000000 --- a/src/components/insights/sleep/__tests__/sleep-quality-grounded-note.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -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 deleted file mode 100644 index b2799a3d3..000000000 --- a/src/components/insights/sleep/sleep-quality-assessment.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * 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 deleted file mode 100644 index 0c8311e0b..000000000 --- a/src/components/insights/sleep/sleep-quality-grounded-note.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"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