mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
fix(sleep): drop the misleading last-night timeline for estimated stages
The last-night card drew a stepped stage timeline for every source. For a source that reports per-stage totals without measured onsets (WHOOP / Polar), the server lays the segments out in a fixed physiological order, so the bar invents a phase progression that was never recorded — it reads as one Core block then one Deep block, not a real hypnogram. Suppress the timeline when the night is reconstructed and keep the per-stage breakdown + totals, which are real. A calm note explains that stage timing is estimated for that source. Real-series nights (Apple Health, Withings, Fitbit) keep the measured timeline unchanged.
This commit is contained in:
1 parent
ae3091df9e
commit
c69e6deb67
8 files changed
+67
-8
No files matched your search
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Geschlafen {value}",
|
||||
"inBedTotal": "Im Bett {value}",
|
||||
"awakenings": "{count} Wachphasen",
|
||||
"source": "Quelle: {source}"
|
||||
"source": "Quelle: {source}",
|
||||
"estimateNote": "Die Phasen-Zeiten sind geschätzt — diese Quelle liefert Summen je Phase, keinen gemessenen Verlauf."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Tief",
|
||||
|
||||
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Asleep {value}",
|
||||
"inBedTotal": "In bed {value}",
|
||||
"awakenings": "{count} awakenings",
|
||||
"source": "Source: {source}"
|
||||
"source": "Source: {source}",
|
||||
"estimateNote": "Stage timing is estimated — this source reports totals per stage, not a measured timeline."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Deep",
|
||||
|
||||
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Dormido {value}",
|
||||
"inBedTotal": "En la cama {value}",
|
||||
"awakenings": "{count} despertares",
|
||||
"source": "Fuente: {source}"
|
||||
"source": "Fuente: {source}",
|
||||
"estimateNote": "Los tiempos de cada fase son estimados: esta fuente informa totales por fase, no una cronología medida."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profundo",
|
||||
|
||||
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Sommeil {value}",
|
||||
"inBedTotal": "Au lit {value}",
|
||||
"awakenings": "{count} réveils",
|
||||
"source": "Source : {source}"
|
||||
"source": "Source : {source}",
|
||||
"estimateNote": "Les durées des phases sont estimées : cette source indique des totaux par phase, pas une chronologie mesurée."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profond",
|
||||
|
||||
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Sonno {value}",
|
||||
"inBedTotal": "A letto {value}",
|
||||
"awakenings": "{count} risvegli",
|
||||
"source": "Fonte: {source}"
|
||||
"source": "Fonte: {source}",
|
||||
"estimateNote": "I tempi delle fasi sono stimati: questa sorgente riporta i totali per fase, non una cronologia misurata."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Profondo",
|
||||
|
||||
+2
-1
@@ -2547,7 +2547,8 @@
|
||||
"asleepTotal": "Sen {value}",
|
||||
"inBedTotal": "W łóżku {value}",
|
||||
"awakenings": "{count} przebudzeń",
|
||||
"source": "Źródło: {source}"
|
||||
"source": "Źródło: {source}",
|
||||
"estimateNote": "Czasy faz są szacowane — to źródło podaje sumy dla każdej fazy, a nie zmierzony przebieg."
|
||||
},
|
||||
"stages": {
|
||||
"deep": "Głęboki",
|
||||
|
||||
@@ -127,6 +127,30 @@ describe("<SleepHypnogram> — timeline bar visibility", () => {
|
||||
expect(render(session)).toContain('role="img"');
|
||||
});
|
||||
|
||||
it("suppresses the timeline bar for a SYNTHESISED (reconstructed) night", () => {
|
||||
// WHOOP / Polar report per-stage TOTALS and the server lays the segments
|
||||
// out in a fixed physiological order — the timeline would invent a phase
|
||||
// progression that was never measured. So even with distinct, well-timed
|
||||
// spans, a `reconstructed` night drops the bar and keeps the breakdown.
|
||||
const session: SleepHypnogramSession = {
|
||||
...baseSession,
|
||||
source: "WHOOP",
|
||||
reconstructed: true,
|
||||
};
|
||||
const html = render(session);
|
||||
expect(html).not.toContain('role="img"');
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-breakdown"');
|
||||
expect(html).toContain('data-slot="sleep-hypnogram-estimate-note"');
|
||||
});
|
||||
|
||||
it("keeps the timeline bar for a measured (non-reconstructed) night", () => {
|
||||
// An Apple Health night carries real per-stage onsets, so the stepped bar
|
||||
// is genuine and stays. The estimate note never appears.
|
||||
const html = render({ ...baseSession, reconstructed: false });
|
||||
expect(html).toContain('role="img"');
|
||||
expect(html).not.toContain('data-slot="sleep-hypnogram-estimate-note"');
|
||||
});
|
||||
|
||||
it("collapses to the breakdown footer for a degenerate single-instant session", () => {
|
||||
// Every segment stamped on ONE instant — no real timeline, so only the
|
||||
// breakdown footer, no bar.
|
||||
|
||||
@@ -58,6 +58,17 @@ export interface SleepHypnogramSession {
|
||||
awakenings: number;
|
||||
stages: Record<string, number>;
|
||||
segments: SleepHypnogramSegment[];
|
||||
/**
|
||||
* `true` when the winning source has no measured per-stage onset
|
||||
* timestamps and the server synthesised a contiguous timeline in a fixed
|
||||
* physiological order (WHOOP / Polar). The "timeline" of such a night is
|
||||
* not a real hypnogram — it would read as one long Core block followed by
|
||||
* one Deep block, never the phase-to-phase progression a measured night
|
||||
* shows — so we never draw the misleading stepped bar for it and fall back
|
||||
* to the per-stage breakdown alone. A real-series source (Apple Health,
|
||||
* Withings, Fitbit) is `false` and keeps the timeline.
|
||||
*/
|
||||
reconstructed?: boolean;
|
||||
}
|
||||
|
||||
export interface SleepHypnogramProps {
|
||||
@@ -216,10 +227,16 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
// breakdown footer. (Gating on the END instant instead hid the bar for an
|
||||
// ordinary multi-stage night whose segments happened to share a right
|
||||
// edge — the regression this restores.)
|
||||
// A SYNTHESISED night (WHOOP / Polar) has no measured stage onsets — the
|
||||
// server laid its segments out in a fixed physiological order, so drawing
|
||||
// the stepped bar would invent a phase progression that was never recorded
|
||||
// (the "Core block then Deep block, no real breakdown" the bar reads as).
|
||||
// For those we keep the per-stage breakdown + totals, which ARE real, and
|
||||
// drop the misleading timeline entirely. Real-series sources keep the bar.
|
||||
const hasTrack =
|
||||
session.reconstructed !== true &&
|
||||
spans.length > 0 &&
|
||||
(spans.length === 1 ||
|
||||
new Set(spans.map((s) => s.x1)).size > 1);
|
||||
(spans.length === 1 || new Set(spans.map((s) => s.x1)).size > 1);
|
||||
const hasBreakdown = breakdown.length > 0;
|
||||
|
||||
return (
|
||||
@@ -251,6 +268,18 @@ export function SleepHypnogram({ session }: SleepHypnogramProps) {
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
{/* When the source reports stage TOTALS but no measured onsets
|
||||
(WHOOP / Polar) the stepped timeline is suppressed — drawing one
|
||||
would invent a phase order that was never recorded. Surface a calm
|
||||
note so the breakdown below doesn't read as a missing chart. */}
|
||||
{session.reconstructed === true && hasBreakdown ? (
|
||||
<span
|
||||
data-slot="sleep-hypnogram-estimate-note"
|
||||
className="text-muted-foreground/70 text-xs"
|
||||
>
|
||||
{t("insights.sleep.hypnogram.estimateNote")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
|
||||
Reference in new issue
Block a user