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

+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));