mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge S1/S2 sleep average + mood saturation into release/v1.19.1
S1: add an average-per-night sleep card as a third peer beside debt and chronotype, computed by a shared helper over the same scorable-night set so the three cannot disagree; grid widens to three columns. S2: restore full saturation on the two missed mood surfaces (calendar heatmap and the time-of-day chart), keeping the quiet tint only for no-data cells.
This commit is contained in:
commit
62a4dfdf7e
16 files changed
+290
-9
No files matched your search
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "Kein aufgelaufenes Defizit über die letzten {nights} Nächte",
|
||||
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für eine verlässliche Bilanz."
|
||||
},
|
||||
"average": {
|
||||
"title": "Durchschnitt pro Nacht",
|
||||
"value": "{hours} Std. {minutes} Min.",
|
||||
"caption": "Mittlerer Schlaf über die letzten {nights} Nächte",
|
||||
"learning": "Dein Schlaf wird noch erfasst — {counted} Nächte bisher, noch {remaining} für einen verlässlichen Durchschnitt."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Dein Rhythmus wird noch erfasst — {counted} von {total} freien Nächten. Ein Chronotyp braucht ein paar weckerfreie Morgen, um aussagekräftig zu sein.",
|
||||
|
||||
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "No accumulated shortfall over the last {nights} nights",
|
||||
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more to read a reliable balance."
|
||||
},
|
||||
"average": {
|
||||
"title": "Average per night",
|
||||
"value": "{hours}h {minutes}m",
|
||||
"caption": "Mean sleep across the last {nights} nights",
|
||||
"learning": "Still learning your sleep — {counted} nights so far, {remaining} more for a reliable average."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Still learning your rhythm — {counted} of {total} free nights. A chronotype needs a few alarm-free mornings before it means anything.",
|
||||
|
||||
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "Sin déficit acumulado en las últimas {nights} noches",
|
||||
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para un balance fiable."
|
||||
},
|
||||
"average": {
|
||||
"title": "Media por noche",
|
||||
"value": "{hours} h {minutes} min",
|
||||
"caption": "Sueño medio en las últimas {nights} noches",
|
||||
"learning": "Aún estamos midiendo tu sueño — {counted} noches hasta ahora, faltan {remaining} para una media fiable."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Aún estamos aprendiendo tu ritmo — {counted} de {total} noches libres. Un cronotipo necesita varias mañanas sin despertador para tener sentido.",
|
||||
|
||||
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "Aucun déficit cumulé sur les {nights} dernières nuits",
|
||||
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour un bilan fiable."
|
||||
},
|
||||
"average": {
|
||||
"title": "Moyenne par nuit",
|
||||
"value": "{hours} h {minutes} min",
|
||||
"caption": "Sommeil moyen sur les {nights} dernières nuits",
|
||||
"learning": "Votre sommeil est encore en cours d'analyse — {counted} nuits jusqu'ici, encore {remaining} pour une moyenne fiable."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotype",
|
||||
"learning": "Votre rythme est encore en cours d'analyse — {counted} sur {total} nuits libres. Un chronotype a besoin de quelques matins sans réveil pour avoir du sens.",
|
||||
|
||||
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "Nessun deficit accumulato nelle ultime {nights} notti",
|
||||
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per un bilancio affidabile."
|
||||
},
|
||||
"average": {
|
||||
"title": "Media per notte",
|
||||
"value": "{hours} h {minutes} min",
|
||||
"caption": "Sonno medio nelle ultime {nights} notti",
|
||||
"learning": "Stiamo ancora misurando il tuo sonno — {counted} notti finora, ne mancano {remaining} per una media affidabile."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Cronotipo",
|
||||
"learning": "Stiamo ancora imparando il tuo ritmo — {counted} di {total} notti libere. Un cronotipo richiede alcune mattine senza sveglia per avere significato.",
|
||||
|
||||
@@ -2587,6 +2587,12 @@
|
||||
"clearCaption": "Brak skumulowanego niedoboru w ostatnich {nights} nocach",
|
||||
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodny bilans."
|
||||
},
|
||||
"average": {
|
||||
"title": "Średnia na noc",
|
||||
"value": "{hours} godz. {minutes} min",
|
||||
"caption": "Średni sen w ciągu ostatnich {nights} nocy",
|
||||
"learning": "Wciąż analizujemy Twój sen — {counted} nocy do tej pory, jeszcze {remaining}, aby uzyskać wiarygodną średnią."
|
||||
},
|
||||
"chronotype": {
|
||||
"title": "Chronotyp",
|
||||
"learning": "Wciąż poznajemy Twój rytm — {counted} z {total} wolnych nocy. Chronotyp wymaga kilku poranków bez budzika, aby coś znaczył.",
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MetricTargetSummary } from "@/components/insights/metric-target-summary
|
||||
import { SleepOverview } from "@/components/insights/sleep-overview";
|
||||
import { SleepRhythmSection } from "@/components/insights/sleep/sleep-rhythm-section";
|
||||
import { ChronotypeSection } from "@/components/insights/sleep/chronotype-section";
|
||||
import { AverageSleepSection } from "@/components/insights/sleep/average-sleep-section";
|
||||
import { SleepQualitySection } from "@/components/insights/sleep/sleep-quality-section";
|
||||
import { SubPageShell } from "@/components/insights/sub-page-shell";
|
||||
|
||||
@@ -123,8 +124,9 @@ export default function InsightsSchlafPage() {
|
||||
viewports and sit side-by-side from `sm` up; each card keeps its own
|
||||
internals (the debt headline and the chronotype band treatment).
|
||||
*/}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SleepRhythmSection enabled={!isEmpty} />
|
||||
<AverageSleepSection enabled={!isEmpty} />
|
||||
<ChronotypeSection enabled={!isEmpty} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -271,7 +271,10 @@ export function MoodHeatmap({
|
||||
height={cellSize}
|
||||
rx={2}
|
||||
fill={cell.color}
|
||||
fillOpacity={cell.cell ? 0.55 : 1}
|
||||
// v1.19.1 — populated days render at full saturation so the
|
||||
// mood band reads clearly; only the no-entry cells stay the
|
||||
// quiet `--secondary` empty-state tint.
|
||||
fillOpacity={1}
|
||||
className="cursor-pointer"
|
||||
onPointerEnter={(e) => {
|
||||
if (e.pointerType === "touch") return;
|
||||
@@ -331,7 +334,7 @@ export function MoodHeatmap({
|
||||
<span key={label} className="flex items-center gap-1">
|
||||
<div
|
||||
className="h-3 w-3 rounded-sm"
|
||||
style={{ backgroundColor: color, opacity: 0.55 }}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
@@ -84,10 +84,9 @@ export function MoodTimeOfDayChart({
|
||||
data={data}
|
||||
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
|
||||
>
|
||||
{/* Muted chart language — same treatment as the weekday/distribution
|
||||
siblings: `--muted-foreground` axis ticks, a quiet comment-grey
|
||||
target guide, and 0.55 bar fill so the level hues inform without
|
||||
shouting. */}
|
||||
{/* Quiet chart language — `--muted-foreground` axis ticks and a
|
||||
comment-grey target guide. The bars themselves render at full
|
||||
saturation (v1.19.1) so the mood level hues read clearly. */}
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
|
||||
@@ -130,7 +129,10 @@ export function MoodTimeOfDayChart({
|
||||
];
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
|
||||
{/* v1.19.1 — populated buckets render at full saturation so the
|
||||
level hues read clearly; only the no-data buckets keep the quiet
|
||||
`--secondary` empty-state tint. */}
|
||||
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
|
||||
{data.map((row) => (
|
||||
<Cell
|
||||
key={row.bucket}
|
||||
@@ -139,6 +141,7 @@ export function MoodTimeOfDayChart({
|
||||
? "var(--secondary)"
|
||||
: colorForScore(row.avgScore)
|
||||
}
|
||||
fillOpacity={row.avgScore == null ? 0.55 : 1}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
|
||||
@@ -50,6 +50,12 @@ const READY_DTO: SleepRhythmDto = {
|
||||
workNightsCounted: 14,
|
||||
freeNightsUntilReady: 0,
|
||||
},
|
||||
averagePerNight: {
|
||||
state: "ready",
|
||||
averageMinutes: 420,
|
||||
nightsCounted: 10,
|
||||
nightsUntilReady: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -58,6 +58,12 @@ const READY_DTO: SleepRhythmDto = {
|
||||
workNightsCounted: 14,
|
||||
freeNightsUntilReady: 0,
|
||||
},
|
||||
averagePerNight: {
|
||||
state: "ready",
|
||||
averageMinutes: 420,
|
||||
nightsCounted: 5,
|
||||
nightsUntilReady: 0,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { BedDouble } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LearningGate } from "@/components/ui/learning-gate";
|
||||
import type { AverageSleepDto } from "./use-sleep-rhythm";
|
||||
|
||||
/** Whole hours + minutes from a minute total, for the headline figure. */
|
||||
function hoursMinutes(totalMinutes: number): {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
} {
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = Math.round(totalMinutes - hours * 60);
|
||||
return { hours, minutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.19.1 — average-sleep-per-night headline.
|
||||
*
|
||||
* The third peer of `<SleepDebtCard>` + `<ChronotypeCard>`: identical card
|
||||
* chrome (icon + `text-base` `CardTitle` header, a `space-y-0.5` block with a
|
||||
* bold `text-2xl` primary value and one `text-xs` muted secondary line) so the
|
||||
* three tiles read uniformly in the shared grid row. The figure is the mean of
|
||||
* the canonical per-night asleep totals over the same scorable-night window the
|
||||
* debt + chronotype read — server-authoritative, never recomputed here. Under
|
||||
* the night floor it shows the calm `partial` state and asserts no average.
|
||||
*/
|
||||
export function AverageSleepCard({ average }: { average: AverageSleepDto }) {
|
||||
const { t } = useTranslations();
|
||||
|
||||
if (average.state === "partial") {
|
||||
return (
|
||||
<Card data-slot="average-sleep-card">
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BedDouble className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.average.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<LearningGate
|
||||
message={t("insights.sleep.average.learning", {
|
||||
counted: average.nightsCounted,
|
||||
remaining: average.nightsUntilReady,
|
||||
})}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { hours, minutes } = hoursMinutes(average.averageMinutes);
|
||||
|
||||
return (
|
||||
<Card data-slot="average-sleep-card">
|
||||
<CardHeader className="pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<BedDouble className="text-info h-4 w-4" />
|
||||
<CardTitle className="text-base font-semibold">
|
||||
{t("insights.sleep.average.title")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-2xl font-semibold tabular-nums">
|
||||
{t("insights.sleep.average.value", { hours, minutes })}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("insights.sleep.average.caption", {
|
||||
nights: average.nightsCounted,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useSleepRhythm } from "./use-sleep-rhythm";
|
||||
import { AverageSleepCard } from "./average-sleep-card";
|
||||
|
||||
/**
|
||||
* v1.19.1 — average-sleep-per-night card on the Sleep view.
|
||||
*
|
||||
* The third peer of `<SleepRhythmSection>` (sleep debt) + `<ChronotypeSection>`
|
||||
* in the shared grid row. Reads the SAME server-authoritative DTO via
|
||||
* `useSleepRhythm`, so it shares the `["sleep-rhythm"]` cache the siblings
|
||||
* already warmed — no extra round-trip. Gated on `enabled` so a source-less
|
||||
* account never fetches.
|
||||
*
|
||||
* States mirror the sibling sections: loading → skeleton, error → quiet inline
|
||||
* notice, settled → the card (which carries its own calm "still learning" copy
|
||||
* for the no-nights-yet case, since the route always returns a full DTO).
|
||||
*/
|
||||
export function AverageSleepSection({ enabled }: { enabled: boolean }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
const { data, isLoading, isError } = useSleepRhythm(
|
||||
isAuthenticated && enabled,
|
||||
);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div
|
||||
data-slot="average-sleep-error"
|
||||
role="status"
|
||||
className="bg-card border-border text-muted-foreground rounded-xl border p-4 text-sm"
|
||||
>
|
||||
{t("insights.sleep.rhythm.loadError")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Skeleton
|
||||
data-slot="average-sleep-loading"
|
||||
className="h-28 w-full rounded-xl"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <AverageSleepCard average={data.averagePerNight} />;
|
||||
}
|
||||
@@ -11,10 +11,11 @@ import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type {
|
||||
SleepDebtDto,
|
||||
ChronotypeDto,
|
||||
AverageSleepDto,
|
||||
SleepRhythmDto,
|
||||
} from "@/lib/insights/derived/sleep-rhythm";
|
||||
|
||||
export type { SleepDebtDto, ChronotypeDto, SleepRhythmDto };
|
||||
export type { SleepDebtDto, ChronotypeDto, AverageSleepDto, SleepRhythmDto };
|
||||
|
||||
/**
|
||||
* Read the server-authoritative sleep-rhythm DTO. Gated on `enabled` so a
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as chronotypeMod from "../chronotype";
|
||||
|
||||
import {
|
||||
buildSleepRhythm,
|
||||
computeAverageSleep,
|
||||
computeSleepRhythmFromNights,
|
||||
defaultDayType,
|
||||
type RhythmNight,
|
||||
@@ -146,3 +147,35 @@ describe("computeSleepRhythmFromNights window cap", () => {
|
||||
expect(oneYear.chronotype).toEqual(sixWeeks.chronotype);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeAverageSleep", () => {
|
||||
function nights(...minutes: number[]): RhythmNight[] {
|
||||
return minutes.map((m, i) => ({
|
||||
night: `2026-06-${String(10 + i).padStart(2, "0")}`,
|
||||
asleepMinutes: m,
|
||||
midpoint: 4 * 60,
|
||||
}));
|
||||
}
|
||||
|
||||
it("stays partial under the four-night floor and asserts no average", () => {
|
||||
const avg = computeAverageSleep(nights(420, 420, 420));
|
||||
expect(avg.state).toBe("partial");
|
||||
expect(avg.averageMinutes).toBe(0);
|
||||
expect(avg.nightsCounted).toBe(3);
|
||||
expect(avg.nightsUntilReady).toBe(1);
|
||||
});
|
||||
|
||||
it("returns the rounded mean over the scorable nights once ready", () => {
|
||||
const avg = computeAverageSleep(nights(400, 410, 420, 430, 450));
|
||||
expect(avg.state).toBe("ready");
|
||||
expect(avg.averageMinutes).toBe(422); // 2110 / 5 = 422
|
||||
expect(avg.nightsCounted).toBe(5);
|
||||
expect(avg.nightsUntilReady).toBe(0);
|
||||
});
|
||||
|
||||
it("matches the average the rhythm assembler emits over the same nights", () => {
|
||||
const dto = computeSleepRhythmFromNights(nights(420, 420, 420, 420), 420);
|
||||
expect(dto.averagePerNight.state).toBe("ready");
|
||||
expect(dto.averagePerNight.averageMinutes).toBe(420);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,23 @@ export interface SleepDebtDto {
|
||||
nightsUntilReady: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire DTO for the average sleep per night over the rhythm window.
|
||||
*
|
||||
* v1.19.1 — a third peer card next to sleep-debt + chronotype. The mean of the
|
||||
* canonical per-night asleep totals over the same scorable-night set the debt
|
||||
* and chronotype read, so the three cards can never contradict one another.
|
||||
* Carries the same calm `partial` state under a minimum-night threshold so it
|
||||
* never asserts an average off one or two thin nights.
|
||||
*/
|
||||
export interface AverageSleepDto {
|
||||
state: "partial" | "ready";
|
||||
/** Mean asleep minutes per scorable night (0 while partial). */
|
||||
averageMinutes: number;
|
||||
nightsCounted: number;
|
||||
nightsUntilReady: number;
|
||||
}
|
||||
|
||||
/** Wire DTO for the MCTQ chronotype — see `ChronotypeResult`. */
|
||||
export interface ChronotypeDto {
|
||||
state: "learning" | "ready";
|
||||
@@ -93,6 +110,39 @@ export interface ChronotypeDto {
|
||||
export interface SleepRhythmDto {
|
||||
sleepDebt: SleepDebtDto;
|
||||
chronotype: ChronotypeDto;
|
||||
averagePerNight: AverageSleepDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimum scorable nights before the average asserts a figure. Mirrors the
|
||||
* sleep-debt `minNights` floor (4) so the three peer cards settle in step.
|
||||
*/
|
||||
const AVG_MIN_NIGHTS = 4;
|
||||
|
||||
/**
|
||||
* Mean asleep minutes over the scorable nights, or the calm `partial` state
|
||||
* under the night floor. Pure: takes the already-filtered, ascending-sorted
|
||||
* scorable nights the rhythm assembler holds.
|
||||
*/
|
||||
export function computeAverageSleep(
|
||||
scorable: readonly RhythmNight[],
|
||||
): AverageSleepDto {
|
||||
const nightsCounted = scorable.length;
|
||||
if (nightsCounted < AVG_MIN_NIGHTS) {
|
||||
return {
|
||||
state: "partial",
|
||||
averageMinutes: 0,
|
||||
nightsCounted,
|
||||
nightsUntilReady: AVG_MIN_NIGHTS - nightsCounted,
|
||||
};
|
||||
}
|
||||
const total = scorable.reduce((sum, n) => sum + n.asleepMinutes, 0);
|
||||
return {
|
||||
state: "ready",
|
||||
averageMinutes: Math.round(total / nightsCounted),
|
||||
nightsCounted,
|
||||
nightsUntilReady: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,9 +261,14 @@ export function computeSleepRhythmFromNights(
|
||||
}));
|
||||
const chronotype = computeChronotype(chronoNights);
|
||||
|
||||
// Average sleep per night over the same scorable set the debt + chronotype
|
||||
// read, so the three peer cards share one window and can't disagree.
|
||||
const averagePerNight = computeAverageSleep(scorable);
|
||||
|
||||
return {
|
||||
sleepDebt: toDebtDto(sleepDebt),
|
||||
chronotype: toChronotypeDto(chronotype),
|
||||
averagePerNight,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user