feat(insights): mood calendar and chart arrive together; add via the header plus

On /insights/mood the line chart used to paint first and the calendar,
whose aggregate is the slower read, pushed it down when it landed
(CLS 0.16 at 390 px on a cold load). The page now holds both on
final-height skeletons until the chart's read has settled and the
calendar's has landed, then shows them in the same frame. The calendar's
height is written in container-query units, so its box is the right size
from the first paint, in the skeleton and the painted grid alike. A
server wrapper prefetches both reads in parallel (time-boxed, skipped
under a record switch, off when SSR prefetch is off), so on a warm cache
they are in the first HTML. The route and the prefetch share
`moodInsightsWire`, so the seeded cell is the route's shape.

The "Log a mood entry" and "Take a mental-wellbeing check-in" text links
under the heading are gone. Logging is the header plus, which opens the
mood form in place; the check-in keeps its own place in the navigation.
The plus is one shared component, `SubPageAddButton`, now also behind
the measurement pages' capture link and the workouts page, which drew
its own. An audit of every Insights page found no other text add-button
of this kind; the remaining buttons are empty-state calls to action.
A sweep keeps it that way.

`e2e/v142-qol.spec.ts` covers all three surfaces of this change set.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 16:45:49 +02:00
1 parent 1a5eab9f7c
commit 41ed8eb6a3
21 files changed
+1223 -339

No files matched your search

+196 -107
View File
@@ -46,6 +46,79 @@ const CELL_FLOOR_PX = 14;
// neighbouring tiles. Capping the cell keeps the grid dense + left-aligned so
// the calendar holds the tile-height rhythm. Mirrors `compliance-heatmap.tsx`.
const CELL_CEIL_PX = 22;
const HEADER_PX = 18;
/**
* The grid's height in the stretch layout, as CSS, so it is known before the
* grid has measured anything — on the server, in the loading skeleton, and in
* the first client frame alike.
*
* The stretch cell is the container width shared out across the weeks,
* clamped to the floor and the ceiling; the grid is the month header plus
* seven cells and six gaps. The same rule `MoodHeatmap` applies in JavaScript
* once it has measured, written here in container-query units so the box is
* the right height from the first paint and nothing below it moves when the
* measurement lands. Read against the nearest `container-type: inline-size`
* ancestor, which `MoodHeatmapBody` provides.
*/
export function moodHeatmapStretchHeight(weeks: number): string {
const w = Math.max(1, weeks);
const cell = `clamp(${CELL_FLOOR_PX}px, calc((100cqw - ${(w - 1) * GAP}px) / ${w}), ${CELL_CEIL_PX}px)`;
return `calc(${HEADER_PX + 6 * GAP}px + 7 * ${cell})`;
}
/** Monday-aligned columns a window of `days` spans, as the grid lays it out. */
export function moodHeatmapWeeks(days: number, timeZone: string): number {
const dates = heatmapDays(new Date(), timeZone, days);
const firstDow = dates[0]?.dow ?? 0;
return Math.floor((dates.length - 1 + firstDow) / 7) + 1;
}
/**
* The size container + the reserved box the grid paints into. Shared by the
* heatmap and its skeleton so the two can never disagree about the height.
*/
export function MoodHeatmapBody({
weeks,
children,
}: {
weeks: number;
children: React.ReactNode;
}) {
return (
<div style={{ containerType: "inline-size" }} className="w-full">
<div
data-slot="mood-heatmap-body"
style={{ height: moodHeatmapStretchHeight(weeks) }}
>
{children}
</div>
</div>
);
}
/**
* The loading shape of the stretched heatmap: the reserved grid box and the
* legend line, at exactly the height the painted heatmap will take.
* `days` is the window when it is already known; without it the shape
* assumes a window short enough to reach the cell ceiling, which every
* window under a quarter year does on any width.
*/
export function MoodHeatmapSkeleton({ days = 84 }: { days?: number }) {
const timeZone = useDisplayTimezone();
return (
<div
data-slot="mood-heatmap-skeleton"
aria-hidden="true"
className="w-full"
>
<MoodHeatmapBody weeks={moodHeatmapWeeks(days, timeZone)}>
<div className="bg-muted/50 h-full w-full animate-pulse rounded-md motion-reduce:animate-none" />
</MoodHeatmapBody>
<div className="mt-2 h-4" />
</div>
);
}
/**
* Mood score band → Dracula colour. Mirrors `mood-chart.tsx` VALUE_BANDS
@@ -209,7 +282,7 @@ export function MoodHeatmap({
});
const labelWidth = stretch ? 0 : 76;
const headerHeight = 18;
const headerHeight = HEADER_PX;
const cellSize =
stretch && containerWidth > 0
? Math.min(
@@ -243,116 +316,118 @@ export function MoodHeatmap({
return (
<div className={`relative ${stretch ? "w-full" : ""}`} ref={containerRef}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() =>
setTooltip((prev) => (prev?.pinned ? prev : null))
<StretchFrame stretch={stretch} weeks={weeks}>
<div
className={
stretch
? "w-full overflow-x-auto sm:w-full sm:overflow-visible"
: "overflow-x-auto"
}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
<svg
width={svgWidth}
height={svgHeight}
role="img"
aria-label={summaryLabel}
// v1.15.3 — `max-w-full` (not `w-full`) so a short window keeps its
// natural, square, left-aligned grid rather than CSS-stretching a few
// capped columns into wide rectangles. Mirrors `compliance-heatmap`.
className={stretch ? "block max-w-full" : "block"}
onMouseLeave={() =>
setTooltip((prev) => (prev?.pinned ? prev : null))
}
>
{monthMarkers.map((m, i) => (
<text
key={i}
x={labelWidth + m.col * step}
y={11}
className="fill-muted-foreground"
fontSize={10}
>
{m.label}
</text>
))}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{!stretch &&
WEEKDAY_LABELS.map(
(label, i) =>
label && (
<text
key={i}
x={labelWidth - 6}
y={headerHeight + i * step + cellSize * 0.65}
textAnchor="end"
className="fill-muted-foreground"
fontSize={10}
>
{label}
</text>
),
)}
{cells.map((cell) => {
const buildText = (): string => describeCell(cell);
return (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// 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"
data-day={cell.dateKey}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
day:
cell.cell !== null && isOpenableDay(cell.dateKey, today)
? cell.dateKey
: undefined,
});
}}
onClick={() => {
if (lastPointer.current === "touch") return;
if (cell.cell === null) return;
if (!isOpenableDay(cell.dateKey, today)) return;
setTooltip(null);
openDay(cell.dateKey, { trigger: containerRef.current });
}}
/>
);
})}
</svg>
</div>
{cells.map((cell) => {
const buildText = (): string => describeCell(cell);
return (
<rect
key={cell.dateKey}
x={labelWidth + cell.col * step}
y={headerHeight + cell.row * step}
width={cellSize}
height={cellSize}
rx={2}
fill={cell.color}
// 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"
data-day={cell.dateKey}
// 2026-07-17 a11y audit (M2) — the per-day values reach
// assistive tech through the visually-hidden day list below
// the grid, not through the rects: a cell subtree nested
// under the SVG's `role="img"` is pruned from the a11y tree,
// and per-cell tab stops would flood the keyboard order on a
// year-long grid. The rects stay a pure pointer affordance.
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
});
}}
onPointerLeave={(e) => {
if (e.pointerType === "touch") return;
setTooltip((prev) => (prev?.pinned ? prev : null));
}}
onPointerDown={(e) => {
lastPointer.current = e.pointerType;
if (e.pointerType !== "touch") return;
setTooltip({
x: e.clientX,
y: e.clientY,
text: buildText(),
pinned: true,
day:
cell.cell !== null && isOpenableDay(cell.dateKey, today)
? cell.dateKey
: undefined,
});
}}
onClick={() => {
if (lastPointer.current === "touch") return;
if (cell.cell === null) return;
if (!isOpenableDay(cell.dateKey, today)) return;
setTooltip(null);
openDay(cell.dateKey, { trigger: containerRef.current });
}}
/>
);
})}
</svg>
</div>
</StretchFrame>
{/* 2026-07-17 a11y audit (M2) — visually-hidden per-day list. The SVG
carries an aggregate `role="img"` summary; this list is the
@@ -431,3 +506,17 @@ export function MoodHeatmap({
</div>
);
}
/** The stretch layout paints into the reserved box; the fixed one sizes itself. */
function StretchFrame({
stretch,
weeks,
children,
}: {
stretch: boolean;
weeks: number;
children: React.ReactNode;
}) {
if (!stretch) return <>{children}</>;
return <MoodHeatmapBody weeks={weeks}>{children}</MoodHeatmapBody>;
}
@@ -0,0 +1,81 @@
/**
* v1.42 — the mood calendar no longer pops in after the line chart.
*
* While the shared read is pending, and while the page holds the reveal until
* the chart is ready too, the heatmap region paints the calendar's card with
* a skeleton at the calendar's final height — the same reserved box the
* painted grid uses — instead of nothing. Before this release the region
* rendered nothing while loading, so the calendar arrived later and pushed
* the chart down.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1" },
isLoading: false,
isAuthenticated: true,
}),
}));
const state = { current: {} as Record<string, unknown> };
vi.mock("@tanstack/react-query", () => ({
useQuery: () => state.current,
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MoodInsightsSections } from "../mood-insights-sections";
const LOADED = {
data: {
summary: { totalEntries: 12, inTargetPct: null },
heatmap: { windowDays: 40, cells: [] },
betterDays: [],
},
isLoading: false,
isPending: false,
isError: false,
refetch: vi.fn(),
};
function render(reveal?: boolean) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MoodInsightsSections region="heatmap" reveal={reveal} />
</I18nProvider>,
);
}
describe("<MoodInsightsSections region=heatmap> reveal", () => {
it("paints the calendar card with a reserved-height skeleton while the read is pending", () => {
state.current = {
data: undefined,
isLoading: true,
isPending: true,
isError: false,
refetch: vi.fn(),
};
const html = render();
expect(html).toContain("Mood calendar");
expect(html).toContain('data-slot="mood-heatmap-skeleton"');
expect(html).toMatch(/data-slot="mood-heatmap-body" style="height:calc\(/);
});
it("holds the skeleton while the page has not revealed, then paints the grid", () => {
state.current = LOADED;
const held = render(false);
expect(held).toContain('data-slot="mood-heatmap-skeleton"');
const shown = render(true);
expect(shown).not.toContain('data-slot="mood-heatmap-skeleton"');
expect(shown).toContain('role="img"');
});
it("the skeleton and the grid reserve the same box for the same window", () => {
state.current = LOADED;
const box = (html: string) =>
/data-slot="mood-heatmap-body" style="(height:[^"]+)"/.exec(html)?.[1];
expect(box(render(false))).toBeDefined();
expect(box(render(false))).toBe(box(render(true)));
});
});
@@ -12,7 +12,10 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { MoodHeatmap } from "@/components/charts/mood-heatmap";
import {
MoodHeatmap,
MoodHeatmapSkeleton,
} from "@/components/charts/mood-heatmap";
import { SectionCard, type MoodInsightsResponse } from "./mood-insights-shared";
import { apiGet } from "@/lib/api/api-fetch";
@@ -97,15 +100,14 @@ const MoodInsightsBreakdowns = dynamic(
*/
export type MoodInsightsRegion = "heatmap" | "assessment" | "rest";
export function MoodInsightsSections({
region = "rest",
}: {
region?: MoodInsightsRegion;
} = {}) {
/**
* The one read every region shares, exported so the page can wait on the same
* cell (same key, same fetch, same options) when it decides when the calendar
* and the line chart appear.
*/
export function useMoodInsights() {
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
return useQuery({
queryKey: queryKeys.moodInsights(),
queryFn: async () => {
return apiGet<MoodInsightsResponse>("/api/mood/insights");
@@ -113,11 +115,42 @@ export function MoodInsightsSections({
enabled: isAuthenticated,
staleTime: 60_000,
});
}
export function MoodInsightsSections({
region = "rest",
reveal = true,
}: {
region?: MoodInsightsRegion;
/**
* v1.42 — heatmap region only: hold the calendar on its skeleton until the
* page says the line chart below is ready too, so the two appear in the
* same frame instead of one popping in after the other. The skeleton holds
* the calendar's final height, so the reveal moves nothing.
*/
reveal?: boolean;
} = {}) {
const { t } = useTranslations();
const { data, isLoading, isPending, isError, refetch } = useMoodInsights();
// `isPending`, not `isLoading`: before the session resolves the read is
// disabled rather than loading, and the frame must already be there.
if (region === "heatmap" && (isPending || (!reveal && !isError))) {
// The calendar's frame and final height from the first paint; the
// window is known once the read has landed, assumed short until then.
if (data && data.summary.totalEntries === 0) return null;
return (
<SectionCard title={t("insights.mood.heatmapTitle")} icon={CalendarDays}>
<MoodHeatmapSkeleton days={data?.heatmap.windowDays} />
</SectionCard>
);
}
if (isLoading) {
// Only the "rest" region carries the page-level loading skeleton; the
// heatmap / assessment regions stay invisible while loading so they don't
// stack three skeletons down the page.
// Only the "rest" region carries the page-level loading skeleton (the
// heatmap's own is above); the assessment region stays invisible while
// loading so the page does not stack skeletons down its length.
return region === "rest" ? (
<Skeleton className="h-48 w-full rounded-lg" />
) : null;
+60 -18
View File
@@ -161,6 +161,65 @@ export interface SubPageShellProps {
children: ReactNode;
}
/**
* The header's add action — one plus, one shape, on every Insights page that
* offers one. A 40 px ghost icon with the extended hit area its cluster
* siblings carry; the accessible name rides on `aria-label` and `title`.
*
* Two forms: `href` links to a capture form elsewhere (the measurement pages'
* `captureType`), `onClick` opens something in place (the workouts and mood
* pages). A page never draws its own "log an entry" text link instead.
*/
export function SubPageAddButton({
label,
href,
onClick,
slot,
}: {
label: string;
href?: string;
onClick?: () => void;
/** `data-slot` for tests; defaults to the shared one. */
slot?: string;
}) {
const className = cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
);
const icon = <Plus className="size-4" aria-hidden="true" />;
if (href) {
return (
<Button
asChild
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
className={className}
>
<Link href={href} aria-label={label} title={label}>
{icon}
</Link>
</Button>
);
}
return (
<Button
type="button"
variant="ghost"
size="icon"
data-slot={slot ?? "metric-add-reading"}
data-header-add=""
aria-label={label}
title={label}
onClick={onClick}
className={className}
>
{icon}
</Button>
);
}
export function SubPageShell({
title,
backLink,
@@ -319,24 +378,7 @@ export function SubPageShell({
<div className="flex shrink-0 items-center gap-3">
{headerAction}
{captureHref ? (
<Button
asChild
variant="ghost"
size="icon"
data-slot="metric-add-reading"
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Link
href={captureHref}
aria-label={captureLabel}
title={captureLabel}
>
<Plus className="size-4" aria-hidden="true" />
</Link>
</Button>
<SubPageAddButton label={captureLabel} href={captureHref} />
) : null}
{/* v1.16.8 — "show all readings" rides the header cluster
as an icon button, LEFT of the target-adjust gear (it