perf(dashboard,insights): reserve the cold-paint footprint and cache the slow overview reads

The dashboard reserves chart-row skeleton cells from the saved layout
before the snapshot resolves, so a cold load paints close to its final
footprint instead of growing by a screen when data lands; the tile
silhouettes count only tile-capable widgets with blood pressure as two,
the greeting line keeps its box through name personalisation, and the
getting-started checklist moves below the charts where its arrival
shifts nothing. On the insights overview the targets build runs its
eight reads in parallel with a one-year floor on the glucose scan
instead of strictly in sequence over an unbounded history, the derived
batch gains a server cache where it previously recomputed sixteen
metrics on every read, both serve stale-while-revalidate, and the
health-score column reserves its exact footprint while the analytics
payload is pending.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 21:52:36 +02:00
1 parent b5e799c2d6
commit 242d2514ee
13 files changed
+784 -159

No files matched your search

@@ -2,7 +2,15 @@ import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { resolveDashboardFirstPaintGate } from "../page";
import {
resolveChartRowPlaceholderCount,
resolveConfiguredTileCount,
resolveDashboardFirstPaintGate,
} from "../page";
import {
DEFAULT_DASHBOARD_LAYOUT,
type DashboardLayout,
} from "@/lib/dashboard-layout";
/**
* v1.4.43 W11-M5 — dashboard tile-strip skeleton during slow first
@@ -94,14 +102,95 @@ describe("resolveDashboardFirstPaintGate", () => {
});
});
describe("resolveConfiguredTileCount (v1.16.8)", () => {
it("counts only widgets that paint a strip tile, with bp counting double", () => {
// Default layout: weight, bp, pulse, bodyFat, mood, bpInTarget are
// tile-visible AND tile-capable; medications + recentWorkouts are
// tile-visible in the stored layout but paint NO strip tile, so
// they must not inflate the silhouette. bp paints sys + dia = 2.
expect(resolveConfiguredTileCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(7);
});
it("ignores chart-only and iOS-pin-only widget ids", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "medications", visible: true, tileVisible: true, order: 0 },
{ id: "recentWorkouts", visible: true, tileVisible: true, order: 1 },
{ id: "achievements", visible: true, tileVisible: true, order: 2 },
{ id: "cardioRecovery", visible: true, tileVisible: true, order: 3 },
],
};
expect(resolveConfiguredTileCount(layout)).toBe(0);
});
it("falls back to `visible` for legacy entries without tileVisible", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, order: 0 },
{ id: "pulse", visible: false, order: 1 },
],
};
expect(resolveConfiguredTileCount(layout)).toBe(1);
});
});
describe("resolveChartRowPlaceholderCount (v1.16.8)", () => {
it("reserves one cell per chart-visible chart-capable widget", () => {
// Default layout chart row: weight, bp, pulse, bodyFat, mood,
// medications are `visible`; bpInTarget / achievements /
// recentWorkouts carry no chart-shaped footprint.
expect(resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT)).toBe(6);
});
it("adds the BMI cell riding the weight gate when a height is known", () => {
expect(
resolveChartRowPlaceholderCount(DEFAULT_DASHBOARD_LAYOUT, {
hasHeightCm: true,
}),
).toBe(7);
});
it("respects a trimmed layout", () => {
const layout: DashboardLayout = {
version: 1,
widgets: [
{ id: "weight", visible: true, tileVisible: true, order: 0 },
{ id: "bp", visible: false, tileVisible: true, order: 1 },
{ id: "steps", visible: true, tileVisible: false, order: 2 },
],
};
expect(resolveChartRowPlaceholderCount(layout)).toBe(2);
});
});
describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
it("derives `configuredTileCount` from the resolved layout", () => {
it("derives `configuredTileCount` through the tile-capable resolver", () => {
const src = readFileSync(PAGE_PATH, "utf8");
expect(src).toMatch(
/const\s+configuredTileCount\s*=\s*layout\.widgets\.filter\([\s\S]*?w\.tileVisible\s*\?\?\s*w\.visible[\s\S]*?\)\.length;/,
/const\s+configuredTileCount\s*=\s*resolveConfiguredTileCount\(layout\);/,
);
});
it("reserves the chart row while the primary query loads", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// The placeholder count keys off the layout config minus the
// already-mounted (layout-gated) cells, and only while loading.
expect(src).toMatch(
/const\s+chartRowPlaceholderCount\s*=\s*primaryLoading[\s\S]*?resolveChartRowPlaceholderCount\(layout,[\s\S]*?hasHeightCm:\s*Boolean\(user\?\.heightCm\)/,
);
expect(src).toMatch(/-\s*charts\.length/);
// The reserved cells render as aria-hidden ChartSkeletons in their
// own slot.
const skeletonBlock = src.match(
/chartRowPlaceholderCount\s*>\s*0\s*&&\s*\([\s\S]*?dashboard-chart-row-skeleton[\s\S]*?\)\}/,
);
expect(skeletonBlock).not.toBeNull();
expect(skeletonBlock?.[0]).toContain('aria-hidden="true"');
expect(skeletonBlock?.[0]).toContain("<ChartSkeleton");
});
it("derives `primaryLoading` from the snapshot vs slim query", () => {
const src = readFileSync(PAGE_PATH, "utf8");
// v1.16.4 — `!mounted` pins SSR + the hydration render to the
+32
View File
@@ -259,3 +259,35 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
expect(hookSrc).toContain("compareBaseline");
});
});
describe("v1.16.8 — insights overview cold paint", () => {
it("fires every overview query in parallel — none gated on another query's data", () => {
const src = load(INSIGHTS_PATH);
// The derived batch + analytics + comprehensive reads all gate on
// auth alone. Chaining any of them behind another query's resolved
// payload re-introduces the sequential reveal.
expect(src).toMatch(/useDashboardDerived\(isAuthenticated\)/);
expect(src).toMatch(/const analyticsQuery = useAnalyticsQuery\(\);/);
expect(src).toMatch(/enabled:\s*isAuthenticated/);
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
});
it("reserves the health-score column while the analytics payload is pending", () => {
const src = load(INSIGHTS_PATH);
// The hero receives the pending flag so the score card's slot holds
// its footprint from first paint (skeleton in `<HeroStrip>`).
expect(src).toMatch(
/healthScorePending=\{analyticsQuery\.isPending && !analytics\}/,
);
});
it("hero strip renders the score-card skeleton in the reserved column", () => {
const heroSrc = readFileSync(
join(ROOT, "src/components/insights/hero-strip.tsx"),
"utf8",
);
expect(heroSrc).toContain('data-slot="health-score-card-skeleton"');
// The two-column split keys on score-or-pending, not score alone.
expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/);
});
});
@@ -51,6 +51,11 @@ import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { computeDerivedMetric } from "@/lib/insights/derived";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
import {
invalidateUserMeasurements,
invalidateUserMood,
} from "@/lib/cache/invalidate";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -66,6 +71,9 @@ function makeReq(metrics?: string): NextRequest {
beforeEach(() => {
vi.clearAllMocks();
// v1.16.8 — the route reads through the module-scope `insightsDerived`
// server cache; reset it so every test starts on a cold cell.
__resetAllCachesForTests();
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
dateOfBirth: new Date("1986-01-01"),
@@ -144,3 +152,75 @@ describe("GET /api/insights/derived/batch", () => {
expect(res.status).toBe(401);
});
});
describe("GET /api/insights/derived/batch — server cache (v1.16.8)", () => {
it("serves a warm repeat from the per-user cache without recomputing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const first = await callGet(makeReq("READINESS,BMI"));
expect(first.status).toBe(200);
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
const second = await callGet(makeReq("READINESS,BMI"));
expect(second.status).toBe(200);
// Cache hit — no second profile read, no second compute fan-out.
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
expect(prisma.user.findUnique).toHaveBeenCalledTimes(1);
const body = (await second.json()) as {
data: { metrics: Record<string, { status: string }> };
};
expect(Object.keys(body.data.metrics).sort()).toEqual([
"BMI",
"READINESS",
]);
});
it("hits the same cell regardless of token order", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("READINESS,BMI"));
await callGet(makeReq("BMI,READINESS"));
// The key sorts the tokens, so the reordered request is a hit.
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
});
it("never serves one user's cell to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
vi.mocked(getSession).mockResolvedValue({
...SESSION_OK,
user: { ...SESSION_OK.user, id: "user-2" },
} as never);
await callGet(makeReq("BMI"));
// Different userId prefix → cold cell → fresh compute.
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
});
it("recomputes after an interactive measurement write evicts the bucket", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
invalidateUserMeasurements("user-1", { evict: true });
await callGet(makeReq("BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
});
it("serves stale-while-revalidate after a background write marks the bucket stale", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await callGet(makeReq("BMI"));
expect(computeDerivedMetric).toHaveBeenCalledTimes(1);
// Background sync posture (no evict): the entry is marked stale, so
// the next read serves the prior body immediately AND kicks off one
// background recompute.
invalidateUserMood("user-1");
const res = await callGet(makeReq("BMI"));
expect(res.status).toBe(200);
// The background rebuild runs detached; give the microtask queue a
// tick so the recompute lands before asserting.
await new Promise((resolve) => setImmediate(resolve));
expect(computeDerivedMetric).toHaveBeenCalledTimes(2);
});
});
+69 -21
View File
@@ -26,6 +26,11 @@ import pLimit from "p-limit";
import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
cachedSwr,
caches,
type ServerCache,
} from "@/lib/cache/server-cache";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { prisma } from "@/lib/db";
import {
@@ -139,10 +144,70 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// The deterministic per-score assessment is locale-dependent, so the
// locale joins the cache key below — a de / en pair never shares a cell.
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: request.nextUrl.searchParams.get("locale"),
});
const assessmentLocale = locale === "de" ? "de" : "en";
// v1.16.8 — read-through the per-user derived cache with
// stale-while-revalidate. The cold build below walks the rollup tier
// once per metric (1–2 s wall-clock for the overview's ~16-token
// grid); the production trace showed the full recompute re-paid on
// every read past the client's 60 s staleTime. Tokens sort into the
// key so request order never splits cells; measurement writes evict /
// mark the `${userId}|` prefix stale via `invalidateUserMeasurements`
// and mood writes mark it stale (READINESS reads the mood series).
const cacheKey = `${user.id}|batch|${items
.map((item) => item.key)
.sort()
.join(",")}|${assessmentLocale}`;
const map = await cachedSwr(
caches.insightsDerived as ServerCache<
Awaited<ReturnType<typeof buildDerivedBatch>>
>,
cacheKey,
() =>
buildDerivedBatch({
userId: user.id,
items,
assessmentLocale,
}),
annotate,
);
annotate({
action: { name: "insights.derived.batch" },
meta: {
requested: items.length,
ok: Object.values(map).filter((entry) => entry.status === "ok").length,
},
});
// `metrics` is a map keyed by the per-request token so the client reads
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
return apiSuccess({ metrics: map });
});
/**
* The cacheable batch body — profile load + bounded per-metric fan-out.
* Lifted out of the handler so `cachedSwr` can wrap it; everything here
* is pure compute over the rollup tier (no provider call).
*/
async function buildDerivedBatch(input: {
userId: string;
items: BatchItem[];
assessmentLocale: "de" | "en";
}) {
const { userId, items, assessmentLocale } = input;
// Profile read ONCE — the pool-contention mitigation; the same shared
// loader the nightly score jobs use, so the batch never re-reads the
// User row per metric.
const profile = await loadBaselineProfile(prisma, user.id);
const profile = await loadBaselineProfile(prisma, userId);
// Fan out under a bounded limiter so a 17-item dashboard read resolves
// four computes at a time against the shared pool, not seventeen.
@@ -156,12 +221,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// null for non-assessable ids / non-`ok` status, so a non-score item still
// gets `assessment: null`. A best-effort grid extra must NEVER fail the
// whole batch, so each call is guarded → null on any unexpected shape.
const locale = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: request.nextUrl.searchParams.get("locale"),
});
const assessmentLocale = locale === "de" ? "de" : "en";
const safeAssessment = (
metric: DerivedMetricId,
derived: Parameters<typeof resolveDeterministicAssessment>[1],
@@ -182,7 +241,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
limit(async () => {
const derived = await computeDerivedMetric({
metric: item.metric,
userId: user.id,
userId,
profile,
type: item.type,
now,
@@ -210,16 +269,5 @@ export const GET = apiHandler(async (request: NextRequest) => {
const map: Record<string, (typeof results)[number]["payload"]> = {};
for (const r of results) map[r.key] = r.payload;
annotate({
action: { name: "insights.derived.batch" },
meta: {
requested: items.length,
ok: results.filter((r) => r.payload.status === "ok").length,
},
});
// `metrics` is a map keyed by the per-request token so the client reads
// back exactly what it asked for (`VITALS_BASELINE:WEIGHT`, `READINESS`).
return apiSuccess({ metrics: map });
});
return map;
}
@@ -277,4 +277,37 @@ describe("GET /api/insights/targets — mood-rollup tier swap", () => {
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
});
it("bounds the glucose read with the same one-year floor (v1.16.8)", async () => {
// The glucose section used to scan the user's entire BLOOD_GLUCOSE
// history on every cold build (no `measuredAt` filter at all) just
// to read the latest value per context. The read now carries the
// same 365-day floor as the latest-ever-per-type query.
await callGet(makeReq());
const calls = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.map((c) => c[0]);
const glucoseCall = calls.find((c) => c?.where?.type === "BLOOD_GLUCOSE");
expect(glucoseCall).toBeDefined();
expect(glucoseCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
});
it("serves a warm repeat from the server cache without re-querying (v1.16.8)", async () => {
const first = await callGet(makeReq());
expect(first.status).toBe(200);
const queriesAfterFirst = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.length;
expect(queriesAfterFirst).toBeGreaterThan(0);
const second = await callGet(makeReq());
expect(second.status).toBe(200);
// Same user, warm cell — the SWR read serves the cached body and
// issues no further measurement queries inside the fresh TTL.
expect(
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mock.calls
.length,
).toBe(queriesAfterFirst);
});
});
+176 -122
View File
@@ -35,7 +35,7 @@ import type {
} from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { NO_STORE_BUT_BFCACHE } from "@/lib/http/cache-headers";
import {
getEffectiveRange,
@@ -133,7 +133,14 @@ type AuthedUser = Awaited<ReturnType<typeof requireAuth>>["user"];
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const body = await cached(
// v1.16.8 — stale-while-revalidate read. The cold build is the
// multi-query walk below; past the 60 s fresh TTL the prior body now
// serves immediately while ONE background rebuild warms the cell,
// instead of every Insights mount more than a minute apart re-paying
// the >1 s compute inline. Writes still hard-evict the bucket
// (`invalidateUser*` → `deleteByPrefix`), so user actions always
// reflect on the next read.
const body = await cachedSwr(
caches.insightsTargets as ServerCache<
Awaited<ReturnType<typeof buildTargetsResponse>>
>,
@@ -158,25 +165,6 @@ async function buildTargetsResponse(user: AuthedUser) {
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const dayKey = (d: Date): string => userDayKey(d, userTz);
// Fetch user profile
const dbUser = await prisma.user.findUnique({
where: { id: userId },
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
glucoseUnit: true,
thresholdsJson: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
},
});
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
const heightCm = dbUser?.heightCm ?? null;
// Fetch latest measurements for each type
// v1.11.5 — SLEEP_DURATION is handled by a dedicated per-night
// reconstruction read below (the generic per-type average treats each
@@ -198,47 +186,166 @@ async function buildTargetsResponse(user: AuthedUser) {
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 30 days + the latest for each type
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
// must not contribute to tile averages / latest values once iOS sync
// starts emitting deletions.
const recentMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
// Absolute latest measurement per type within a one-year floor.
//
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
// driver dedups after pulling the rows, so an unbounded scan on a
// 347 k-row tenant pulls every measurement back to Node only to drop
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
// floor caps the planner-side walk to the existing
// `(user_id, type, measured_at)` index range; the trade-off is that a
// user who hasn't measured a metric in over a year sees the tile's
// `current` value as null instead of pulling a stale year-old reading
// forward — which is the correct behaviour for the consistency strip
// (the strip already gates on "fewer than 3 readings in 30 days" →
// insufficientData).
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const latestEverByType = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: oneYearAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
distinct: ["type"],
select: { type: true, value: true },
});
// Mood-rollup warm-up fires before the parallel read below so a legacy
// account converges onto the rollup tier on a subsequent request; the
// read itself never blocks on the warm-up (fire-and-forget).
void ensureUserMoodRollupsFresh(userId);
// Five-year window mirrors `/api/mood/analytics` so existing rollup
// coverage is consulted; the trailing 30-day filter happens in JS
// because the consistency strip already walks per-day buckets.
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
const moodSince = new Date(Date.now() - moodRollupWindowMs);
// v1.16.8 — ONE parallel round of every independent read. The cold
// build used to await eight queries strictly in sequence (profile →
// measurements → latest-per-type → sleep → medications → intakes →
// mood → glucose), stacking their round-trip latencies into the >1 s
// wall-clock the production trace showed. All of them are independent
// (the intake read depends only on the medication ids, so the pair
// chains inside its own slot); fan them out together and the cold
// build pays roughly the slowest query instead of the sum.
const [
dbUser,
recentMeasurements,
latestEverByType,
sleepStageRows,
medicationBundle,
moodRollups,
latestMoodEntry,
glucoseRows,
] = await Promise.all([
// User profile (height / age / gender / thresholds / source priority).
prisma.user.findUnique({
where: { id: userId },
select: {
heightCm: true,
dateOfBirth: true,
gender: true,
glucoseUnit: true,
thresholdsJson: true,
// v1.11.5 — needed to collapse a dual-source sleep night to one
// canonical source before reconstructing per-night asleep totals.
sourcePriorityJson: true,
},
}),
// All measurements in the last 30 days for the generic type set.
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
// must not contribute to tile averages / latest values once iOS sync
// starts emitting deletions.
prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
}),
// Absolute latest measurement per type within a one-year floor.
//
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
// driver dedups after pulling the rows, so an unbounded scan on a
// 347 k-row tenant pulls every measurement back to Node only to drop
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
// floor caps the planner-side walk to the existing
// `(user_id, type, measured_at)` index range; the trade-off is that a
// user who hasn't measured a metric in over a year sees the tile's
// `current` value as null instead of pulling a stale year-old reading
// forward — which is the correct behaviour for the consistency strip
// (the strip already gates on "fewer than 3 readings in 30 days" →
// insufficientData).
prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: oneYearAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
distinct: ["type"],
select: { type: true, value: true },
}),
// Sleep stage rows for the per-night reconstruction (section 4).
prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
}),
// Active medications + their 30-day intake events (section 8). The
// intake read depends on the medication ids, so the pair chains
// inside this slot while riding the outer fan-out.
(async () => {
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
orderBy: { name: "asc" },
});
if (activeMedications.length === 0) {
return { activeMedications, intakeEvents: [] };
}
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: {
in: activeMedications.map((medication) => medication.id),
},
scheduledFor: { gte: thirtyDaysAgo },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
return { activeMedications, intakeEvents };
})(),
// Mood day rollups (section 9).
readMoodDayRollups(userId, moodSince),
// Latest raw mood entry — bounded `take: 1` via the index.
prisma.moodEntry.findFirst({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
}),
// Glucose rows (section 10). Same one-year floor as the
// latest-per-type read above — the previous unbounded scan pulled a
// heavy logger's full history back to Node on every cold build; a
// context whose newest reading is over a year old now reports no
// card, matching the per-type tile posture.
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_GLUCOSE",
measuredAt: { gte: oneYearAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { value: true, measuredAt: true, glucoseContext: true },
}),
]);
const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null;
const gender = (dbUser?.gender as "MALE" | "FEMALE" | null) ?? null;
const heightCm = dbUser?.heightCm ?? null;
const latestByType: Record<string, number | null> = {};
const avg30ByType: Record<string, number | null> = {};
@@ -813,16 +920,7 @@ async function buildTargetsResponse(user: AuthedUser) {
// overview headline.
const sleepRange = getSleepDurationRange();
{
const sleepStageRows = await prisma.measurement.findMany({
where: {
userId,
type: "SLEEP_DURATION",
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, sleepStage: true, source: true },
});
// `sleepStageRows` resolved in the parallel read at the top.
const nights = reconstructSleepNights(
sleepStageRows as SleepStageRow[],
userTz,
@@ -1002,40 +1100,12 @@ async function buildTargetsResponse(user: AuthedUser) {
});
}
// 8. Medication Compliance (average across active medications)
const activeMedications = await prisma.medication.findMany({
where: { userId, active: true },
// v1.15.20 — schedules through the shared compliance select so the
// configured per-dose windows reach this surface like every other.
include: {
schedules: { select: SCHEDULE_COMPLIANCE_SELECT },
// v1.16.3 — archived schedule eras for era-aware compliance.
scheduleRevisions: { orderBy: { validFrom: "asc" } },
},
orderBy: { name: "asc" },
});
// 8. Medication Compliance (average across active medications) —
// `activeMedications` + their 30-day `intakeEvents` resolved in the
// parallel read at the top.
const { activeMedications, intakeEvents } = medicationBundle;
if (activeMedications.length > 0) {
const thirtyDaysAgoIntake = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const intakeEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId,
// v1.7.0 sync — exclude tombstoned rows.
deletedAt: null,
medicationId: {
in: activeMedications.map((medication) => medication.id),
},
scheduledFor: { gte: thirtyDaysAgoIntake },
},
orderBy: { scheduledFor: "desc" },
select: {
medicationId: true,
takenAt: true,
skipped: true,
scheduledFor: true,
},
});
const medicationStats = activeMedications.map((medication) => {
const medicationEvents = intakeEvents.filter(
(event) => event.medicationId === medication.id,
@@ -1187,21 +1257,9 @@ async function buildTargetsResponse(user: AuthedUser) {
// rows yet (legacy account before the boot-time backfill caught up)
// we run the legacy live walk once and fire the warm-up so the next
// request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
// `/api/mood/analytics`. The warm-up trigger + the `moodRollups` /
// `latestMoodEntry` reads moved into the parallel round at the top.
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Five-year window mirrors `/api/mood/analytics` so existing rollup
// coverage is consulted; the trailing 30-day filter happens in JS
// because the consistency strip already walks per-day buckets.
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
const moodSince = new Date(Date.now() - moodRollupWindowMs);
const moodRollups = await readMoodDayRollups(userId, moodSince);
const latestMoodEntry = await prisma.moodEntry.findFirst({
// v1.7.0 sync — exclude tombstoned rows.
where: { userId, deletedAt: null },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
});
// Build a per-day event list for the consistency strip + 30-day stats.
// Use the rollup tier when populated; fall back to a bounded raw walk
@@ -1357,12 +1415,8 @@ async function buildTargetsResponse(user: AuthedUser) {
dateOfBirth: dbUser?.dateOfBirth ?? null,
gender: dbUser?.gender ?? null,
};
// `glucoseRows` resolved (one-year floor) in the parallel read at the top.
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseRows = await prisma.measurement.findMany({
where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
const labelKeyByContext: Record<GlucoseContext, string> = {
FASTING: "targets.glucoseFasting",
+9
View File
@@ -475,6 +475,15 @@ export default function InsightsPage() {
coachLaunch ? (prompt) => coachLaunch.askCoach(prompt) : undefined
}
healthScore={analytics?.healthScore ?? undefined}
// v1.16.8 — reserve the score card's column while the thick
// analytics payload is in flight. Every overview query
// (comprehensive, advisor, analytics, derived batch, layout)
// already fires in parallel at mount; the "greeting first,
// score card later" stagger came from the right column not
// existing until the slowest payload resolved. `isPending`
// settles false on success AND error, so a no-score account
// collapses the column exactly once.
healthScorePending={analyticsQuery.isPending && !analytics}
/>
{/* v1.15.18 — the inline "Anpassen" toggle was removed. Customising the
+135 -11
View File
@@ -21,7 +21,6 @@ import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import { cn } from "@/lib/utils";
import {
resolveDashboardLayout,
DASHBOARD_WIDGET_IDS,
type DashboardLayout,
} from "@/lib/dashboard-layout";
import type { DashboardAnalyticsData as AnalyticsData } from "@/types/analytics";
@@ -146,6 +145,86 @@ export function resolveDashboardFirstPaintGate(input: {
return { showTileStripSkeleton, showEmptyState };
}
/**
* v1.16.8 — widget ids that actually paint a tile in the strip on the
* web dashboard, mirrored from the per-id render blocks below.
* `medications` / `recentWorkouts` / `achievements` carry NO strip tile
* (chart-row cards only), and the iOS-pin-only ids have no web render
* path at all — counting any of them over-reserved the loading
* silhouette and made the strip reshuffle when the data landed.
*/
const TILE_CAPABLE_WIDGET_IDS = new Set<string>([
"weight",
"bp",
"pulse",
"bodyFat",
"mood",
"sleep",
"steps",
"glucose",
"bpInTarget",
"vo2Max",
]);
/**
* v1.16.8 — silhouette count for the tile-strip skeleton: one card per
* tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia,
* see the render block below) so it counts double; `glucose` can fan
* out to one tile per logged context, but the contexts are unknown
* until the snapshot lands, so it reserves one card (the sane floor).
* Pure + exported for direct unit coverage.
*/
export function resolveConfiguredTileCount(layout: DashboardLayout): number {
let count = 0;
for (const widget of layout.widgets) {
if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
if (!(widget.tileVisible ?? widget.visible)) continue;
count += widget.id === "bp" ? 2 : 1;
}
return count;
}
/**
* v1.16.8 — widget ids with a chart-row surface on the web dashboard,
* mirrored from the `charts[]` entries below. The achievements +
* recent-workouts cards stay out: they self-skeleton, carry no
* chart-shaped footprint, and gate on `layoutResolved`.
*/
const CHART_CAPABLE_WIDGET_IDS = new Set<string>([
"weight",
"bp",
"pulse",
"bodyFat",
"mood",
"sleep",
"steps",
"medications",
]);
/**
* v1.16.8 — expected chart-row card count while the snapshot is still
* in flight. Every chart gate below needs `count > 0` from snapshot
* data, so the cold page used to render NO chart row at all and then
* grow ~1000 px when the snapshot landed. The best available signal
* before data arrives is the layout config (the user's saved layout
* when cached, the default otherwise): one card per chart-visible
* widget, plus the BMI card that rides the weight gate when the
* profile carries a height. Pure + exported for direct unit coverage.
*/
export function resolveChartRowPlaceholderCount(
layout: DashboardLayout,
opts?: { hasHeightCm?: boolean },
): number {
let count = 0;
for (const widget of layout.widgets) {
if (!CHART_CAPABLE_WIDGET_IDS.has(widget.id)) continue;
if (!widget.visible) continue;
count += 1;
if (widget.id === "weight" && opts?.hasHeightCm) count += 1;
}
return count;
}
export default function DashboardPage() {
const { isAuthenticated, user } = useAuth();
const mounted = useMounted();
@@ -642,12 +721,6 @@ export default function DashboardPage() {
onQuickEntry={setQuickEntryDialog}
/>
{/* v1.4: Getting-started checklist for brand-new users.
* Self-gates visibility on (onboardingCompletedAt == null
* || measurementCount < 5) and disappears once dismissed
* or fully complete. See B2 in the v1.4 discovery summary. */}
<GettingStartedChecklist />
{/* v1.4.15 Phase B5 — spotlight tour for first-time users.
* Self-gates on `user.onboardingTourCompleted` (DB flag) plus
* a session-storage dismiss guard. We pass `ready=true` only
@@ -1324,10 +1397,12 @@ export default function DashboardPage() {
// its merge workarounds), but the web dashboard has no tile
// component for them; including them here would over-reserve the
// skeleton silhouette by rows that never paint.
const webWidgetIds = new Set<string>(DASHBOARD_WIDGET_IDS);
const configuredTileCount = layout.widgets.filter(
(w) => webWidgetIds.has(w.id) && (w.tileVisible ?? w.visible),
).length;
// v1.16.8 — narrowed further to the tile-CAPABLE set (see
// `resolveConfiguredTileCount`): the old count included
// `medications` + `recentWorkouts` (chart-row cards with no
// strip tile) and undercounted `bp` (one widget id, two tiles),
// so the silhouettes reshuffled when the data landed.
const configuredTileCount = resolveConfiguredTileCount(layout);
// v1.7.0 — the primary data source differs by flag. In snapshot
// mode `analyticsSlimQuery` is `enabled: false`, and a disabled
// TanStack query reports `fetchStatus: "idle"` → `isLoading` is
@@ -1346,6 +1421,23 @@ export default function DashboardPage() {
(snapshotEnabled
? snapshotQuery.isLoading
: analyticsSlimQuery.isLoading);
// v1.16.8 — chart-row reservation while the snapshot is in
// flight. The chart visibility gates above need snapshot data
// (`count > 0`), so the cold chart row only carried the
// layout-gated medications card and then grew by ~1000 px when
// the snapshot landed. Reserve layout-stable ChartSkeleton
// cells for the charts the layout expects (minus the ones
// already mounted) so the page holds roughly its final
// footprint from first paint and the snapshot landing swaps
// content instead of growing the page.
const chartRowPlaceholderCount = primaryLoading
? Math.max(
0,
resolveChartRowPlaceholderCount(layout, {
hasHeightCm: Boolean(user?.heightCm),
}) - charts.length,
)
: 0;
const { showTileStripSkeleton, showEmptyState } =
resolveDashboardFirstPaintGate({
trendCardCount: trendCards.length,
@@ -1555,9 +1647,41 @@ export default function DashboardPage() {
)}
</div>
))}
{/* v1.16.8 — layout-stable chart-row reservation. While the
snapshot is in flight the data-gated chart entries above
cannot exist yet, so these silhouettes carry the row's
footprint; the moment the snapshot lands the real cells
render in their place (same render pass — no growth, a
swap). `aria-hidden` like the tile-strip skeleton: the
mounted charts announce their own loading state. */}
{chartRowPlaceholderCount > 0 && (
<div
aria-hidden="true"
data-slot="dashboard-chart-row-skeleton"
className="space-y-6"
>
{Array.from({ length: chartRowPlaceholderCount }).map(
(_, idx) => (
<ChartSkeleton key={`chart-row-skeleton-${idx}`} />
),
)}
</div>
)}
</>
);
})()}
{/* v1.4: Getting-started checklist for brand-new users. Self-gates
* visibility on (onboardingCompletedAt == null || measurementCount
* < 5) and disappears once dismissed or fully complete.
* v1.16.8 — moved BELOW the tile strip + chart row: the card only
* mounts once the snapshot resolves, and sitting above the strip
* it pushed every tile and chart down when it popped in. At the
* bottom its late arrival shifts nothing — the fold-priority
* content (tiles, charts) keeps a stable position, and the
* near-empty dashboards of the new accounts the card targets keep
* it visible without scrolling anyway. */}
<GettingStartedChecklist />
</div>
);
}
@@ -40,7 +40,14 @@ export function DashboardHeader({
<h1 className="text-2xl font-bold tracking-tight">
{t("dashboard.title")}
</h1>
<p className="text-muted-foreground mt-1 text-sm">{welcomeText}</p>
{/* v1.16.8 — `min-h-5` reserves the greeting's line box from the
SSR pass on: the text personalises (name appended) on the
first client re-render after hydration, and the reserved
line keeps the header from collapsing/growing around that
swap. */}
<p className="text-muted-foreground mt-1 min-h-5 text-sm">
{welcomeText}
</p>
</div>
<div className="flex items-center gap-2">
{/* Customize shortcut to the dashboard-customization settings
@@ -317,4 +317,61 @@ describe("<HeroStrip>", () => {
const html = render(<HeroStrip briefing={null} now={morningLocal} />);
expect(html).not.toContain("md:items-stretch");
});
// ── v1.16.8 — score-column reservation while analytics is pending ──
it("reserves the score column with a skeleton while the payload is pending", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
);
// The placeholder mirrors the card's column footprint so the band
// paints at its final geometry from the first frame.
expect(html).toContain('data-slot="health-score-card-skeleton"');
expect(html).toContain("md:basis-[22rem]");
expect(html).toContain('aria-hidden="true"');
// The real card stays absent until the payload lands.
expect(html).not.toContain('data-slot="health-score-card" ');
});
it("keeps the two-column split active while pending", () => {
const html = render(
<HeroStrip briefing={null} now={morningLocal} healthScorePending />,
);
expect(html).toContain("md:flex-row");
expect(html).toContain("md:items-stretch");
});
it("drops the skeleton once the payload resolves without a score", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScorePending={false}
/>,
);
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
expect(html).not.toContain("md:items-stretch");
});
it("renders the real card, never the skeleton, when a score is present", () => {
const html = render(
<HeroStrip
briefing={null}
now={morningLocal}
healthScorePending
healthScore={{
score: 86,
band: "green",
components: {
bp: { value: 80, weight: 0.3 },
weight: { value: 70, weight: 0.2 },
mood: { value: 90, weight: 0.2 },
compliance: { value: 100, weight: 0.3 },
},
delta: 5,
}}
/>,
);
expect(html).toContain('data-slot="health-score-card"');
expect(html).not.toContain('data-slot="health-score-card-skeleton"');
});
});
+47 -1
View File
@@ -2,6 +2,7 @@
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { formatRelativeTime } from "@/lib/i18n/relative-time";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
@@ -89,6 +90,18 @@ interface HeroStripProps {
components: HealthScoreCardProps["components"];
delta: HealthScoreCardProps["delta"];
} | null;
/**
* v1.16.8 — true while the analytics payload that carries
* `healthScore` is still in flight. The right column then reserves the
* score card's footprint with a skeleton, so the hero band paints at
* roughly its final size from the first frame and the card landing is
* a swap, not a push. Pre-fix the column collapsed while loading and
* the whole overview reflowed when the (slow, thick-slice) analytics
* response arrived — the "greeting first, score card later" stagger.
* When the payload resolves WITHOUT a score (no data yet) the column
* collapses once, which is the legacy no-score shape.
*/
healthScorePending?: boolean;
}
/**
@@ -118,6 +131,7 @@ export function HeroStrip({
onAskCoach,
now,
healthScore,
healthScorePending = false,
}: HeroStripProps) {
const { t } = useTranslations();
// v1.4.37 W5 — operator-level Coach gate. When the global Coach flag
@@ -180,10 +194,15 @@ export function HeroStrip({
* `h-full` + `mt-auto` on its disclaimer to redistribute the
* recovered space without visual jank.
*/}
{/* v1.16.8 — the split keys on `reserveScoreColumn`, not on the
resolved payload alone: while the analytics query is in flight
the right column already holds the score card's footprint (the
skeleton below), so the band's two-column geometry is stable
from first paint. */}
<div
className={cn(
"flex flex-col gap-5",
healthScore &&
(healthScore || healthScorePending) &&
"md:flex-row md:items-stretch md:gap-6 lg:flex-row lg:items-stretch lg:gap-6",
)}
>
@@ -287,6 +306,33 @@ export function HeroStrip({
)}
</div>
{!healthScore && healthScorePending && (
/* v1.16.8 — layout-stable placeholder mirroring the score
card's column footprint (same basis / stretch classes as
`<HealthScoreCard>`): label row, headline number, progress
bar, four component rows. Decorative — the card announces
nothing while loading. */
<div
data-slot="health-score-card-skeleton"
aria-hidden="true"
className={cn(
"bg-card/65 border-border/60 rounded-xl border px-4 py-4 shadow-sm backdrop-blur-sm",
"w-full md:basis-[22rem] md:shrink-0 md:grow-0 xl:basis-[26rem]",
"flex h-full min-h-64 flex-col gap-3.5",
)}
>
<Skeleton className="h-3 w-24" />
<Skeleton className="h-12 w-28" />
<Skeleton className="h-2 w-full rounded-full" />
<Skeleton className="h-3 w-32" />
<div className="space-y-2.5 border-t pt-3.5">
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-full" />
</div>
</div>
)}
{healthScore && (
<HealthScoreCard
score={healthScore.score}
+15
View File
@@ -87,6 +87,16 @@ export function invalidateUserMeasurements(
// `/api/insights/targets` computes. Evict the user's bucket so the
// next mount paints fresh data.
caches.insightsTargets.deleteByPrefix(userId);
// v1.16.8 — the batched derived-wellness payload computes over the
// same measurement rollups. Same split as the analytics bucket:
// interactive writes evict (the user's own entry must reflect on the
// next read), background syncs mark stale so the SWR reader serves
// the prior grid while one recompute warms it.
if (opts?.evict) {
caches.insightsDerived.deleteByPrefix(`${userId}|`);
} else {
caches.insightsDerived.markStaleByPrefix(`${userId}|`);
}
}
/**
@@ -111,6 +121,11 @@ export function invalidateUserMood(userId: string): void {
// the prior aggregate (within the SWR window) while a single
// background recompute warms a fresh one.
caches.moodInsights.markStaleByPrefix(userId);
// v1.16.8 — READINESS folds the mood series into the derived batch,
// so a mood write dirties the cached grid. Mark stale (not evict):
// the wellness strip is a glanceable aggregate, and the background
// recompute lands the new entry within one read cycle.
caches.insightsDerived.markStaleByPrefix(`${userId}|`);
}
/**
+31
View File
@@ -466,11 +466,42 @@ export const caches = {
* the analytics cache so multiple sub-page mounts inside a minute
* all hit a warm cache. Invalidated alongside the analytics bucket
* on measurement / mood / medication writes.
*
* v1.16.8 — stale-while-revalidate. The 60 s fresh TTL meant every
* Insights mount more than a minute after the last one re-paid the
* full multi-query build inline (>1 s cold). The route reads via
* `cachedSwr` now: inside the 10-minute stale window the prior body
* serves immediately while one coalesced background rebuild warms a
* fresh one. Writes keep their hard evict (`deleteByPrefix`) so a
* user's own measurement / mood / medication action is always
* reflected on the very next read — the window only bounds
* wall-clock drift, never user-action staleness.
*/
insightsTargets: new ServerCache<unknown>({
name: "insightsTargets",
maxEntries: 1000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.16.8 — batched derived-wellness payload
* (`GET /api/insights/derived/batch`). The Insights overview reads
* ~16 metric computes in one request; each cold build walks the
* rollup tier per metric and lands at 1–2 s wall-clock even under the
* bounded `p-limit(4)` fan-out. Keyed
* `${userId}|batch|${sortedTokens}|${locale}` so the overview's one
* canonical token set always lands on one cell. Measurement writes
* cover it through `invalidateUserMeasurements` (evict on interactive
* writes, mark-stale on background syncs); mood writes mark it stale
* (READINESS folds the mood series in). Stale-while-revalidate keeps
* any repeat read inside the 10-minute window instant while one
* background recompute refreshes the cell.
*/
insightsDerived: new ServerCache<unknown>({
name: "insightsDerived",
maxEntries: 2000,
ttlMs: 60_000,
staleTtlMs: 600_000,
}),
/**
* v1.5.5 — per-user insights tile layout cache. Mirrors