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:
Marc-André Bombeck committed 2026-08-13 19:06:56 +02:00
1 parent ac96e5e975
commit 61114fab90
31 files changed
+962 -326

No files matched your search

@@ -97,4 +97,45 @@ test.describe("dashboard hydration with the SSR prefetch on", () => {
expect(witness?.seen).toBe(true);
expect(witness?.replaced).toBe(0);
});
// The two list-page prefetch wrappers (`/checkups`, `/mood`) hand TanStack a
// server-dehydrated state through the same HydrationBoundary mechanism the
// dashboard uses. Their page-clients render behind a `useMounted` gate, so
// the streamed HTML is the auth-gate shell by design — the node-identity
// witness has nothing to latch. What CAN break, and what these assert, is
// the hydration itself: a dehydrated state that disagrees with the client
// render surfaces as a React #418 bailout on exactly this server (the
// mocked suite runs with the prefetch OFF and can never see it).
for (const target of [
{
path: "/checkups",
name: "checkups",
readySelector: '[data-testid="checkups-tab-vorsorge"]',
},
{
path: "/mood",
name: "mood",
readySelector: '[data-tour-id="mood-hero"]',
},
]) {
test(`hydrates ${target.name} with its prefetched list state`, async ({
page,
}) => {
const clientErrors: string[] = [];
page.on("pageerror", (error) => clientErrors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") clientErrors.push(message.text());
});
await page.goto(target.path);
await expect(page.locator(target.readySelector)).toBeVisible();
// Let every boundary hydrate and every post-mount refetch land before
// the verdict; a bailout that happens late is still a bailout.
await page.waitForLoadState("networkidle");
expect(
clientErrors.filter((message) => /418|hydrat/i.test(message)),
).toEqual([]);
});
}
});
@@ -140,7 +140,7 @@ describe("the customise shortcut is withheld inside somebody else's record", ()
// clean tree are the same sentence otherwise, and this is a leg whose
// verdict is an empty list.
expect(carrying.length).toBeGreaterThan(4);
expect(carrying).toContain("app/mood/page.tsx");
expect(carrying).toContain("app/mood/page-client.tsx");
expect(carrying).toContain("app/medications/page-client.tsx");
for (const rel of Object.keys(NOT_A_SHARED_DESTINATION)) {
expect(carrying, rel).toContain(rel);
@@ -52,8 +52,11 @@ function read(relativePath: string): string {
*/
const VORSORGE_READ_SITES: ReadonlyArray<{ file: string; surface: string }> = [
{
file: "src/app/api/measurement-reminders/route.ts",
surface: "the reminder list the checkups page renders",
// The list query moved out of the route into the shared read so the
// `/checkups` SSR prefetch and `GET /api/measurement-reminders` cannot
// drift; the exclusion travels with the query.
file: "src/lib/measurement-reminders/list-read.ts",
surface: "the reminder list the checkups page and its SSR prefetch render",
},
{
file: "src/lib/daily/load-digest.ts",
+5 -16
View File
@@ -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> {
+11 -76
View File
@@ -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();
});
});
+80
View File
@@ -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
View File
@@ -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();
});
});
+156
View File
@@ -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
View File
@@ -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>
);
}
@@ -29,6 +29,9 @@ vi.mock("@/hooks/use-auth", () => ({
function mockQueryError() {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: undefined,
isLoading: false,
@@ -39,6 +39,9 @@ describe("<HealthChart> — accessibility label", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -30,6 +30,9 @@ const sample30Days = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: sample30Days.map((row) => {
const ts = Date.parse(row.measuredAt);
@@ -30,6 +30,9 @@ async function renderChart(
extraProps: Record<string, unknown> = {},
) {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -38,6 +38,9 @@ vi.mock("@/hooks/use-auth", () => ({
async function renderChart(data: unknown[]): Promise<string> {
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -50,6 +50,9 @@ describe("<HealthChart> — card heading level", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -22,6 +22,9 @@ const sampleData = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data: sampleData, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -39,6 +39,9 @@ describe("<HealthChart> — empty-window state (W11-M6)", () => {
const data = buildData();
vi.doMock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({ data, isLoading: false }),
useQueryClient: () => ({
cancelQueries: () => Promise.resolve(),
@@ -41,6 +41,9 @@ const sampleSeries = vi.hoisted(() => {
});
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: () => ({
data: sampleSeries.map((row) => {
const ts = Date.parse(row.measuredAt);
@@ -22,6 +22,9 @@ import { renderToStaticMarkup } from "react-dom/server";
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: ({
queryFn,
queryKey,
@@ -15,6 +15,9 @@ import { renderToStaticMarkup } from "react-dom/server";
const fetchCalls: string[] = [];
vi.mock("@tanstack/react-query", () => ({
// `health-chart` imports `keepPreviousData` for its placeholder option;
// the identity stand-in keeps the module-level destructure satisfied.
keepPreviousData: (previous: unknown) => previous,
useQuery: ({
queryFn,
queryKey,
+18 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { queryKeys } from "@/lib/query-keys";
@@ -772,7 +772,13 @@ export function HealthChart({
);
}, [preloadedSeries, preloadedCoverageDays, fetchWindow.windowDays, types]);
const { data, isLoading, isError, refetch } = useQuery({
const { data, isLoading, isError, isPlaceholderData, refetch } = useQuery({
// A range-tab change re-keys the query; without placeholder data every
// switch dropped the painted chart to a skeleton for the fetch round-trip.
// Keeping the previous range's series visible is only honest because the
// render below pairs it with a visual dimming while `isPlaceholderData`
// is true — the old range must never masquerade as the new one.
placeholderData: keepPreviousData,
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
// and a fresh measurement evicts every per-chart daily cache in one
@@ -1867,7 +1873,16 @@ export function HealthChart({
/>
) : (
<>
<div className={`relative ${chartHeightClass}`}>
<div
// The truth half of `placeholderData: keepPreviousData` above:
// while the re-keyed range is still fetching, the previous
// range's series stays painted but visibly dimmed and inert, so
// it cannot masquerade as the new range.
aria-busy={isPlaceholderData}
className={`relative ${chartHeightClass} transition-opacity ${
isPlaceholderData ? "pointer-events-none opacity-50" : ""
}`}
>
{visibleBands.length > 0 ? (
// v1.4.27 R3d MB2 — band overlay positioning fix. The
// overlay used to inset `right: 18px` while the chart
@@ -80,7 +80,12 @@ export function AchievementUnlockNotifier({
// to re-render on the same cadence.
const { data } = useAchievementsQuery({
enabled: !!user && achievementsEnabled,
refetchInterval: 2 * 60 * 1000,
// 5 min: every in-app write already invalidates the achievements cell
// (the `*DependentKeys` bundles all carry it), so this poll exists ONLY
// to surface an unlock earned by out-of-band ingest (an iOS batch, a
// Withings sync). A toast for those arriving up to five minutes later is
// an honest trade against an app-global 2-minute poll.
refetchInterval: 5 * 60 * 1000,
});
useEffect(() => {
@@ -5,7 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { CoachPlanDTO } from "@/hooks/use-coach-plans";
import { PlanProposalCards } from "../plan-proposal-card";
import {
PlanProposalCards,
PROPOSAL_POLL_MS,
PROPOSAL_POLL_MAX_ATTEMPTS,
nextProposalPollInterval,
} from "../plan-proposal-card";
/**
* The plan-proposal confirm cards at the thread tail. SSR string render
@@ -80,3 +85,21 @@ describe("<PlanProposalCards>", () => {
expect(html).toContain("after dinner");
});
});
describe("nextProposalPollInterval", () => {
it("polls at the slow interval below the attempt ceiling", () => {
expect(nextProposalPollInterval(0)).toBe(PROPOSAL_POLL_MS);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS - 1)).toBe(
PROPOSAL_POLL_MS,
);
});
it("stops polling once the attempt ceiling is reached", () => {
// Watched red: asserting an interval at the cap fails — the ceiling is
// live, not decorative.
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS)).toBe(false);
expect(nextProposalPollInterval(PROPOSAL_POLL_MAX_ATTEMPTS + 5)).toBe(
false,
);
});
});
@@ -33,7 +33,30 @@ import {
*/
/** Slow poll (ms) — proposals arrive via the post-turn background worker. */
const PROPOSAL_POLL_MS = 20_000;
export const PROPOSAL_POLL_MS = 20_000;
/**
* Attempt ceiling for the proposal poll. The extractor runs right after the
* turn and lands its proposal within a couple of minutes; 15 polls (~5 min
* of coverage) catch it with margin, and every settled response counts an
* attempt. Without the cap an open thread polled the proposed list every
* 20 s indefinitely. Same bounded-poll shape as `nextAdvisorPollInterval`
* (`use-insights-advisor.ts`); a new turn's invalidation bumps the query and
* the next mount of a fresh conversation restarts the budget.
*/
export const PROPOSAL_POLL_MAX_ATTEMPTS = 15;
/**
* Decide whether the proposal query schedules its next poll. Pure so the
* ceiling is unit-testable: returns the slow interval until the attempt cap
* is reached, then `false` (poll off).
*/
export function nextProposalPollInterval(
dataUpdateCount: number,
): number | false {
if (dataUpdateCount >= PROPOSAL_POLL_MAX_ATTEMPTS) return false;
return PROPOSAL_POLL_MS;
}
type SettledOutcome = "accepted" | "declined";
@@ -49,7 +72,7 @@ export function PlanProposalCards({
const query = useCoachPlans({
filter: { status: "proposed" },
refetchInterval: PROPOSAL_POLL_MS,
refetchInterval: (q) => nextProposalPollInterval(q.state.dataUpdateCount),
});
// Quiet on error by design: the thread is the Coach's surface, and a failed
+1 -2
View File
@@ -105,6 +105,7 @@ import {
} from "./mood-context-fields";
import { dayOfLocalInput, useLinkedDayContext } from "./use-linked-day-context";
import { apiDelete, apiGet, apiPost, apiPut } from "@/lib/api/api-fetch";
import { MOOD_LIST_PAGE_SIZE as PAGE_SIZE } from "@/lib/mood/list-page-size";
import { localizedApiError } from "@/lib/api/localized-error";
// Re-export the score map under the legacy local name to keep the
@@ -140,8 +141,6 @@ interface MoodEntry {
moodLoggedAt: string;
}
const PAGE_SIZE = 25;
const MOOD_LEVELS_LIST = [
"SUPER_GUT",
"GUT",
+13 -6
View File
@@ -14,7 +14,12 @@
* prose editor by design: the PATCH contract never accepts the encrypted
* free-text fields.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { apiDelete, apiGet, apiPatch } from "@/lib/api/api-fetch";
@@ -63,12 +68,14 @@ export function useCoachPlans(opts?: {
filter?: PlanFilter;
enabled?: boolean;
/**
* Optional poll interval (ms). The chat thread uses a slow poll because
* proposals land asynchronously (the memory-refresh worker runs after the
* turn); the management page reads once. TanStack pauses the interval in
* background tabs by default, so the poll never runs unwatched.
* Optional poll interval (ms), or TanStack's interval callback so a caller
* can bound its poll (the chat thread caps attempts — see
* `nextProposalPollInterval` in `plan-proposal-card.tsx`). Proposals land
* asynchronously (the memory-refresh worker runs after the turn); the
* management page reads once. TanStack pauses the interval in background
* tabs by default, so the poll never runs unwatched.
*/
refetchInterval?: number;
refetchInterval?: UseQueryOptions<CoachPlanDTO[]>["refetchInterval"];
}) {
const filter = opts?.filter;
return useQuery({
@@ -0,0 +1,32 @@
/**
* The one Vorsorge-reminder list read, shared by `GET
* /api/measurement-reminders` and the `/checkups` RSC prefetch wrapper so the
* two cannot drift in filter, order, or DTO shape — the SSR-seeded cell must
* be byte-compatible with what the client `queryFn` gets from the wire.
*/
import { prisma } from "@/lib/db";
import {
toMeasurementReminderDto,
type MeasurementReminderDtoShape,
} from "@/lib/measurement-reminders/dto";
export async function listMeasurementReminders(
userId: string,
): Promise<MeasurementReminderDtoShape[]> {
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, 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" },
],
});
return reminders.map(toMeasurementReminderDto);
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Page size of the mood management list — shared by the client list query
* (`components/mood/mood-list.tsx`) and the `/mood` RSC prefetch wrapper, so
* the server-seeded first page carries exactly the slice the client's
* first-mount `queryFn` would fetch. Deliberately dependency-free: the client
* bundle must be able to import it, so it cannot live in the (server-only)
* list read next to the query it parameterises.
*/
export const MOOD_LIST_PAGE_SIZE = 25;
+112
View File
@@ -0,0 +1,112 @@
/**
* The one mood-entry list read, shared by `GET /api/mood-entries` and the
* `/mood` RSC prefetch wrapper so the two cannot drift in filter, order, or
* wire shape — the SSR-seeded cell must be byte-compatible with what the
* client `queryFn` gets from the wire. Ciphertext never leaves this module:
* the note is decrypted onto `note` and the context rides `contextForWire`.
*/
import { prisma } from "@/lib/db";
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
import { shapeLevelA } from "@/lib/mood/level-a";
import { contextForWire } from "@/lib/mood/context";
export interface MoodEntryListParams {
mood?: string;
source?: string;
from?: string;
to?: string;
limit: number;
offset: number;
sortBy: string;
sortDir: "asc" | "desc";
}
function parseTags(tags: string | null): string[] {
if (!tags) return [];
try {
return JSON.parse(tags) as string[];
} catch {
return [];
}
}
export async function listMoodEntriesPage(
userId: string,
params: MoodEntryListParams,
) {
const { mood, source, from, to, limit, offset, sortBy, sortDir } = params;
const where = {
userId,
// 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 }),
]);
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 {
entries: entriesWithParsedTags,
meta: { total, limit, offset },
};
}