mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
fix(insights): merge the sleep page's two assessments into one
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.
This commit is contained in:
1 parent
f599ba9a92
commit
73ec05de26
12 files changed
+17
-600
No files matched your search
+1
-22
@@ -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",
|
||||
|
||||
+1
-22
@@ -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",
|
||||
|
||||
+1
-22
@@ -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",
|
||||
|
||||
+1
-22
@@ -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",
|
||||
|
||||
+1
-22
@@ -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",
|
||||
|
||||
+1
-22
@@ -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",
|
||||
|
||||
@@ -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 (`<SleepOverview>`), 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 (`<SleepOverview>`).
|
||||
|
||||
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.
|
||||
*/}
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_DURATION"
|
||||
@@ -136,8 +139,8 @@ export default function InsightsSchlafPage() {
|
||||
disturbance count + the Oura sleep score) that were ingested but never
|
||||
rendered. Each tile is data-gated, so the block is invisible for
|
||||
non-wearable users and collapses metric-by-metric. Server-authoritative
|
||||
— the tiles render stored values. v1.18.1 — the block carries its own
|
||||
`<MetricStatusCard metric="SLEEP_SCORE">` assessment internally.
|
||||
— the tiles render stored values. v1.25 — score tiles only; the page's
|
||||
single assessment is the SLEEP_DURATION card above.
|
||||
*/}
|
||||
<SleepQualitySection enabled={!isEmpty} />
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
<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("");
|
||||
});
|
||||
});
|
||||
@@ -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<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,
|
||||
};
|
||||
}
|
||||
@@ -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 | 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,14 +3,10 @@
|
||||
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`.
|
||||
@@ -128,75 +124,6 @@ export function SleepQualitySection({ enabled }: { enabled: boolean }) {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SleepQualityAssessmentBlock summaries={summaries} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The quality block's "Einschätzung".
|
||||
*
|
||||
* v1.18.7 W-D — the assessment slot was broken: the previous wiring disabled
|
||||
* the status fetch whenever the AI narrative was absent, so the card could not
|
||||
* read `hasProvider` and fell back to its `hasProvider: false` default — it
|
||||
* showed the "connect an AI provider" CTA even for accounts that HAVE a
|
||||
* provider connected (the text just hadn't been generated yet), AND it stacked
|
||||
* that CTA under the grounded note. Two assessments, one of them wrong.
|
||||
*
|
||||
* The card now stays enabled (gated only on the metric having data), so it
|
||||
* reports the truthful state from the route:
|
||||
* - no provider → the route's `hasProvider: false` → the card's own
|
||||
* clean "connect an AI provider → AI settings" CTA,
|
||||
* ALONE (the grounded note stands down).
|
||||
* - provider, no text yet → the grounded note fills the slot with a read
|
||||
* built from the user's own quality averages, so the
|
||||
* block is never the bare "no assessment yet" line.
|
||||
* - provider, text/prep → the AI card owns the slot; the grounded note
|
||||
* stands down so the two never stack.
|
||||
*
|
||||
* The grounded note reads the SAME SLEEP_SCORE status query the card warmed, so
|
||||
* it adds no second round-trip.
|
||||
*/
|
||||
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 grounded note only steps in when a provider IS connected but the AI
|
||||
// narrative has SETTLED with no text (and none is being assembled). When no
|
||||
// provider is configured the AI card owns the slot with its own CTA, so the
|
||||
// note never stacks under it; when the AI text exists or is preparing the AI
|
||||
// card wins. Until the status query settles we show nothing extra, so the
|
||||
// note never flashes ahead of a warm AI assessment.
|
||||
const showGroundedNote =
|
||||
hasSleepScore &&
|
||||
status?.hasProvider === true &&
|
||||
!statusLoading &&
|
||||
!status?.text &&
|
||||
status?.preparing !== true &&
|
||||
status?.revalidating !== true;
|
||||
|
||||
// Exactly one block fills the assessment slot, so the two never stack: the
|
||||
// grounded note when a provider is connected but the AI text has settled
|
||||
// empty, otherwise the status card (which owns the no-provider CTA, the
|
||||
// preparing state, and the real text). The card's hook fetched regardless of
|
||||
// which branch renders — it runs at the top of this component — so swapping
|
||||
// its render for the note loses no `hasProvider` knowledge.
|
||||
if (showGroundedNote) {
|
||||
return <SleepQualityGroundedNote summaries={summaries} showWhenAiAbsent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MetricStatusCard
|
||||
metric="SLEEP_SCORE"
|
||||
icon={<Moon className="h-5 w-5" />}
|
||||
enabled={hasSleepScore}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user