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

+382
View File
@@ -0,0 +1,382 @@
/**
* v1.42 — three surfaces made easier to live with.
*
* - Documents: the display choice behind the wrench (cards or list, months
* stacked or flowing) persists per account; every delete asks first; the
* selection is cleared with an X on the count line.
* - Wellness scores: every score tile shows its course; a score's detail
* page carries its history right under the score card and no longer the
* warning-coloured "not clinical" line.
* - Mood insights: the calendar and the line chart appear together without
* moving the page, and logging is the header plus every Insights page
* uses.
*
* Everything is addressed through stable data attributes, never copy.
*/
import type { Page } from "@playwright/test";
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { ensureVaultFixture, seedNamespaceDocs } from "./setup/vault-fixture";
import { mockPopulatedInsights } from "./utils/mock-populated-insights";
test.use({ storageState: STORAGE_STATE_PATH });
async function resetDocumentsLayout(page: Page): Promise<void> {
const res = await page.request.put("/api/documents/inbound/layout", {
data: { version: 1, view: "cards", arrangement: "stacked" },
});
expect(res.status()).toBe(200);
}
test.describe("documents", () => {
test.beforeAll(async () => {
await ensureVaultFixture();
});
test("the display choice behind the wrench persists across visits", async ({
page,
isMobile,
}) => {
// Both projects share one account; the layout is account state, so one
// project owns this journey.
test.skip(isMobile, "account-level preference; one project owns it");
await resetDocumentsLayout(page);
try {
await page.goto("/documents");
const timeline = page.locator('[data-slot="document-timeline"]');
await expect(timeline).toHaveAttribute("data-view", "cards");
await expect(timeline).toHaveAttribute("data-arrangement", "stacked");
await page.locator('[data-slot="documents-customize"]').click();
await expect(page).toHaveURL(/\/settings\/layout\/documents$/);
const settings = page.locator('[data-slot="documents-layout-settings"]');
await settings.locator('[data-slot="module-view-list"]').click();
await expect(
settings.locator('[data-slot="module-view-list"]'),
).toHaveAttribute("aria-pressed", "true");
const flow = settings.locator('[data-slot="documents-arrangement-flow"]');
// Wait for the write to land, so the reload below reads it back.
const saved = page.waitForResponse(
(r) =>
r.url().includes("/api/documents/inbound/layout") &&
r.request().method() === "PUT",
);
await flow.click();
expect((await saved).status()).toBe(200);
await page.goto("/documents");
await expect(timeline).toHaveAttribute("data-view", "list");
await expect(timeline).toHaveAttribute("data-arrangement", "flow");
await expect(
timeline
.locator('[data-slot="document-card"][data-variant="row"]')
.first(),
).toBeVisible();
// Flowing: no month gets a row of its own, the name rides inline.
await expect(
timeline.locator('[data-slot="document-flow-month"]').first(),
).toBeVisible();
// A fresh visit in a fresh page — the choice is the account's, not
// this tab's.
const second = await page.context().newPage();
await second.goto("/documents");
await expect(
second.locator('[data-slot="document-timeline"]'),
).toHaveAttribute("data-arrangement", "flow");
await second.close();
} finally {
await resetDocumentsLayout(page);
}
});
test("bulk delete asks first, and cancelling deletes nothing", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qoldel", 2);
await page.goto("/documents?q=qoldel");
const box = page.getByRole("checkbox", { name: "Select qoldel 002" });
await expect(box).toBeVisible();
await box.click();
let deletes = 0;
page.on("request", (req) => {
if (
req.method() === "POST" &&
req.url().includes("/api/documents/inbound/bulk") &&
(req.postData() ?? "").includes('"delete"')
) {
deletes += 1;
}
});
const bar = page.locator('[data-slot="document-bulk-bar"]');
await bar.locator('[data-slot="document-bulk-delete"]').click();
const confirm = page.locator('[data-slot="documents-delete-confirm"]');
await expect(confirm).toBeVisible();
await page
.getByRole("alertdialog")
.getByRole("button", { name: "Cancel" })
.click();
await expect(confirm).toBeHidden();
expect(deletes).toBe(0);
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeVisible();
await bar.locator('[data-slot="document-bulk-delete"]').click();
await confirm.click();
await expect(
page.getByRole("button", { name: "Open qoldel 002" }),
).toBeHidden();
expect(deletes).toBe(1);
});
test("the Delete key on a focused card asks first too", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "keyboard flow");
await seedNamespaceDocs("qolkey", 1);
await page.goto("/documents?q=qolkey");
const open = page.getByRole("button", { name: "Open qolkey 001" });
await expect(open).toBeVisible();
await open.focus();
await page.keyboard.press("Delete");
await expect(
page.locator('[data-slot="documents-delete-confirm"]'),
).toBeVisible();
await page.keyboard.press("Escape");
await expect(open).toBeVisible();
});
test("the selection clears with the X on the count line", async ({
page,
isMobile,
}) => {
test.skip(isMobile, "selection by checkbox is a pointer flow");
await seedNamespaceDocs("qolclear", 2);
await page.goto("/documents?q=qolclear");
await page.getByRole("checkbox", { name: "Select qolclear 001" }).click();
const bar = page.locator('[data-slot="document-bulk-bar"]');
await expect(bar).toBeVisible();
const count = bar.locator('[data-slot="document-bulk-count"]');
const clear = bar.locator('[data-slot="document-bulk-clear"]');
const actions = bar.locator('[data-slot="document-bulk-actions"]');
// The X sits on the count line, at its right edge, above the action row,
// and the action row follows the count line without a gap of its own.
const [countBox, clearBox, actionsBox, barBox] = await Promise.all([
count.boundingBox(),
clear.boundingBox(),
actions.boundingBox(),
bar.boundingBox(),
]);
expect(countBox && clearBox && actionsBox && barBox).toBeTruthy();
const mid = (b: { y: number; height: number }) => b.y + b.height / 2;
expect(Math.abs(mid(clearBox!) - mid(countBox!))).toBeLessThan(6);
expect(
barBox!.x + barBox!.width - (clearBox!.x + clearBox!.width),
).toBeLessThan(20);
expect(
actionsBox!.y - (clearBox!.y + clearBox!.height),
).toBeLessThanOrEqual(10);
// One action row: every verb shares a top edge.
const tops = await actions
.locator("button")
.evaluateAll((els) =>
els.map((el) => Math.round(el.getBoundingClientRect().top)),
);
expect(new Set(tops).size).toBe(1);
await clear.click();
await expect(bar).toBeHidden();
});
});
test.describe("wellness scores", () => {
test("every score tile shows its course", async ({ page }) => {
await mockPopulatedInsights(page);
await page.goto("/insights");
for (const metric of ["RECOVERY_SCORE", "STRESS_SCORE"]) {
const tile = page.locator(
`[data-slot="wellness-score-tile"][data-metric="${metric}"]`,
);
await tile.scrollIntoViewIfNeeded();
await expect(
tile.locator('[data-slot="wellness-score-history"]'),
).toBeVisible();
}
});
test("a score's detail page carries its history under the card, without the caveat line", async ({
page,
}) => {
// The e2e account has no nightly score; serve one so the page has a
// score to draw the history under.
await page.route(
/\/api\/insights\/derived\?metric=RECOVERY_SCORE/,
(route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
metric: "RECOVERY_SCORE",
status: "ok",
value: {
score: 66,
band: "yellow",
trendDelta: 2,
daysInWindow: 5,
asOf: new Date().toISOString(),
series: [58, 61, 64, 60, 66],
anchor: null,
components: null,
},
coverage: {
requiredInputs: 1,
presentInputs: 1,
historyDays: 5,
missing: [],
},
confidence: { score: 80, band: "high" },
provenance: {
inputs: ["RECOVERY_SCORE"],
source: "live",
windowDays: 14,
computedAt: new Date().toISOString(),
},
reason: null,
assessment: null,
},
error: null,
}),
}),
);
await page.goto("/insights/scores/recovery");
const anatomy = page.locator('[data-slot="score-anatomy-view"]');
await expect(anatomy).toBeVisible();
const history = page.locator('[data-slot="score-history-chart"]');
await expect(history).toBeAttached();
const [cardBox, historyBox] = await Promise.all([
anatomy.boundingBox(),
history.boundingBox(),
]);
expect(historyBox!.y).toBeGreaterThan(cardBox!.y + cardBox!.height - 1);
await expect(anatomy.locator(".text-warning")).toHaveCount(0);
});
});
test.describe("mood insights", () => {
test.beforeAll(async ({ browser }) => {
// A short, real history, so the calendar and the chart both have days.
const context = await browser.newContext({
storageState: STORAGE_STATE_PATH,
});
const now = Date.now();
for (let day = 1; day <= 12; day++) {
const res = await context.request.post("/api/mood-entries", {
data: {
mood: day % 3 === 0 ? "OKAY" : "GUT",
moodLoggedAt: new Date(now - day * 86_400_000).toISOString(),
source: "MANUAL",
externalId: `e2e-qol-mood-${day}`,
},
});
expect([200, 201]).toContain(res.status());
}
await context.close();
});
test("the calendar and the chart appear together without moving the page", async ({
page,
}) => {
// The calendar's read is the slower one in production; slow it down here
// so the old order (chart first, calendar pushing it down) would show.
await page.route(/\/api\/mood\/insights(\?|$)/, async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1_200));
await route.continue();
});
await page.addInitScript(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
w.__cls = 0;
w.__seen = {};
new PerformanceObserver((list) => {
for (const entry of list.getEntries() as Array<
PerformanceEntry & { value: number; hadRecentInput: boolean }
>) {
if (!entry.hadRecentInput) w.__cls += entry.value;
}
}).observe({ type: "layout-shift", buffered: true });
const watch = () => {
if (
!w.__seen.heatmap &&
document.querySelector('[data-slot="mood-heatmap-day-list"]')
)
w.__seen.heatmap = performance.now();
if (
!w.__seen.chart &&
document.querySelector(
'[data-slot="insights-mood-chart"] .recharts-surface',
) &&
!document.querySelector(
'[data-slot="insights-mood-chart"] .invisible',
)
)
w.__seen.chart = performance.now();
requestAnimationFrame(watch);
};
requestAnimationFrame(watch);
});
await page.goto("/insights/mood");
await expect(
page.locator('[data-slot="mood-heatmap-day-list"]'),
).toBeAttached({
timeout: 15_000,
});
await expect(
page
.locator('[data-slot="insights-mood-chart"] .recharts-surface')
.first(),
).toBeVisible();
await page.waitForTimeout(1_500);
const { cls, seen } = await page.evaluate(() => {
const w = window as unknown as {
__cls: number;
__seen: Record<string, number>;
};
return { cls: w.__cls, seen: w.__seen };
});
expect(cls).toBeLessThan(0.02);
expect(seen.heatmap).toBeDefined();
expect(seen.chart).toBeDefined();
// Together: within a couple of frames of each other.
expect(Math.abs(seen.heatmap - seen.chart)).toBeLessThan(100);
});
test("logging is the header plus, not a text link", async ({ page }) => {
await page.goto("/insights/mood");
const add = page.locator(
'[data-slot="insights-mood-add"][data-header-add]',
);
await expect(add).toBeVisible();
await expect(
page.locator('[data-slot="insights-mood-log-link"]'),
).toHaveCount(0);
await expect(
page.locator('[data-slot="insights-mood-mental-wellbeing-link"]'),
).toHaveCount(0);
await add.click();
await expect(page.getByRole("dialog")).toBeVisible();
});
});
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Gehgeschwindigkeit",
"mood": {
"logLink": "Stimmung eintragen",
"mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen",
"heatmapTitle": "Stimmungskalender",
"heatmapNoEntry": "Kein Eintrag",
"legendGreat": "Super",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Walking Speed",
"mood": {
"logLink": "Log a mood entry",
"mentalWellbeingLink": "Take a mental-wellbeing check-in",
"heatmapTitle": "Mood calendar",
"heatmapNoEntry": "No entry",
"legendGreat": "Great",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Velocidad al andar",
"mood": {
"logLink": "Registrar un estado de ánimo",
"mentalWellbeingLink": "Hacer un chequeo de bienestar mental",
"heatmapTitle": "Calendario de ánimo",
"heatmapNoEntry": "Sin registro",
"legendGreat": "Genial",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Vitesse de marche",
"mood": {
"logLink": "Enregistrer une humeur",
"mentalWellbeingLink": "Faire un bilan de bien-être mental",
"heatmapTitle": "Calendrier d'humeur",
"heatmapNoEntry": "Aucune entrée",
"legendGreat": "Excellent",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Velocità di cammino",
"mood": {
"logLink": "Registra un umore",
"mentalWellbeingLink": "Fai un check-in sul benessere mentale",
"heatmapTitle": "Calendario dell'umore",
"heatmapNoEntry": "Nessun dato",
"legendGreat": "Ottimo",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "보행 속도",
"mood": {
"logLink": "기분 기록하기",
"mentalWellbeingLink": "정신 건강 체크인 하기",
"heatmapTitle": "기분 캘린더",
"heatmapNoEntry": "기록 없음",
"legendGreat": "아주 좋음",
-1
View File
@@ -4043,7 +4043,6 @@
"navWalkingSpeed": "Prędkość chodu",
"mood": {
"logLink": "Zapisz nastrój",
"mentalWellbeingLink": "Wykonaj sprawdzian dobrostanu psychicznego",
"heatmapTitle": "Kalendarz nastroju",
"heatmapNoEntry": "Brak wpisu",
"legendGreat": "Świetnie",
@@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [
"/checkups",
"/coach",
"/insights",
"/insights/mood",
"/insights/workouts",
"/medications",
"/mood",
@@ -138,6 +138,8 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"RSC prefetch of the session user's coach state, skipped under a switch",
"app/insights/page.tsx":
"RSC prefetch of the session user's insights, skipped under a switch",
"app/insights/mood/page.tsx":
"RSC prefetch of the session user's mood calendar and line, skipped under a switch",
"app/insights/workouts/page.tsx":
"RSC prefetch of the session user's workouts, skipped under a switch",
"app/medications/page.tsx":
+2 -13
View File
@@ -7,6 +7,7 @@ import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
MOOD_STABILITY_WINDOWS,
moodInsightsWire,
type MoodAggregates,
type MoodStabilityWindow,
} from "@/lib/insights/mood-aggregates";
@@ -74,17 +75,5 @@ export const GET = apiHandler(async (request: Request) => {
},
});
// The per-window map is the cache's, not the wire's: the chosen window is
// served as `stability`. An aggregate cached before the map existed has
// only the year, so a shorter window reads as not yet computed (`null`)
// until the next rebuild rather than as the year's score under a new name.
const { stabilityByWindow, ...wire } = result;
return apiSuccess({
...wire,
stability:
stabilityWindowDays === 365
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
});
return apiSuccess(moodInsightsWire(result, stabilityWindowDays));
});
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
/**
@@ -61,7 +61,13 @@ const BESPOKE_PAGES: ReadonlyArray<{
describe("bespoke metric-detail spine — assessment is the last block", () => {
for (const { slug, assessmentTag, assessmentMayTrail } of BESPOKE_PAGES) {
it(`renders the assessment card as the last SubPageShell child on /insights/${slug}`, () => {
const source = readFileSync(join(PAGES_DIR, slug, "page.tsx"), "utf8");
// A page split into an RSC prefetch wrapper and its client body keeps
// the spine in the client file.
const client = join(PAGES_DIR, slug, "page-client.tsx");
const source = readFileSync(
existsSync(client) ? client : join(PAGES_DIR, slug, "page.tsx"),
"utf8",
);
// The render branch that carries the assessment is the final
// `<SubPageShell>…</SubPageShell>` in the module (the empty-state
@@ -0,0 +1,60 @@
/**
* v1.42 — adding from an Insights page is the header plus, one component.
*
* The mood page carried two text links under its heading ("Log a mood entry",
* "Take a mental-wellbeing check-in") where every other Insights page offers
* a plus in the header, and the workouts page drew its plus by hand. Both now
* go through `SubPageAddButton`. This sweep keeps it that way: no page under
* `src/app/insights` draws its own plus glyph, and the mood page carries no
* text link into a capture surface.
*
* The matchers assert a non-zero population, so a move that leaves the sweep
* reading nothing fails instead of passing quietly.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
const ROOT = process.cwd();
const INSIGHTS = join(ROOT, "src/app/insights");
function pages(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = join(dir, name);
if (name === "__tests__") return [];
if (statSync(full).isDirectory()) return pages(full);
return /^page(-client)?\.tsx$/.test(name) ? [full] : [];
});
}
describe("Insights add actions", () => {
const files = pages(INSIGHTS);
it("sweeps the Insights pages", () => {
expect(files.length).toBeGreaterThan(40);
});
it("no page draws its own plus; the header plus is the shared component", () => {
const own = files.filter((file) =>
/import\s*\{[^}]*\bPlus\b[^}]*\}\s*from\s*"lucide-react"/.test(
readFileSync(file, "utf8"),
),
);
expect(own.map((file) => relative(ROOT, file))).toEqual([]);
});
it("the mood and workouts pages add through SubPageAddButton", () => {
for (const rel of ["mood/page-client.tsx", "workouts/page-client.tsx"]) {
const source = readFileSync(join(INSIGHTS, rel), "utf8");
expect(source, rel).toContain("<SubPageAddButton");
expect(source, rel).toContain("headerAction=");
}
});
it("the mood page has no text links into logging or the check-in", () => {
const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8");
expect(source).not.toMatch(/<Link\s+href="\/mood"\s+data-slot/);
expect(source).not.toContain('href="/mental-wellbeing"');
expect(source).not.toContain("insights.mood.mentalWellbeingLink");
});
});
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { useCallback, useState } from "react";
import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import {
MoodInsightsSections,
useMoodInsights,
} from "@/components/insights/mood/mood-insights-sections";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import {
SubPageAddButton,
SubPageShell,
} from "@/components/insights/sub-page-shell";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { apiGet } from "@/lib/api/api-fetch";
/**
* The mood form, loaded only when the header plus opens it: the route's first
* paint is the calendar and the chart, and the form's tag catalogue is heavy.
*/
const MoodForm = dynamic(
() =>
import("@/components/mood/mood-form").then((mod) => ({
default: mod.MoodForm,
})),
{ ssr: false, loading: () => null },
);
/**
* v1.4.25 W4 — `/insights/mood`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
* cannot just blank itself — we'd land on an empty white page. Instead,
* the sub-page surfaces a clear empty-state CTA into `/mood` so the
* user can log their first entry, matching the Apple Health "No data"
* convention (research §1.1).
*/
const MoodChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
/**
* The day's two readings. Deferred like every other block on this route that
* is not the first paint: it reads its own endpoint and renders below the
* calendar, so nothing about it belongs in the eager graph of a page whose
* first paint is a heatmap. This route sits close to its bundle ceiling and
* every addition to it goes through the same treatment.
*/
const MoodPrognosisCard = dynamic(
() =>
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
default: mod.MoodPrognosisCard,
})),
{ ssr: false, loading: () => null },
);
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
}
export default function InsightsMoodPageClient() {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
// A mood entry is a MANAGE create under the mind section, as on `/mood`.
const { canManageDomain } = useRecordCapabilities();
const canAddMood = canManageDomain("mind") && user?.modules?.mood !== false;
const [addOpen, setAddOpen] = useState(false);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.42 — the calendar and the line chart appear together. The chart says
// when its read has settled (`onDataReady`, the dashboard's reveal gate);
// the calendar's read is the shared mood-insights cell. Until both are in,
// each holds its final-height skeleton, so the reveal moves nothing.
const [chartSettled, setChartSettled] = useState(false);
const markChartSettled = useCallback(() => setChartSettled(true), []);
const insights = useMoodInsights();
const topRevealed = chartSettled && !insights.isPending;
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensiveMoodData>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
});
const moodCount = comprehensive?.moodSummary?.count ?? 0;
// v1.4.27 F17 — Mood is event-driven so the gate reads
// `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated
// mood-logging surface) — short-circuits the user to the quickest
// path to log their first entry.
//
// v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared
// `<MetricEmptyState>` primitive. The mood data path stays on
// `/api/insights/comprehensive` because the `moodSummary.count`
// signal is event-driven, not sensor-aggregated.
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
cta={
<Button size="sm" asChild>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
coachLaunch
headerAction={
canAddMood ? (
<SubPageAddButton
label={t("insights.mood.logLink")}
onClick={() => setAddOpen(true)}
slot="insights-mood-add"
/>
) : null
}
>
{canAddMood ? (
<ResponsiveSheet
open={addOpen}
onOpenChange={setAddOpen}
title={t("mood.addEntry")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
{addOpen ? (
<MoodForm
onSuccess={() => setAddOpen(false)}
onCancel={() => setAddOpen(false)}
footerSlot={footerEl}
/>
) : null}
</ResponsiveSheet>
) : null}
{/* v1.12.7 — operator spine: heading + summary (the shell above), then
the Stimmungskalender, then the line chart, then the Ziel card, then
the better-days Einschätzung, then the classification + breakdowns.
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
{/* v1.42 — the "log a mood entry" and "take a check-in" text links that
sat here are gone: logging is the header plus, the same control every
other Insights page carries, and the check-in has its own entry in
the navigation. */}
<MoodInsightsSections region="heatmap" reveal={topRevealed} />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
MeasurementType series, so the period-over-period range read
(`/api/analytics/range`, keyed on a MeasurementType enum) has
nothing to aggregate. */}
{/* Held invisible under its own skeleton until the calendar above is
ready too; it keeps its place in the flow the whole time, so the
page below never moves. */}
<div data-slot="insights-mood-chart" className="relative">
{!topRevealed ? (
<ChartSkeleton className="absolute inset-0 z-10" />
) : null}
<div
className={topRevealed ? undefined : "invisible"}
aria-hidden={topRevealed ? undefined : true}
>
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
onDataReady={markChartSettled}
/>
</div>
</div>
<MetricTargetSummary slug="mood" />
{/* The day's own rating beside what the account's past days imply. It
sits high because the self-assessment is the leading value on this
page, and directly under the line chart because that is where a
reader has just seen the rating it leads with. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
<MoodInsightsSections region="rest" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
metric-detail page, matching the canonical spine the generic
scaffold (weight / bmi / pulse / blood-pressure) renders. The
reader sees the trend and the breakdown sections first, then the
narration of them at the foot. */}
<SlugInsightStatusCard slug="mood" icon={<Smile className="h-5 w-5" />} />
</SubPageShell>
);
}
+117 -162
View File
@@ -1,175 +1,130 @@
"use client";
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { ArrowRight, Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { buildMoodDailySeries } from "@/lib/analytics/mood-series";
import {
cached,
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import {
fetchMoodAggregates,
moodInsightsWire,
type MoodAggregates,
} from "@/lib/insights/mood-aggregates";
import type { MoodDailySeries } from "@/lib/analytics/mood-series";
import { resolveModuleMap } from "@/lib/modules/gate";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { ChartSkeleton } from "@/components/charts/chart-skeleton";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { MetricTargetSummary } from "@/components/insights/metric-target-summary";
import { MoodInsightsSections } from "@/components/insights/mood/mood-insights-sections";
import { SlugInsightStatusCard } from "@/components/insights/slug-insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { apiGet } from "@/lib/api/api-fetch";
import InsightsMoodPageClient from "./page-client";
/**
* v1.4.25 W4 — `/insights/mood`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
* cannot just blank itself — we'd land on an empty white page. Instead,
* the sub-page surfaces a clear empty-state CTA into `/mood` so the
* user can log their first entry, matching the Apple Health "No data"
* convention (research §1.1).
* How long the first HTML waits for the two reads. Both are cached per user
* (the aggregate stale-while-revalidate), so a warm visit answers in a few
* milliseconds; a cold aggregate can take longer than a reader should wait
* for a blank page, and then the client's own fetch takes over.
*/
const MoodChart = dynamic(
() =>
import("@/components/charts/chart-runtime").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false, loading: () => <ChartSkeleton /> },
);
const PREFETCH_BUDGET_MS = 1_500;
/**
* The day's two readings. Deferred like every other block on this route that
* is not the first paint: it reads its own endpoint and renders below the
* calendar, so nothing about it belongs in the eager graph of a page whose
* first paint is a heatmap. This route sits close to its bundle ceiling and
* every addition to it goes through the same treatment.
*/
const MoodPrognosisCard = dynamic(
() =>
import("@/components/insights/mood/mood-prognosis-card").then((mod) => ({
default: mod.MoodPrognosisCard,
})),
{ ssr: false, loading: () => null },
);
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
function withinBudget<T>(work: Promise<T>): Promise<T | null> {
return Promise.race([
work,
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), PREFETCH_BUDGET_MS),
),
]).catch(() => null);
}
export default function InsightsStimmungPage() {
const { user, isAuthenticated } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
return apiGet<ComprehensiveMoodData>("/api/insights/comprehensive");
},
enabled: isAuthenticated,
});
const moodCount = comprehensive?.moodSummary?.count ?? 0;
// v1.4.27 F17 — Mood is event-driven so the gate reads
// `hasMood = moodCount > 0`. CTA targets `/mood` (the dedicated
// mood-logging surface) — short-circuits the user to the quickest
// path to log their first entry.
//
// v1.4.28 R3d (BK-F-M1) — empty-state render delegates to the shared
// `<MetricEmptyState>` primitive. The mood data path stays on
// `/api/insights/comprehensive` because the `moodSummary.count`
// signal is event-driven, not sensor-aggregated.
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
>
<MetricEmptyState
icon={<Smile className="size-6" />}
title={t("insights.emptyState.mood.title")}
description={t("insights.emptyState.mood.description")}
cta={
<Button size="sm" asChild>
<Link href="/mood">{t("insights.emptyState.mood.cta")}</Link>
</Button>
}
coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?"
/>
</SubPageShell>
);
/**
* Thin RSC wrapper around the (client) mood insights page.
*
* The page's first paint is the mood calendar over the line chart. Both read
* their own endpoint after hydrate, and the calendar's aggregate is the
* slower of the two, so the chart used to paint first and the calendar pushed
* it down when it landed. This wrapper runs the SAME two reads the routes run
* (`moodInsightsWire` over the cached aggregate, `buildMoodDailySeries` over
* its cache) in parallel during SSR and hands both to TanStack, so the two
* appear together in the first client frame. The client page still gates the
* pair on each other, which is what keeps them together when this wrapper
* steps aside.
*
* Contract notes (the `/mood` wrapper's, unchanged):
* - the seeded keys are exactly the client's (`moodInsights()`,
* `moodAnalytics()`), and each value is JSON-round-tripped into the wire
* shape the client `queryFn` reads — never a Date-carrying sibling;
* - module-gate parity: nothing is prefetched when `modules.mood` is off;
* - record identity: `getUnswitchedSession()` answers null while the browser
* acts on somebody else's record, so a delegate's own mood never seeds a
* page opened on the owner's record;
* - fail-soft and time-boxed: no session, a lookup hiccup or a slow cold
* aggregate renders the page exactly as the client path would.
*/
export default async function InsightsMoodPage() {
// The e2e server turns every SSR prefetch off so its route mocks, which
// only see CLIENT fetches, keep governing what the page paints.
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
return <InsightsMoodPageClient />;
}
let dehydratedState = null;
try {
const session = await getUnswitchedSession();
if (session) {
const { user } = session;
const modules = await resolveModuleMap(user.id);
if (modules.mood !== false) {
const [aggregates, series] = await Promise.all([
withinBudget(
cachedSwr(
caches.moodInsights as ServerCache<MoodAggregates>,
user.id,
() => fetchMoodAggregates(user.id),
),
),
withinBudget(
cached(
caches.moodAnalytics as ServerCache<MoodDailySeries>,
user.id,
() => buildMoodDailySeries(user.id),
),
),
]);
const queryClient = new QueryClient();
if (aggregates) {
queryClient.setQueryData(
queryKeys.moodInsights(),
JSON.parse(JSON.stringify(moodInsightsWire(aggregates, 365))),
);
}
if (series) {
queryClient.setQueryData(
queryKeys.moodAnalytics(),
JSON.parse(
JSON.stringify({
entries: series.entries,
summary: series.summary,
}),
),
);
}
if (aggregates || series) dehydratedState = dehydrate(queryClient);
}
}
} catch {
// Prefetch is an accelerator, never a gate — the client path stands.
}
if (dehydratedState === null) {
return <InsightsMoodPageClient />;
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
explainerMetric="mood"
coachLaunch
>
{/* v1.12.7 — operator spine: heading + summary (the shell above), then
the Stimmungskalender, then the line chart, then the Ziel card, then
the better-days Einschätzung, then the classification + breakdowns.
The heatmap and the assessment are lifted out of `MoodInsightsSections`
into their own regions so they land in this exact order; all three
regions share one `moodInsights` query (TanStack dedups the fetch). */}
{/* 2026-07-17 UX/IA audit M9 — mood, the mental-wellbeing screeners, and
this insights page form one mental-health domain; this cross-links
into the other two so the trend a user reads here can lead
somewhere. */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<Link
href="/mood"
data-slot="insights-mood-log-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.logLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
{user?.modules?.mentalHealth === true ? (
<Link
href="/mental-wellbeing"
data-slot="insights-mood-mental-wellbeing-link"
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1.5 text-sm underline-offset-4 transition-colors hover:underline focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("insights.mood.mentalWellbeingLink")}
<ArrowRight className="size-3.5" aria-hidden="true" />
</Link>
) : null}
</div>
<MoodInsightsSections region="heatmap" />
{/* No `<MetricRangeControls>` here: mood is event-driven, not a
MeasurementType series, so the period-over-period range read
(`/api/analytics/range`, keyed on a MeasurementType enum) has
nothing to aggregate. */}
<MoodChart chartKey="mood" compareBaseline={compareBaseline} />
<MetricTargetSummary slug="mood" />
{/* The day's own rating beside what the account's past days imply. It
sits high because the self-assessment is the leading value on this
page, and directly under the line chart because that is where a
reader has just seen the rating it leads with. */}
<MoodPrognosisCard />
{/* The better-days Einschätzung sits directly under the Ziel card,
ahead of the classification tiles and breakdowns. */}
<MoodInsightsSections region="assessment" />
<MoodInsightsSections region="rest" />
{/* v1.12.2 — the assessment is the LAST block on every bespoke
metric-detail page, matching the canonical spine the generic
scaffold (weight / bmi / pulse / blood-pressure) renders. The
reader sees the trend and the breakdown sections first, then the
narration of them at the foot. */}
<SlugInsightStatusCard slug="mood" icon={<Smile className="h-5 w-5" />} />
</SubPageShell>
<HydrationBoundary state={dehydratedState}>
<InsightsMoodPageClient />
</HydrationBoundary>
);
}
+10 -18
View File
@@ -1,13 +1,16 @@
"use client";
import { useMemo, useState } from "react";
import { Activity, Plus } from "lucide-react";
import { Activity } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslations } from "@/lib/i18n/context";
import { useInfiniteWorkouts } from "@/hooks/use-workouts";
import { MetricEmptyState } from "@/components/insights/metric-empty-state";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import {
SubPageAddButton,
SubPageShell,
} from "@/components/insights/sub-page-shell";
import { WorkoutList } from "@/components/insights/workout-list";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
@@ -91,23 +94,12 @@ export default function InsightsWorkoutsPageClient() {
coachLaunch
headerAction={
showLog ? (
// Same 40 px ghost icon and widened hit area as the header
// cluster's other actions; it opens the form in place.
<Button
type="button"
variant="ghost"
size="icon"
data-slot="workout-log-manual"
aria-label={t("insights.workouts.manual.logWorkout")}
title={t("insights.workouts.manual.logWorkout")}
// The shared header plus; it opens the form in place.
<SubPageAddButton
label={t("insights.workouts.manual.logWorkout")}
onClick={() => setLogOpen(true)}
className={cn(
"text-muted-foreground hover:text-foreground relative size-10",
"before:absolute before:-inset-1.5 before:content-['']",
)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
slot="workout-log-manual"
/>
) : null
}
>
+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
+27
View File
@@ -979,3 +979,30 @@ export async function fetchMoodAggregates(
})),
};
}
/**
* The `/api/mood/insights` wire for one stability window.
*
* The per-window map is the cache's, not the wire's: the chosen window is
* served as `stability`. An aggregate cached before the map existed has only
* the year, so a shorter window reads as not yet computed (`null`) until the
* next rebuild rather than as the year's score under a new name.
*
* Shared by the route and by the `/insights/mood` server prefetch, so the
* cell the prefetch seeds is byte-for-byte what the client's own fetch would
* have put there — the same key with a different shape is silent cache poison.
*/
export function moodInsightsWire(
result: MoodAggregates,
stabilityWindowDays: MoodStabilityWindow,
) {
const { stabilityByWindow, ...wire } = result;
return {
...wire,
stability:
stabilityWindowDays === 365
? result.stability
: (stabilityByWindow?.[stabilityWindowDays] ?? null),
stabilityWindowDays,
};
}