mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
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:
1 parent
1a5eab9f7c
commit
41ed8eb6a3
21 files changed
+1223
-339
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user