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

+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
}
>