mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Vendored
+15
@@ -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}|`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+31
@@ -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
|
||||
|
||||
Reference in new issue
Block a user