From c67bd942a25d9ab42c7ec92d8f61f15b454c332d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:01:30 +0200 Subject: [PATCH 1/2] feat(sleep): add average-per-night card beside sleep debt and chronotype Compute the mean asleep minutes per night from the same canonical per-night data the sleep-debt and chronotype cards read, exposed as a new averagePerNight field on the sleep-rhythm DTO. Surface it as a third peer card with identical chrome, so the three tiles sit side by side. Calm partial state under a four-night floor; localised in six locales. --- messages/de.json | 6 ++ messages/en.json | 6 ++ messages/es.json | 6 ++ messages/fr.json | 6 ++ messages/it.json | 6 ++ messages/pl.json | 6 ++ src/app/insights/sleep/page.tsx | 4 +- .../__tests__/chronotype-section.test.tsx | 6 ++ .../__tests__/sleep-rhythm-section.test.tsx | 6 ++ .../insights/sleep/average-sleep-card.tsx | 83 +++++++++++++++++++ .../insights/sleep/average-sleep-section.tsx | 53 ++++++++++++ .../insights/sleep/use-sleep-rhythm.ts | 3 +- .../derived/__tests__/sleep-rhythm.test.ts | 33 ++++++++ src/lib/insights/derived/sleep-rhythm.ts | 55 ++++++++++++ 14 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 src/components/insights/sleep/average-sleep-card.tsx create mode 100644 src/components/insights/sleep/average-sleep-section.tsx diff --git a/messages/de.json b/messages/de.json index 4f600dbab..47c4805eb 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 0c7cde8de..5d81ccedc 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 7899eb0a7..4cf3c2a82 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 e923ab65c..76d7b0535 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 5a4fece9a..33140d2c3 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 e99e307cc..43e8cd823 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/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, }; } From bbff6ab1439dc3ba2ae09d314e68b25164bdb73e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 14:01:37 +0200 Subject: [PATCH 2/2] fix(mood): full-saturate the time-of-day chart and mood calendar The v1.19.0 saturation fix missed two surfaces: the mood-by-time-of-day bars and the mood calendar still rendered populated values at 0.55 opacity, reading as a dimmed glitch. Drop the dimming on populated bars and cells (and the legend swatches); keep the quiet tint only on the no-data empty state. --- src/components/charts/mood-heatmap.tsx | 7 +++++-- .../insights/mood/mood-time-of-day-chart.tsx | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) 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) => ( ))}