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:
Marc-André Bombeck committed 2026-06-21 14:02:09 +02:00
commit 62a4dfdf7e
16 files changed
+290 -9

No files matched your search

+5 -2
View File
@@ -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