diff --git a/messages/de.json b/messages/de.json
index e1a27e32d..6674ca67f 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -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.",
diff --git a/messages/en.json b/messages/en.json
index 2dcf994be..2f753c94a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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.",
diff --git a/messages/es.json b/messages/es.json
index 4b61aeb82..ff16bdc8f 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -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.",
diff --git a/messages/fr.json b/messages/fr.json
index 16210e300..a3c2c476c 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -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.",
diff --git a/messages/it.json b/messages/it.json
index 3575c4db8..3e512e59b 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -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.",
diff --git a/messages/pl.json b/messages/pl.json
index 04cd5b0fb..9709e45af 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -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ł.",
diff --git a/src/app/insights/sleep/page.tsx b/src/app/insights/sleep/page.tsx
index 101b49d4d..fda0a981f 100644
--- a/src/app/insights/sleep/page.tsx
+++ b/src/app/insights/sleep/page.tsx
@@ -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).
*/}
-
+
diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx
index 749e613e5..c1c547447 100644
--- a/src/components/charts/mood-heatmap.tsx
+++ b/src/components/charts/mood-heatmap.tsx
@@ -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({
{label}
diff --git a/src/components/insights/mood/mood-time-of-day-chart.tsx b/src/components/insights/mood/mood-time-of-day-chart.tsx
index e1e8f61bc..5a5aa102b 100644
--- a/src/components/insights/mood/mood-time-of-day-chart.tsx
+++ b/src/components/insights/mood/mood-time-of-day-chart.tsx
@@ -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. */}
-
+ {/* 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. */}
+
{data.map((row) => (
|
))}
diff --git a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
index 8d26be570..6216b89bb 100644
--- a/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
+++ b/src/components/insights/sleep/__tests__/chronotype-section.test.tsx
@@ -50,6 +50,12 @@ const READY_DTO: SleepRhythmDto = {
workNightsCounted: 14,
freeNightsUntilReady: 0,
},
+ averagePerNight: {
+ state: "ready",
+ averageMinutes: 420,
+ nightsCounted: 10,
+ nightsUntilReady: 0,
+ },
};
beforeEach(() => {
diff --git a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
index d4af80009..5ed46274a 100644
--- a/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
+++ b/src/components/insights/sleep/__tests__/sleep-rhythm-section.test.tsx
@@ -58,6 +58,12 @@ const READY_DTO: SleepRhythmDto = {
workNightsCounted: 14,
freeNightsUntilReady: 0,
},
+ averagePerNight: {
+ state: "ready",
+ averageMinutes: 420,
+ nightsCounted: 5,
+ nightsUntilReady: 0,
+ },
};
beforeEach(() => {
diff --git a/src/components/insights/sleep/average-sleep-card.tsx b/src/components/insights/sleep/average-sleep-card.tsx
new file mode 100644
index 000000000..81cb2b9c1
--- /dev/null
+++ b/src/components/insights/sleep/average-sleep-card.tsx
@@ -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 `` + ``: 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 (
+
+
+
+
+
+ {t("insights.sleep.average.title")}
+
+
+
+
+
+
+
+ );
+ }
+
+ const { hours, minutes } = hoursMinutes(average.averageMinutes);
+
+ return (
+
+
+
+
+
+ {t("insights.sleep.average.title")}
+
+
+
+
+
+
+ {t("insights.sleep.average.value", { hours, minutes })}
+
+
+ {t("insights.sleep.average.caption", {
+ nights: average.nightsCounted,
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/insights/sleep/average-sleep-section.tsx b/src/components/insights/sleep/average-sleep-section.tsx
new file mode 100644
index 000000000..8e848fd18
--- /dev/null
+++ b/src/components/insights/sleep/average-sleep-section.tsx
@@ -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 `` (sleep debt) + ``
+ * 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 (
+
+ {t("insights.sleep.rhythm.loadError")}
+
+ );
+ }
+
+ if (isLoading || !data) {
+ return (
+
+ );
+ }
+
+ return ;
+}
diff --git a/src/components/insights/sleep/use-sleep-rhythm.ts b/src/components/insights/sleep/use-sleep-rhythm.ts
index 64a66213d..2b4de708e 100644
--- a/src/components/insights/sleep/use-sleep-rhythm.ts
+++ b/src/components/insights/sleep/use-sleep-rhythm.ts
@@ -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
diff --git a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts
index 13f1cc29b..c518ee56a 100644
--- a/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts
+++ b/src/lib/insights/derived/__tests__/sleep-rhythm.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/insights/derived/sleep-rhythm.ts b/src/lib/insights/derived/sleep-rhythm.ts
index b46039715..ec3cbbaff 100644
--- a/src/lib/insights/derived/sleep-rhythm.ts
+++ b/src/lib/insights/derived/sleep-rhythm.ts
@@ -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,
};
}