mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
perf(prefetch): server-prefetch above-the-fold data for /insights, /medications, /coach
Extend the dashboard's RSC server-prefetch pattern to three more route roots so their above-the-fold content paints populated on first load instead of flashing a shell that fills in on mount. Each route gains a thin RSC wrapper that runs the same cached read its API route uses, dehydrates the result under the exact client query key, and hands it to the client leaf through a HydrationBoundary. Every wrapper fails soft to today's client-fetch path on any error and honours the DASHBOARD_SSR_PREFETCH kill-switch the e2e server sets. - /insights: prefetch the dashboard snapshot — the same locale-keyed cell the dashboard warms, resolved once server-side so the key matches by construction. The AI briefing and the heavy analytics aggregations stay client-lazy (the briefing must never trigger generation; a cold analytics read would trade FCP for TTFB). - /medications: prefetch the medications list through the shared caches.medications cell (extracted to src/lib/medications/list-read.ts). The list cell keeps refetchOnMount: "always" — a cross-device take/skip produces no client mutation event, so the mount refetch is a correctness gate; the prefetch removes the initial skeleton flash without dropping that guarantee. - /coach: prefetch the nudge status (extracted to src/lib/ai/coach/nudge-status.ts) so the auto-open-most-recent decision is ready at hydrate, collapsing the one sequential waterfall. Only runs when the Coach surface is reachable; the streaming conversation is never warmed. Add per-route tests pinning server-key === client-key, JSON wire-shape round-trip, and the fail-soft / module-gate paths. Repoint the source-reading insights, take-all-due, and Coach gate-cascade tests at the client leaves.
This commit is contained in:
1 parent
3743a46a40
commit
e0387c5ceb
20 files changed
+2349
-1691
No files matched your search
@@ -24,46 +24,16 @@
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAssistantSurface } from "@/lib/feature-flags";
|
||||
import { readCoachNudgeStatus } from "@/lib/ai/coach/nudge-status";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
await requireAssistantSurface("coach");
|
||||
|
||||
// The newest assistant message across the caller's conversations — the
|
||||
// proactive nudge writes one, and so does every normal Coach reply. We
|
||||
// intentionally key on assistant (not user) messages: the unread dot
|
||||
// means "the Coach said something", and the open-stamp clears it.
|
||||
const [lastAssistant, seen] = await Promise.all([
|
||||
prisma.coachMessage.findFirst({
|
||||
where: { role: "assistant", conversation: { userId: user.id } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
// v1.22 (W5) — also return the owning conversation id so the FAB can
|
||||
// deep-link straight into the proactive thread (`/coach?c=<id>`)
|
||||
// instead of opening a blank new chat and relying on a most-recent
|
||||
// heuristic to find it.
|
||||
select: { createdAt: true, conversationId: true },
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { coachLastSeenAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nudgedAt = lastAssistant?.createdAt ?? null;
|
||||
const lastSeenAt = seen?.coachLastSeenAt ?? null;
|
||||
const unread =
|
||||
nudgedAt !== null && (lastSeenAt === null || lastSeenAt < nudgedAt);
|
||||
|
||||
return apiSuccess({
|
||||
nudgedAt: nudgedAt ? nudgedAt.toISOString() : null,
|
||||
unread,
|
||||
// The conversation holding the newest assistant message. Present
|
||||
// whenever a Coach reply exists; the FAB only consumes it on the
|
||||
// unread path.
|
||||
conversationId: lastAssistant?.conversationId ?? null,
|
||||
});
|
||||
// Shared with the `/coach` RSC prefetch (`src/app/coach/page.tsx`) so both
|
||||
// readers compute the unread signal identically.
|
||||
return apiSuccess(await readCoachNudgeStatus(user.id));
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -15,262 +15,24 @@ import {
|
||||
setMedicationCategory,
|
||||
} from "@/lib/medication-category";
|
||||
import { serializeScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
import {
|
||||
computeDisplayDue,
|
||||
OVERDUE_LOOKBACK_MS,
|
||||
toResolvedSlotMark,
|
||||
type ResolvedSlotMark,
|
||||
} from "@/lib/medications/scheduling/next-due";
|
||||
import { getUserTodayBounds } from "@/lib/tz/local-day";
|
||||
import { invalidateUserMedications } from "@/lib/cache/invalidate";
|
||||
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
|
||||
import { readMedicationsListCached } from "@/lib/medications/list-read";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type MedicationsListResult = Array<Record<string, unknown>>;
|
||||
|
||||
async function buildMedicationsList(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<MedicationsListResult> {
|
||||
const { start: todayStartUtc, end: todayEndUtc } = getUserTodayBounds(
|
||||
new Date(),
|
||||
userTz,
|
||||
);
|
||||
|
||||
// v1.15.10 — the slots the user has already acted on near "now", so the
|
||||
// next-due search can skip them. A resolved slot is a taken, deliberately
|
||||
// skipped, or cron-auto-missed row. Bound the window to [today-start,
|
||||
// today-end + 2d] — the next-due lookahead only needs the slots adjacent to
|
||||
// now, and a tight window keeps the read cheap. The 60s list cache covers
|
||||
// the rest.
|
||||
const resolvedWindowEnd = new Date(
|
||||
todayEndUtc.getTime() + 2 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
// v1.16.4 — the open-overdue search reaches back as far as the widest
|
||||
// band tail (weekly on-time + overdue), so the resolved-slot read must
|
||||
// cover the same horizon or a long-resolved past slot would resurface
|
||||
// as "overdue".
|
||||
const resolvedWindowStart = new Date(
|
||||
todayStartUtc.getTime() - OVERDUE_LOOKBACK_MS,
|
||||
);
|
||||
|
||||
const [
|
||||
medications,
|
||||
latestIntakes,
|
||||
todayEvents,
|
||||
resolvedEvents,
|
||||
eraFloors,
|
||||
usableStock,
|
||||
inventoryCounts,
|
||||
] = await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId },
|
||||
include: { schedules: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
// v1.7.0 sync — exclude tombstoned rows from the last-taken map.
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
skipped: false,
|
||||
takenAt: { not: null },
|
||||
},
|
||||
_max: { takenAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.groupBy({
|
||||
by: ["medicationId"],
|
||||
// v1.7.0 sync — exclude tombstoned rows from the today-count map.
|
||||
// v1.16.9 — count only ACTIONED rows (taken or skipped). The
|
||||
// dashboard projector mints pending rows for every slot of the
|
||||
// day, so counting all rows made `todayEventCount` cover every
|
||||
// passed dose after any dashboard visit — and the cards'
|
||||
// overdue-pill suppression (`todayEventCount < passedDoseCount`)
|
||||
// went dark nondeterministically for genuinely overdue doses.
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: todayStartUtc, lte: todayEndUtc },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
_count: { id: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
scheduledFor: { gte: resolvedWindowStart, lte: resolvedWindowEnd },
|
||||
OR: [
|
||||
{ takenAt: { not: null } },
|
||||
{ skipped: true },
|
||||
{ autoMissed: true },
|
||||
],
|
||||
},
|
||||
// v1.16.9 — `takenAt` rides along so the ad-hoc shape
|
||||
// (`scheduledFor === takenAt`) is detectable: such a row must not
|
||||
// ±6h-resolve a DIFFERENT slot (a 14:30 ad-hoc take hid tonight's
|
||||
// genuinely-due 20:00 dose).
|
||||
select: { medicationId: true, scheduledFor: true, takenAt: true },
|
||||
}),
|
||||
// v1.16.4 — current-era floor per medication: the newest revision's
|
||||
// `validUntil` is where the LIVE schedule rows became valid. The
|
||||
// open-overdue search mints from the live rows, so it must not reach
|
||||
// past this boundary into a previous era's cadence.
|
||||
prisma.medicationScheduleRevision.groupBy({
|
||||
by: ["medicationId"],
|
||||
// Superseded rows are audit records — a correction may have
|
||||
// shortened the era, so the boundary reads only active rows.
|
||||
where: { medication: { userId }, supersededByRevisionId: null },
|
||||
_max: { validUntil: true },
|
||||
}),
|
||||
// v1.16.10 — usable stock per medication (one batched aggregate,
|
||||
// not per-row): the sum of `unitsRemaining` over ACTIVE / IN_USE
|
||||
// containers with units left — the same usable-container filter
|
||||
// the GLP-1 details endpoint applies. Feeds the list payload's
|
||||
// `stockUnitsRemaining` / `stockDosesRemaining` for the table view.
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: {
|
||||
userId,
|
||||
state: { in: ["ACTIVE", "IN_USE"] },
|
||||
unitsRemaining: { gt: 0 },
|
||||
},
|
||||
_sum: { unitsRemaining: true },
|
||||
}),
|
||||
// …and the any-state item count, so a medication whose containers
|
||||
// are all used up / expired reads as stock 0 (tracking is ON, the
|
||||
// supply ran out) instead of null (tracking off).
|
||||
prisma.medicationInventoryItem.groupBy({
|
||||
by: ["medicationId"],
|
||||
where: { userId },
|
||||
_count: { id: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolvedSlotsByMedId = new Map<string, ResolvedSlotMark[]>();
|
||||
for (const e of resolvedEvents) {
|
||||
const mark = toResolvedSlotMark(e);
|
||||
const list = resolvedSlotsByMedId.get(e.medicationId);
|
||||
if (list) list.push(mark);
|
||||
else resolvedSlotsByMedId.set(e.medicationId, [mark]);
|
||||
}
|
||||
|
||||
const eraStartByMedId = new Map<string, Date>();
|
||||
for (const f of eraFloors) {
|
||||
if (f._max.validUntil)
|
||||
eraStartByMedId.set(f.medicationId, f._max.validUntil);
|
||||
}
|
||||
|
||||
const lastTakenAtByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [
|
||||
entry.medicationId,
|
||||
entry._max.takenAt ? entry._max.takenAt.toISOString() : null,
|
||||
]),
|
||||
);
|
||||
// v1.7.0 SB-SCHED-3 — Date-typed last-intake map for the engine
|
||||
// (rolling cadences re-anchor on it). Same groupBy as the ISO map.
|
||||
const lastTakenAtDateByMedicationId = Object.fromEntries(
|
||||
latestIntakes.map((entry) => [entry.medicationId, entry._max.takenAt]),
|
||||
);
|
||||
const todayEventCountByMedId = Object.fromEntries(
|
||||
todayEvents.map(
|
||||
(entry: { medicationId: string; _count: { id: number } }) => [
|
||||
entry.medicationId,
|
||||
entry._count.id,
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
const usableUnitsByMedId = new Map<string, number>();
|
||||
for (const entry of usableStock) {
|
||||
usableUnitsByMedId.set(
|
||||
entry.medicationId,
|
||||
Number(entry._sum.unitsRemaining ?? 0),
|
||||
);
|
||||
}
|
||||
const trackedMedIds = new Set(inventoryCounts.map((e) => e.medicationId));
|
||||
|
||||
let categoryMap: Record<string, string> = {};
|
||||
try {
|
||||
categoryMap = await getMedicationCategories(medications.map((m) => m.id));
|
||||
} catch {
|
||||
getEvent()?.addWarning("Medication categories could not be loaded");
|
||||
}
|
||||
|
||||
// v1.7.0 SB-SCHED-3 — server-computed next due instant. Time-derived;
|
||||
// the list GET is cached 60 s on userId, so a 60 s staleness window is
|
||||
// accepted here as it already is for `todayEventCount`.
|
||||
const now = new Date();
|
||||
|
||||
return medications.map((m) => {
|
||||
// v1.16.4 — an OPEN overdue slot (anchor passed, still inside its
|
||||
// catch-up band, unresolved) surfaces FIRST with `nextDueOverdue:
|
||||
// true`; only a closed or resolved band falls through to the future
|
||||
// next-due. Keeps the card on the still-takeable dose instead of
|
||||
// jumping ahead the minute the anchor passes.
|
||||
const display = computeDisplayDue({
|
||||
medication: {
|
||||
id: m.id,
|
||||
startsOn: m.startsOn,
|
||||
endsOn: m.endsOn,
|
||||
oneShot: m.oneShot,
|
||||
createdAt: m.createdAt,
|
||||
},
|
||||
schedules: m.schedules,
|
||||
now,
|
||||
userTz,
|
||||
lastIntakeAt: lastTakenAtDateByMedicationId[m.id] ?? null,
|
||||
resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [],
|
||||
eraStart: eraStartByMedId.get(m.id) ?? null,
|
||||
});
|
||||
// v1.16.10 — dose-derived stock for the table view. NULL when the
|
||||
// medication has no inventory items at all (tracking off); 0 when
|
||||
// tracking is on but every container is used up / expired.
|
||||
const tracksInventory = trackedMedIds.has(m.id);
|
||||
const stockUnitsRemaining = tracksInventory
|
||||
? (usableUnitsByMedId.get(m.id) ?? 0)
|
||||
: null;
|
||||
const stockDosesRemaining =
|
||||
stockUnitsRemaining === null
|
||||
? null
|
||||
: Math.floor(stockUnitsRemaining / (Number(m.unitsPerDose) || 1));
|
||||
return {
|
||||
...m,
|
||||
// v1.16.12 — Decimal → number so the wire stays a JSON number, not
|
||||
// the string Prisma would otherwise serialise a Decimal to.
|
||||
unitsPerDose: Number(m.unitsPerDose),
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
nextDueAt: display ? display.at.toISOString() : null,
|
||||
nextDueOverdue: display?.overdue ?? false,
|
||||
stockUnitsRemaining,
|
||||
stockDosesRemaining,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userTz = user.timezone || "Europe/Berlin";
|
||||
|
||||
// Cache the list shape on userId. The 60 s fresh TTL bounds the
|
||||
// cross-midnight staleness window for `todayEventCount` and the
|
||||
// next-due fields; v1.16.8 reads through `cachedSwr`, so a visit
|
||||
// next-due fields; the read goes through `cachedSwr`, so a visit
|
||||
// minutes after the last one serves the prior list immediately while
|
||||
// one background recompute warms a fresh entry (the 10-minute stale
|
||||
// window bounds how old that served list can be). Interactive writes
|
||||
// via POST/PUT/DELETE hard-evict through
|
||||
// `invalidateUserMedications({ evict: true })` before the next read
|
||||
// lands; background sync paths mark the bucket stale instead.
|
||||
const result = await cachedSwr(
|
||||
caches.medications as ServerCache<MedicationsListResult>,
|
||||
user.id,
|
||||
() => buildMedicationsList(user.id, userTz),
|
||||
annotate,
|
||||
);
|
||||
// lands; background sync paths mark the bucket stale instead. The RSC
|
||||
// wrapper (`src/app/medications/page.tsx`) shares this exact cell.
|
||||
const result = await readMedicationsListCached(user);
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.list" },
|
||||
|
||||
Reference in new issue
Block a user