mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Accelerate the chart range switch, prefetch two list pages, calm two polls
placeholderData keeps the previous range painted while a health-chart range switch fetches, paired with its truth condition: the container dims and goes inert while isPlaceholderData is true, so the old range cannot masquerade as the new one. The mood chart needs no pairing — its range is a client-side slice over one static-key query and never drops to a skeleton. /checkups and /mood gain the dashboard-template RSC prefetch: the primary list read moves into a shared lib read (route + wrapper consume one query, so filter/order/wire shape cannot drift), the wrapper honours the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through getUnswitchedSession, JSON-round-trips the dehydrated state, and fails soft to the bare client on every error path. Page-prefetch unit tests pin the seeded keys and the fail-soft arms; the SSR hydration spec gains an assertion per page. The achievement-unlock poll drops to five minutes (in-app writes already invalidate the cell; the poll only covers out-of-band ingest), and the plan-proposal poll gains the advisor-style bounded attempt ceiling so an open thread stops polling after its budget.
This commit is contained in:
1 parent
ac96e5e975
commit
61114fab90
31 files changed
+962
-326
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
@@ -38,28 +39,16 @@ async function resolveTimezone(userId: string): Promise<string> {
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
// A booked visit's one-shot reminder rides this same engine with
|
||||
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
|
||||
// Vorsorge surface, or the list fills with appointments. Four read
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
|
||||
// to the end so the actionable items float to the top.
|
||||
orderBy: [
|
||||
{ nextDueAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
// The query (with its ENCOUNTER-origin exclusion) lives in the shared
|
||||
// list read so this route and the `/checkups` SSR prefetch cannot drift.
|
||||
const reminders = await listMeasurementReminders(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.list" },
|
||||
meta: { total: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess(reminders.map(toMeasurementReminderDto));
|
||||
return apiSuccess(reminders);
|
||||
});
|
||||
|
||||
async function postReminder(request: NextRequest): Promise<Response> {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { listMoodEntriesPage } from "@/lib/mood/list-read";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -78,87 +79,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
|
||||
deletedAt: null,
|
||||
...(mood && { mood }),
|
||||
// v1.15.13 — management-list source filter.
|
||||
...(source && { source }),
|
||||
...(from || to
|
||||
? {
|
||||
date: {
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
|
||||
// the client can split binary tags from rated factors on hydrate.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
// The query + wire shaping (note decryption, tag split, day context) live
|
||||
// in the shared list read so this route and the `/mood` SSR prefetch
|
||||
// cannot drift.
|
||||
const body = await listMoodEntriesPage(user.id, parsed.data);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood-entries.list" },
|
||||
meta: { total, limit, offset },
|
||||
meta: {
|
||||
total: body.meta.total,
|
||||
limit: body.meta.limit,
|
||||
offset: body.meta.offset,
|
||||
},
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
entries: entriesWithParsedTags,
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* The `/checkups` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/checkups/page.tsx`) dehydrates the Vorsorge
|
||||
* reminder list under the SAME zero-arg factory key the client hook
|
||||
* (`useMeasurementReminders`) mounts on, via the shared
|
||||
* `listMeasurementReminders` read — so the checkups list paints from the
|
||||
* first HTML. These tests pin the seeded key (byte-identical hash), the
|
||||
* JSON-wire shape, and that every error path fails soft to the bare client.
|
||||
*/
|
||||
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const listMeasurementReminders = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/measurement-reminders/list-read", () => ({
|
||||
listMeasurementReminders: (userId: string) =>
|
||||
listMeasurementReminders(userId),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import CheckupsPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function queriesOf(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } }[] {
|
||||
if (el.type !== HydrationBoundary) return [];
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
return props.state?.queries ?? [];
|
||||
}
|
||||
|
||||
describe("/checkups RSC prefetch", () => {
|
||||
it("dehydrates the reminder list under the client's factory key", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
listMeasurementReminders.mockResolvedValue([
|
||||
{ id: "r1", label: "Blood pressure", nextDueAt: null },
|
||||
]);
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
const hashes = queriesOf(el).map((q) => q.queryHash);
|
||||
expect(hashes).toContain(hashKey(queryKeys.measurementReminders()));
|
||||
expect(listMeasurementReminders).toHaveBeenCalledWith("u1");
|
||||
});
|
||||
|
||||
it("JSON-round-trips the list to the wire shape", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
const createdAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
listMeasurementReminders.mockResolvedValue([{ id: "r1", createdAt }]);
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
const seeded = queriesOf(el).find(
|
||||
(q) => q.queryHash === hashKey(queryKeys.measurementReminders()),
|
||||
);
|
||||
expect((seeded!.state.data as { createdAt: unknown }[])[0]!.createdAt).toBe(
|
||||
createdAt.toISOString(),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
listMeasurementReminders.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMeasurementReminders).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await CheckupsPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
*
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
*/
|
||||
export default function CheckupsPageClient() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <PageAuthGate />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+62
-68
@@ -1,80 +1,74 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { VorsorgeSection } from "@/components/measurement-reminders/vorsorge-section";
|
||||
import { VisitsSection } from "@/components/encounters/visits-section";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
||||
import CheckupsPageClient from "./page-client";
|
||||
|
||||
/**
|
||||
* v1.17.1 — Checkups (preventive-care) reminders page. The dedicated
|
||||
* feature surface for "wann muss ich was wo machen". Auth-gated; the
|
||||
* section component owns the list + create flow.
|
||||
* Thin RSC wrapper around the (client) checkups page.
|
||||
*
|
||||
* v1.18.1 — pull-to-refresh parity with every peer page (labs, mood,
|
||||
* measurements): a pull invalidates the measurement-reminders read so the
|
||||
* list + server-computed next-due repaint.
|
||||
* The page's above-the-fold content — the Vorsorge reminder list — waited for
|
||||
* the client `queryKeys.measurementReminders()` cell to fetch after hydrate,
|
||||
* so the first paint flashed a skeleton before the list filled in. This
|
||||
* wrapper runs the SAME read the API route uses (`listMeasurementReminders`,
|
||||
* shared so filter/order/DTO cannot drift) during SSR and hands it to
|
||||
* TanStack through `HydrationBoundary`.
|
||||
*
|
||||
* The page now holds two things, split by a segmented control rather than by a
|
||||
* second nav entry: the preventive-care list, and the visits. They answer the
|
||||
* same question from two directions — what do I still have to arrange, and what
|
||||
* did I actually attend — and the page's own sentence already covers both. A
|
||||
* visit carries no module gate for the same reason this page never had one.
|
||||
*
|
||||
* The pull invalidates both reads. Refreshing one and leaving the other stale
|
||||
* is how a page ends up showing a visit that closed a checkup still listed as
|
||||
* due.
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - The query key comes ONLY from the central factory —
|
||||
* `queryKeys.measurementReminders()` is a deterministic zero-arg tuple, so
|
||||
* the server-seeded key equals the client's `useQuery` key by construction.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is
|
||||
* exactly what the client `queryFn` produces from the wire (ISO date
|
||||
* strings), never a Date-carrying sibling that poisons the cell.
|
||||
* - Only the PRIMARY read is prefetched. The visits tab mounts its own read
|
||||
* lazily on first open, and that stays client-lazy on purpose — most
|
||||
* visits to this page never open it.
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record —
|
||||
* the prefetch must never serialise the delegate's own reminders into a
|
||||
* page opened on the owner's record.
|
||||
* - Fail-soft: no session or a read hiccup renders the page exactly as
|
||||
* before this wrapper existed — the client cell owns the fetch.
|
||||
*/
|
||||
export default function CheckupsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslations();
|
||||
const [view, setView] = useState("vorsorge");
|
||||
|
||||
const refresh = useCallback(
|
||||
() =>
|
||||
invalidateKeys(queryClient, [
|
||||
queryKeys.measurementReminders(),
|
||||
queryKeys.encounters(),
|
||||
]),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({ onRefresh: refresh });
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
return <PageAuthGate />;
|
||||
export default async function CheckupsPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <CheckupsPageClient />;
|
||||
}
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const list = await listMeasurementReminders(session.user.id);
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO date
|
||||
// strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.measurementReminders(),
|
||||
JSON.parse(JSON.stringify(list)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <CheckupsPageClient />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<Tabs value={view} onValueChange={setView}>
|
||||
<TabsList aria-label={t("checkups.viewSwitchLabel")}>
|
||||
<TabsTrigger value="vorsorge" data-testid="checkups-tab-vorsorge">
|
||||
{t("checkups.tabVorsorge")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visits" data-testid="checkups-tab-visits">
|
||||
{t("checkups.tabVisits")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="vorsorge">
|
||||
<VorsorgeSection enabled={isAuthenticated} variant="page" />
|
||||
</TabsContent>
|
||||
<TabsContent value="visits">
|
||||
{/* Mounted only on the active tab, so the visits read does not fire
|
||||
for a person who never opens it. */}
|
||||
<VisitsSection enabled={isAuthenticated} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<CheckupsPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HydrationBoundary, hashKey } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size";
|
||||
|
||||
/**
|
||||
* The `/mood` server-prefetch key crux + fail-soft.
|
||||
*
|
||||
* The RSC wrapper (`src/app/mood/page.tsx`) dehydrates the mood list's FIRST
|
||||
* page under the parameterised `queryKeys.moodEntriesList(…)` tuple the
|
||||
* client list mounts with (no filters, page 1, `moodLoggedAt desc`), via the
|
||||
* shared `listMoodEntriesPage` read. These tests pin the seeded key
|
||||
* (byte-identical hash), the first-mount parameters (`MOOD_LIST_PAGE_SIZE`,
|
||||
* offset 0), the module-gate parity, and the fail-soft paths.
|
||||
*/
|
||||
|
||||
const getUnswitchedSession = vi.fn();
|
||||
const listMoodEntriesPage = vi.fn();
|
||||
const resolveModuleMap = vi.fn();
|
||||
|
||||
vi.mock("@/lib/auth/acting-carrier", () => ({
|
||||
getUnswitchedSession: () => getUnswitchedSession(),
|
||||
}));
|
||||
vi.mock("@/lib/mood/list-read", () => ({
|
||||
listMoodEntriesPage: (userId: string, params: unknown) =>
|
||||
listMoodEntriesPage(userId, params),
|
||||
}));
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
resolveModuleMap: (userId: string) => resolveModuleMap(userId),
|
||||
}));
|
||||
vi.mock("../page-client", () => ({ default: () => null }));
|
||||
|
||||
import MoodPage from "../page";
|
||||
|
||||
const SESSION = { user: { id: "u1", timezone: "Europe/Berlin" } };
|
||||
|
||||
/** The client list's first-mount key (no filters, page 1, default sort). */
|
||||
const FIRST_MOUNT_KEY = queryKeys.moodEntriesList({
|
||||
mood: undefined,
|
||||
source: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 1,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.DASHBOARD_SSR_PREFETCH;
|
||||
});
|
||||
|
||||
function queriesOf(
|
||||
el: ReactElement,
|
||||
): { queryHash: string; state: { data: unknown } }[] {
|
||||
if (el.type !== HydrationBoundary) return [];
|
||||
const props = el.props as {
|
||||
state?: { queries: { queryHash: string; state: { data: unknown } }[] };
|
||||
};
|
||||
return props.state?.queries ?? [];
|
||||
}
|
||||
|
||||
describe("/mood RSC prefetch", () => {
|
||||
it("dehydrates the first list page under the client's first-mount key", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
listMoodEntriesPage.mockResolvedValue({
|
||||
entries: [],
|
||||
meta: { total: 0, limit: MOOD_LIST_PAGE_SIZE, offset: 0 },
|
||||
});
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
const hashes = queriesOf(el).map((q) => q.queryHash);
|
||||
expect(hashes).toContain(hashKey(FIRST_MOUNT_KEY));
|
||||
// The read runs with the client's first-mount slice parameters.
|
||||
expect(listMoodEntriesPage).toHaveBeenCalledWith("u1", {
|
||||
limit: MOOD_LIST_PAGE_SIZE,
|
||||
offset: 0,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
});
|
||||
|
||||
it("JSON-round-trips the page body to the wire shape", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
const loggedAt = new Date("2026-07-18T06:00:00.000Z");
|
||||
listMoodEntriesPage.mockResolvedValue({
|
||||
entries: [{ id: "m1", moodLoggedAt: loggedAt }],
|
||||
meta: { total: 1, limit: MOOD_LIST_PAGE_SIZE, offset: 0 },
|
||||
});
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
const seeded = queriesOf(el).find(
|
||||
(q) => q.queryHash === hashKey(FIRST_MOUNT_KEY),
|
||||
);
|
||||
const data = seeded!.state.data as {
|
||||
entries: { moodLoggedAt: unknown }[];
|
||||
};
|
||||
expect(data.entries[0]!.moodLoggedAt).toBe(loggedAt.toISOString());
|
||||
});
|
||||
|
||||
it("skips the prefetch when the mood module is explicitly off", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({ mood: false });
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMoodEntriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails soft to the bare client when the read throws", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(SESSION);
|
||||
resolveModuleMap.mockResolvedValue({});
|
||||
listMoodEntriesPage.mockRejectedValue(new Error("db blip"));
|
||||
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
});
|
||||
|
||||
// Null also means "acting on somebody else's record" — `getUnswitchedSession`
|
||||
// collapses both into the same answer, and the page treats them the same.
|
||||
it("fails soft when there is no session", async () => {
|
||||
getUnswitchedSession.mockResolvedValue(null);
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(listMoodEntriesPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honours the DASHBOARD_SSR_PREFETCH kill-switch", async () => {
|
||||
process.env.DASHBOARD_SSR_PREFETCH = "false";
|
||||
const el = (await MoodPage()) as ReactElement;
|
||||
expect(el.type).not.toBe(HydrationBoundary);
|
||||
expect(getUnswitchedSession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPageClient() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
// `!mounted` keeps the hydration render identical to the SSR HTML: the
|
||||
// auth query is fired by the early-hydrating shell and can settle before
|
||||
// this page boundary hydrates, so `isLoading` alone flipped the branch
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<PageHeader
|
||||
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
|
||||
description={t("mood.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
of the add button) and responsive 44-px mobile tap floor
|
||||
as the medications header.
|
||||
|
||||
v1.36.x — and gated like the other four wrenches. It was the
|
||||
one of five left open, so a delegate tapped it straight into
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setDialogOpen(false)}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<MoodList onAddFirst={() => setDialogOpen(true)} />
|
||||
|
||||
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
|
||||
screeners, and the mood insights page form one mental-health
|
||||
domain but used to be three unconnected islands. A quiet pointer
|
||||
here (module-gated — no nav change) rather than a merge: capture
|
||||
vs. screener stay two defensible surfaces, just cross-linked. */}
|
||||
{user?.modules?.mentalHealth === true ? (
|
||||
<Link
|
||||
href="/mental-wellbeing"
|
||||
data-slot="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("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+92
-148
@@ -1,156 +1,100 @@
|
||||
"use client";
|
||||
import {
|
||||
dehydrate,
|
||||
HydrationBoundary,
|
||||
QueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { useMounted } from "@/hooks/use-mounted";
|
||||
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
||||
import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MoodList } from "@/components/mood/mood-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { PageAuthGate } from "@/components/ui/page-auth-gate";
|
||||
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { ArrowRight, Plus, Wrench } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { listMoodEntriesPage } from "@/lib/mood/list-read";
|
||||
import { MOOD_LIST_PAGE_SIZE } from "@/lib/mood/list-page-size";
|
||||
import { resolveModuleMap } from "@/lib/modules/gate";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const { canManage, inSharedRecord } = useRecordCapabilities();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// v1.4.27 R4 RC2 — DOM-ref handle the form portals its action row
|
||||
// into. The ref lives on the `<ResponsiveSheet>` footer slot so the
|
||||
// Sheet branch can sticky-pin Save / Cancel above the keyboard.
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
import MoodPageClient from "./page-client";
|
||||
|
||||
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = inSharedRecord || user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
// open so a drag inside the form can't arm the gesture.
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
);
|
||||
const pull = usePullToRefresh({
|
||||
onRefresh: refreshVisible,
|
||||
disabled: dialogOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
// `!mounted` keeps the hydration render identical to the SSR HTML: the
|
||||
// auth query is fired by the early-hydrating shell and can settle before
|
||||
// this page boundary hydrates, so `isLoading` alone flipped the branch
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
/**
|
||||
* Thin RSC wrapper around the (client) mood page.
|
||||
*
|
||||
* The page's primary content — the mood management list — waited for the
|
||||
* client `queryKeys.moodEntriesList(…)` cell to fetch the first page after
|
||||
* hydrate, so the first paint flashed a skeleton before the entries filled
|
||||
* in. This wrapper runs the SAME read the API route uses
|
||||
* (`listMoodEntriesPage`, shared so filter/order/wire shape cannot drift)
|
||||
* with the list's first-mount defaults during SSR and hands it to TanStack
|
||||
* through `HydrationBoundary`.
|
||||
*
|
||||
* Contract notes (the v1.30.9 dashboard template):
|
||||
* - THE CRUX — the list key is parameterised. The seeded tuple spells the
|
||||
* client's first-mount state exactly: no filters, page 1, the default
|
||||
* `moodLoggedAt desc` sort, and `MOOD_LIST_PAGE_SIZE` rows (the shared
|
||||
* constant the client's `queryFn` also uses). Any other page/filter state
|
||||
* keys a different slot and fetches client-side as before.
|
||||
* - The dehydrated VALUE is JSON-round-tripped so the hydrated shape is
|
||||
* exactly what the client `queryFn` produces from the wire (ISO date
|
||||
* strings, decrypted note as plain `note`), never a Date-carrying sibling
|
||||
* that poisons the cell.
|
||||
* - Module-gate parity: the client page redirects when `modules.mood` is
|
||||
* explicitly `false`; skip the prefetch then (the client never mounts the
|
||||
* list).
|
||||
* - Record identity: the session comes from `getUnswitchedSession()`, which
|
||||
* answers null while the browser is acting on somebody else's record —
|
||||
* the prefetch must never serialise the delegate's own entries into a
|
||||
* page opened on the owner's record.
|
||||
* - Fail-soft: no session, a module lookup hiccup, or a read hiccup renders
|
||||
* the page exactly as before this wrapper existed — the client cell owns
|
||||
* the fetch.
|
||||
*/
|
||||
export default async function MoodPage() {
|
||||
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
|
||||
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
|
||||
// only see CLIENT fetches — keep governing what every prefetched page paints.
|
||||
if (process.env.DASHBOARD_SSR_PREFETCH === "false") {
|
||||
return <MoodPageClient />;
|
||||
}
|
||||
|
||||
let dehydratedState = null;
|
||||
try {
|
||||
const session = await getUnswitchedSession();
|
||||
if (session) {
|
||||
const { user } = session;
|
||||
const modules = await resolveModuleMap(user.id);
|
||||
// Mirror the client gate (`user?.modules?.mood !== false`): an absent
|
||||
// key reads as enabled; only an explicit `false` disables.
|
||||
if (modules.mood !== false) {
|
||||
const body = await listMoodEntriesPage(user.id, {
|
||||
limit: MOOD_LIST_PAGE_SIZE,
|
||||
offset: 0,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
});
|
||||
const queryClient = new QueryClient();
|
||||
// Match the client cell's wire shape exactly (JSON semantics, ISO
|
||||
// date strings) — same-key-different-shape is silent cache poison.
|
||||
queryClient.setQueryData(
|
||||
queryKeys.moodEntriesList({
|
||||
mood: undefined,
|
||||
source: undefined,
|
||||
from: undefined,
|
||||
to: undefined,
|
||||
page: 1,
|
||||
sortBy: "moodLoggedAt",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
JSON.parse(JSON.stringify(body)),
|
||||
);
|
||||
dehydratedState = dehydrate(queryClient);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Prefetch is an accelerator, never a gate — the client path stands.
|
||||
}
|
||||
|
||||
if (dehydratedState === null) {
|
||||
return <MoodPageClient />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PullToRefreshIndicator {...pull} />
|
||||
<PageHeader
|
||||
title={<span data-tour-id="mood-hero">{t("mood.title")}</span>}
|
||||
description={t("mood.subtitle")}
|
||||
actions={
|
||||
<>
|
||||
{/* v1.17 — the wrench is the one customize entry point: it
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
of the add button) and responsive 44-px mobile tap floor
|
||||
as the medications header.
|
||||
|
||||
v1.36.x — and gated like the other four wrenches. It was the
|
||||
one of five left open, so a delegate tapped it straight into
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
onClick={() => setDialogOpen(true)}
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t("mood.addEntry")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResponsiveSheet
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MoodForm
|
||||
onSuccess={() => setDialogOpen(false)}
|
||||
onCancel={() => setDialogOpen(false)}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
|
||||
<MoodList onAddFirst={() => setDialogOpen(true)} />
|
||||
|
||||
{/* 2026-07-17 UX/IA audit M9 — mood tracking, the mental-wellbeing
|
||||
screeners, and the mood insights page form one mental-health
|
||||
domain but used to be three unconnected islands. A quiet pointer
|
||||
here (module-gated — no nav change) rather than a merge: capture
|
||||
vs. screener stay two defensible surfaces, just cross-linked. */}
|
||||
{user?.modules?.mentalHealth === true ? (
|
||||
<Link
|
||||
href="/mental-wellbeing"
|
||||
data-slot="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("mood.mentalWellbeingLink")}
|
||||
<ArrowRight className="size-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<HydrationBoundary state={dehydratedState}>
|
||||
<MoodPageClient />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user