diff --git a/e2e/v142-qol.spec.ts b/e2e/v142-qol.spec.ts new file mode 100644 index 000000000..7e1fadd82 --- /dev/null +++ b/e2e/v142-qol.spec.ts @@ -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 { + 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; + }; + 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; + }; + 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(); + }); +}); diff --git a/messages/de.json b/messages/de.json index dccc6e5f9..7774c2681 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "Gehgeschwindigkeit", "mood": { "logLink": "Stimmung eintragen", - "mentalWellbeingLink": "Check-in zum seelischen Wohlbefinden machen", "heatmapTitle": "Stimmungskalender", "heatmapNoEntry": "Kein Eintrag", "legendGreat": "Super", diff --git a/messages/en.json b/messages/en.json index 154461567..5c2bb9d38 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index ce8949c7b..71dbfbd84 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 2d7beeb73..ebef8a983 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index 824a5b12a..c68fa9b1d 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index a8fb4aa52..01c4bc613 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -4043,7 +4043,6 @@ "navWalkingSpeed": "보행 속도", "mood": { "logLink": "기분 기록하기", - "mentalWellbeingLink": "정신 건강 체크인 하기", "heatmapTitle": "기분 캘린더", "heatmapNoEntry": "기록 없음", "legendGreat": "아주 좋음", diff --git a/messages/pl.json b/messages/pl.json index a4642ee23..10f0c1b44 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts index 883bb2c9d..609b32256 100644 --- a/src/__tests__/prefetch-page-caching-guard.test.ts +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -175,6 +175,7 @@ const PREFETCHING_ROUTES = [ "/checkups", "/coach", "/insights", + "/insights/mood", "/insights/workouts", "/medications", "/mood", diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index 9cecc9448..ff563f791 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -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": diff --git a/src/app/api/mood/insights/route.ts b/src/app/api/mood/insights/route.ts index 354d5895d..d856af2fd 100644 --- a/src/app/api/mood/insights/route.ts +++ b/src/app/api/mood/insights/route.ts @@ -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)); }); diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index 66fb9f720..49f601778 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -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 // `…` in the module (the empty-state diff --git a/src/app/insights/__tests__/header-add-pattern.test.ts b/src/app/insights/__tests__/header-add-pattern.test.ts new file mode 100644 index 000000000..72f9616a8 --- /dev/null +++ b/src/app/insights/__tests__/header-add-pattern.test.ts @@ -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(" { + const source = readFileSync(join(INSIGHTS, "mood/page-client.tsx"), "utf8"); + expect(source).not.toMatch(/ + 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: () => }, +); + +/** + * 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(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("/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 + // `` 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 ( + + } + title={t("insights.emptyState.mood.title")} + description={t("insights.emptyState.mood.description")} + cta={ + + } + coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?" + /> + + ); + } + + return ( + setAddOpen(true)} + slot="insights-mood-add" + /> + ) : null + } + > + {canAddMood ? ( + } + > + {addOpen ? ( + setAddOpen(false)} + onCancel={() => setAddOpen(false)} + footerSlot={footerEl} + /> + ) : null} + + ) : 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. */} + + + {/* No `` 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. */} +
+ {!topRevealed ? ( + + ) : null} +
+ +
+
+ + + + {/* 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. */} + + + {/* The better-days Einschätzung sits directly under the Ziel card, + ahead of the classification tiles and breakdowns. */} + + + + + {/* 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. */} + } /> +
+ ); +} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index 50886b099..e52e82600 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -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: () => }, -); +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(work: Promise): Promise { + return Promise.race([ + work, + new Promise((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("/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 - // `` 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 ( - - } - title={t("insights.emptyState.mood.title")} - description={t("insights.emptyState.mood.description")} - cta={ - - } - coachPrefill="I haven't logged any mood entries yet — why does mood tracking matter, and how should I start?" - /> - - ); +/** + * 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 ; } + 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, + user.id, + () => fetchMoodAggregates(user.id), + ), + ), + withinBudget( + cached( + caches.moodAnalytics as ServerCache, + 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 ; + } return ( - - {/* 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. */} -
- - {t("insights.mood.logLink")} -
- - - - {/* No `` 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. */} - - - - - {/* 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. */} - - - {/* The better-days Einschätzung sits directly under the Ziel card, - ahead of the classification tiles and breakdowns. */} - - - - - {/* 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. */} - } /> -
+ + + ); } diff --git a/src/app/insights/workouts/page-client.tsx b/src/app/insights/workouts/page-client.tsx index b1c3238fc..8dcd5efa2 100644 --- a/src/app/insights/workouts/page-client.tsx +++ b/src/app/insights/workouts/page-client.tsx @@ -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. - + slot="workout-log-manual" + /> ) : null } > diff --git a/src/components/charts/mood-heatmap.tsx b/src/components/charts/mood-heatmap.tsx index 39841e476..7b60bf68e 100644 --- a/src/components/charts/mood-heatmap.tsx +++ b/src/components/charts/mood-heatmap.tsx @@ -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 ( +
+
+ {children} +
+
+ ); +} + +/** + * 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 ( +