mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12: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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user