diff --git a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts index 8bb77e152..aed76afe3 100644 --- a/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts +++ b/src/app/__tests__/dashboard-tile-strip-skeleton.test.ts @@ -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(" { const src = readFileSync(PAGE_PATH, "utf8"); // v1.16.4 — `!mounted` pins SSR + the hydration render to the diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts index cae362655..ab59ec3a6 100644 --- a/src/app/__tests__/insights-polish.test.ts +++ b/src/app/__tests__/insights-polish.test.ts @@ -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 ``). + 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\) &&/); + }); +}); diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index 96f550009..a63189400 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -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 }; + }; + 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); + }); +}); diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index b9330ff29..1569f4713 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -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> + >, + 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[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 = {}; 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; +} diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index b413e80a0..c65c67674 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -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 + ).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 + ).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).mock.calls + .length, + ).toBe(queriesAfterFirst); + }); }); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index eb0a8a307..1d4504bfa 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -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>["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> >, @@ -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 = {}; const avg30ByType: Record = {}; @@ -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 = { FASTING: "targets.glucoseFasting", diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx index 259179bad..4336534f5 100644 --- a/src/app/insights/page.tsx +++ b/src/app/insights/page.tsx @@ -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 diff --git a/src/app/page.tsx b/src/app/page.tsx index 6fdd0c709..0e665bbd1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([ + "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([ + "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. */} - - {/* 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(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() { )} ))} + {/* 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 && ( + + )} ); })()} + + {/* 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. */} + ); } diff --git a/src/components/dashboard/dashboard-header.tsx b/src/components/dashboard/dashboard-header.tsx index 994e54578..a8e7cb5c1 100644 --- a/src/components/dashboard/dashboard-header.tsx +++ b/src/components/dashboard/dashboard-header.tsx @@ -40,7 +40,14 @@ export function DashboardHeader({

{t("dashboard.title")}

-

{welcomeText}

+ {/* 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. */} +

+ {welcomeText} +

{/* Customize shortcut to the dashboard-customization settings diff --git a/src/components/insights/__tests__/hero-strip.test.tsx b/src/components/insights/__tests__/hero-strip.test.tsx index b0d4009d1..797e136b2 100644 --- a/src/components/insights/__tests__/hero-strip.test.tsx +++ b/src/components/insights/__tests__/hero-strip.test.tsx @@ -317,4 +317,61 @@ describe("", () => { const html = render(); 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( + , + ); + // 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( + , + ); + 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( + , + ); + 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( + , + ); + expect(html).toContain('data-slot="health-score-card"'); + expect(html).not.toContain('data-slot="health-score-card-skeleton"'); + }); }); diff --git a/src/components/insights/hero-strip.tsx b/src/components/insights/hero-strip.tsx index 83180be62..e412f2fc1 100644 --- a/src/components/insights/hero-strip.tsx +++ b/src/components/insights/hero-strip.tsx @@ -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. */}
@@ -287,6 +306,33 @@ export function HeroStrip({ )}
+ {!healthScore && healthScorePending && ( + /* v1.16.8 — layout-stable placeholder mirroring the score + card's column footprint (same basis / stretch classes as + ``): label row, headline number, progress + bar, four component rows. Decorative — the card announces + nothing while loading. */ + + )} {healthScore && ( 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({ 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({ + name: "insightsDerived", + maxEntries: 2000, + ttlMs: 60_000, + staleTtlMs: 600_000, }), /** * v1.5.5 — per-user insights tile layout cache. Mirrors