mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
Accelerate the chart range switch, prefetch two list pages, calm two polls
placeholderData keeps the previous range painted while a health-chart range switch fetches, paired with its truth condition: the container dims and goes inert while isPlaceholderData is true, so the old range cannot masquerade as the new one. The mood chart needs no pairing — its range is a client-side slice over one static-key query and never drops to a skeleton. /checkups and /mood gain the dashboard-template RSC prefetch: the primary list read moves into a shared lib read (route + wrapper consume one query, so filter/order/wire shape cannot drift), the wrapper honours the DASHBOARD_SSR_PREFETCH kill-switch, resolves identity through getUnswitchedSession, JSON-round-trips the dehydrated state, and fails soft to the bare client on every error path. Page-prefetch unit tests pin the seeded keys and the fail-soft arms; the SSR hydration spec gains an assertion per page. The achievement-unlock poll drops to five minutes (in-app writes already invalidate the cell; the poll only covers out-of-band ingest), and the plan-proposal poll gains the advisor-style bounded attempt ceiling so an open thread stops polling after its budget.
This commit is contained in:
1 parent
ac96e5e975
commit
61114fab90
31 files changed
+962
-326
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
import { listMeasurementReminders } from "@/lib/measurement-reminders/list-read";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
@@ -38,28 +39,16 @@ async function resolveTimezone(userId: string): Promise<string> {
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read", "measurements");
|
||||
|
||||
const reminders = await prisma.measurementReminder.findMany({
|
||||
// A booked visit's one-shot reminder rides this same engine with
|
||||
// `origin: ENCOUNTER`. It is not a checkup and must not appear on a
|
||||
// Vorsorge surface, or the list fills with appointments. Four read
|
||||
// sites carry this exclusion; `encounter-reminder-exclusion.test.ts`
|
||||
// proves every one of them, and the DTO mapper refuses such a row
|
||||
// outright so a site that lost its filter fails loudly.
|
||||
where: { userId: user.id, deletedAt: null, origin: { not: "ENCOUNTER" } },
|
||||
// Most-urgent first; a null next-due (uncomputable / disabled) sinks
|
||||
// to the end so the actionable items float to the top.
|
||||
orderBy: [
|
||||
{ nextDueAt: { sort: "asc", nulls: "last" } },
|
||||
{ createdAt: "asc" },
|
||||
],
|
||||
});
|
||||
// The query (with its ENCOUNTER-origin exclusion) lives in the shared
|
||||
// list read so this route and the `/checkups` SSR prefetch cannot drift.
|
||||
const reminders = await listMeasurementReminders(user.id);
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement-reminders.list" },
|
||||
meta: { total: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess(reminders.map(toMeasurementReminderDto));
|
||||
return apiSuccess(reminders);
|
||||
});
|
||||
|
||||
async function postReminder(request: NextRequest): Promise<Response> {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWrite, shapeLevelA } from "@/lib/mood/level-a";
|
||||
import { contextForWire, persistMoodContext } from "@/lib/mood/context";
|
||||
import { listMoodEntriesPage } from "@/lib/mood/list-read";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -78,87 +79,21 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { mood, source, from, to, limit, offset, sortBy, sortDir } =
|
||||
parsed.data;
|
||||
|
||||
const where = {
|
||||
userId: user.id,
|
||||
// v1.7.0 sync — hide soft-deleted (tombstoned) rows from the list.
|
||||
deletedAt: null,
|
||||
...(mood && { mood }),
|
||||
// v1.15.13 — management-list source filter.
|
||||
...(source && { source }),
|
||||
...(from || to
|
||||
? {
|
||||
date: {
|
||||
...(from && { gte: from }),
|
||||
...(to && { lte: to }),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [entries, total] = await Promise.all([
|
||||
prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { [sortBy]: sortDir },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
// v1.8.5 — include the structured-tag link keys so the edit form
|
||||
// can pre-populate the taxonomy picker.
|
||||
// v1.12.0 — also carry the per-link `rating` + the tag `kind` so
|
||||
// the client can split binary tags from rated factors on hydrate.
|
||||
include: {
|
||||
tagLinks: {
|
||||
select: {
|
||||
rating: true,
|
||||
moodTag: { select: { key: true, kind: true } },
|
||||
},
|
||||
},
|
||||
// v1.38 — the day context, so the edit dialog can pre-populate the
|
||||
// sections without a second round trip per row. Sparse by design: most
|
||||
// rows carry none and the join costs nothing on those.
|
||||
context: true,
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({ where }),
|
||||
]);
|
||||
// The query + wire shaping (note decryption, tag split, day context) live
|
||||
// in the shared list read so this route and the `/mood` SSR prefetch
|
||||
// cannot drift.
|
||||
const body = await listMoodEntriesPage(user.id, parsed.data);
|
||||
|
||||
annotate({
|
||||
action: { name: "mood-entries.list" },
|
||||
meta: { total, limit, offset },
|
||||
meta: {
|
||||
total: body.meta.total,
|
||||
limit: body.meta.limit,
|
||||
offset: body.meta.offset,
|
||||
},
|
||||
});
|
||||
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, context, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand. The column spelling is stripped, not
|
||||
// joined: one number under two names is a choice nobody made.
|
||||
...shapeLevelA(shapeMoodNote(e)),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
tagKeys: tagLinks
|
||||
.filter((link) => link.moodTag.kind !== "RATED")
|
||||
.map((link) => link.moodTag.key),
|
||||
// v1.12.0 — rated factors with their per-entry score, so the edit
|
||||
// form re-renders the sliders without a refetch.
|
||||
ratedFactors: tagLinks
|
||||
.filter((link) => link.moodTag.kind === "RATED" && link.rating !== null)
|
||||
.map((link) => ({
|
||||
key: link.moodTag.key,
|
||||
rating: link.rating as number,
|
||||
})),
|
||||
// v1.38 — the day context, or null. The ciphertext never leaves the
|
||||
// server: `contextForWire` reads the note and drops the column.
|
||||
context: context ? contextForWire(context) : null,
|
||||
}));
|
||||
|
||||
return apiSuccess({
|
||||
entries: entriesWithParsedTags,
|
||||
meta: { total, limit, offset },
|
||||
});
|
||||
return apiSuccess(body);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMoodEntry));
|
||||
|
||||
Reference in new issue
Block a user