perf(dashboard): batched rollup series endpoint + server-side target bands + gate-helper split

This commit is contained in:
Marc-André Bombeck committed 2026-06-18 15:33:53 +02:00
commit 82c6947633
16 files changed
+1648 -343

No files matched your search

+320
View File
@@ -1443,6 +1443,55 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/measurements/series-batch:
get:
tags:
- Measurements
summary: Batched daily series (dashboard fetch coalescing)
description: Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the
dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is
byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is
not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via
cookie or Bearer.
parameters:
- in: query
name: types
schema:
description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup
tier.
type: string
minLength: 1
required: true
description: Comma-separated list of MeasurementType values (1..16). Each is read as a daily series through the rollup
tier.
- in: query
name: from
schema:
description: Window start (ISO-8601 instant).
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required: true
description: Window start (ISO-8601 instant).
- in: query
name: to
schema:
description: Window end (ISO-8601 instant).
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required: true
description: Window end (ISO-8601 instant).
responses:
"200":
description: Per-type daily series.
content:
application/json:
schema:
$ref: "#/components/schemas/GetMeasurementsSeriesBatchResponse"
"401": *a1
"422": *a3
"429": *a4
/api/measurements/series:
get:
tags:
@@ -10175,6 +10224,277 @@ components:
- updatedAt
additionalProperties: false
description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.
GetMeasurementsSeriesBatchResponse:
type: object
properties:
data:
$ref: "#/components/schemas/MeasurementsSeriesBatchResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
MeasurementsSeriesBatchResponse:
type: object
properties:
series:
type: object
propertyNames:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
additionalProperties:
type: array
items:
type: object
properties:
type:
type: string
enum:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
value:
type: number
description: Daily mean (spot metrics) or SUM (cumulative).
measuredAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
description: Day-bucket start (ISO-8601).
count:
description: Underlying raw-row count for the bucket.
type: integer
minimum: 0
maximum: 9007199254740991
minValue:
description: Per-day minimum (spot metrics only; absent for cumulative).
anyOf:
- type: number
- type: "null"
maxValue:
description: Per-day maximum (spot metrics only; absent for cumulative).
anyOf:
- type: number
- type: "null"
required:
- type
- value
- measuredAt
additionalProperties: false
required:
- WEIGHT
- BLOOD_PRESSURE_SYS
- BLOOD_PRESSURE_DIA
- PULSE
- BODY_FAT
- SLEEP_DURATION
- ACTIVITY_STEPS
- BLOOD_GLUCOSE
- TOTAL_BODY_WATER
- BONE_MASS
- OXYGEN_SATURATION
- HEART_RATE_VARIABILITY
- RESTING_HEART_RATE
- ACTIVE_ENERGY_BURNED
- FLIGHTS_CLIMBED
- WALKING_RUNNING_DISTANCE
- VO2_MAX
- BODY_TEMPERATURE
- FAT_FREE_MASS
- FAT_MASS
- MUSCLE_MASS
- SKIN_TEMPERATURE
- PULSE_WAVE_VELOCITY
- VASCULAR_AGE
- VISCERAL_FAT
- AUDIO_EXPOSURE_ENV
- AUDIO_EXPOSURE_HEADPHONE
- TIME_IN_DAYLIGHT
- WALKING_STEADINESS
- AUDIO_EXPOSURE_EVENT
- RESPIRATORY_RATE
- BODY_MASS_INDEX
- LEAN_BODY_MASS
- WALKING_HEART_RATE_AVERAGE
- WALKING_ASYMMETRY
- WALKING_DOUBLE_SUPPORT
- WALKING_STEP_LENGTH
- WALKING_SPEED
- CARDIO_RECOVERY
- WRIST_TEMPERATURE
- FALL_COUNT
- SIX_MINUTE_WALK_DISTANCE
- STAIR_ASCENT_SPEED
- STAIR_DESCENT_SPEED
- BREATHING_DISTURBANCES
- IRREGULAR_RHYTHM_NOTIFICATION
- HIGH_HEART_RATE_EVENT
- LOW_HEART_RATE_EVENT
- WALKING_STEADINESS_EVENT
- BREATHING_DISTURBANCE_EVENT
- RECOVERY_SCORE
- STRESS_SCORE
- STRAIN_SCORE
- HRV_RMSSD
- DAY_STRAIN
- WORKOUT_STRAIN
- SLEEP_PERFORMANCE
- SLEEP_EFFICIENCY
- SLEEP_CONSISTENCY
- SLEEP_NEED
- ENERGY_EXPENDITURE_KJ
- AVERAGE_HEART_RATE
- MAX_HEART_RATE
- SLEEP_DISTURBANCE_COUNT
- ANS_CHARGE
- CARDIO_LOAD
- SLEEP_SCORE
- BODY_TEMPERATURE_DEVIATION
description: Per-type daily series, keyed by MeasurementType.
required:
- series
additionalProperties: false
description: Batched daily series — one rollup-backed series per requested type.
GetMeasurementsSeriesResponse:
type: object
properties:
+73
View File
@@ -8,7 +8,79 @@ import type { DataSummary } from "@/lib/analytics/trends";
import type {
DashboardSnapshot,
DashboardLayoutCatalogueEntry,
DashboardTargetBands,
} from "@/lib/dashboard/snapshot";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
buildTrafficLightBands,
buildTrafficRange,
buildWeightBandsFromHeight,
buildWeightRangeFromHeight,
getBodyFatTargetRange,
} from "@/lib/analytics/value-bands";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
// v1.18.6 — resolve the mock's band block from the same pure helpers the
// server builder uses, so the e2e fixture matches the real DTO shape.
function mockTargetBands(): DashboardTargetBands {
const dob = new Date("1990-01-01T00:00:00.000Z");
const bpTargets = getBpTargets(dob);
const pulseTarget = getPersonalizedPulseTarget(
getAgeFromDateOfBirth(dob),
"MALE",
);
const bodyFatRange = getBodyFatTargetRange("MALE");
return {
bpTargets,
bpSysRange: bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null,
bpDiaRange: bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null,
pulseDisplayRange: {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
},
pulseBands: [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((b) => b.max > b.min),
bodyFatRange,
bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
}),
weightRange: buildWeightRangeFromHeight(180),
weightBands: buildWeightBandsFromHeight(180, {
lowerBound: 30,
upperBound: 250,
}),
};
}
/**
* v1.7.2 — shared mock for `GET /api/dashboard/snapshot`.
@@ -138,6 +210,7 @@ export function buildMockSnapshot(
layout: DEFAULT_DASHBOARD_LAYOUT,
layoutCatalogue: buildLayoutCatalogue(),
metricStates: {},
targetBands: mockTargetBands(),
tiles: {
summaries,
lastSeenByType,
+21 -186
View File
@@ -33,12 +33,9 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
recomputeUserRollups,
} from "@/lib/rollups/measurement-rollups";
import {
collapseRollupRowsBySource,
loadUserSourcePriority,
} from "@/lib/rollups/measurement-read";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
import {
reconstructSleepSessions,
pickMainNightAndNaps,
@@ -350,186 +347,23 @@ export const GET = apiHandler(async (request: NextRequest) => {
// populator hasn't caught up still sees a correct chart on its
// first render.
if (source === "rollup" && aggregate === "daily" && type && from && to) {
// v1.18.6 — the rollup-daily read (per-source collapse + coverage
// probe + inline fold + live-SQL fallback) moved to the shared
// `readDailySeries` reader so the batched series endpoint
// (`/api/measurements/series-batch`) reads through the SAME path
// instead of duplicating this SQL. Behaviour is unchanged: the rows
// returned here are byte-identical with the pre-extraction branch.
const cap = Math.min(limit, BUCKET_CAP.daily);
// v1.11.1 — rollup rows are per source; collapse overlapping sources to the
// ladder-canonical reading per day before the chart consumes them, so a
// dual-source vital paints one line, not two stacked points per day.
const priorityJson = await loadUserSourcePriority(user.id);
const rollupRows = collapseRollupRowsBySource(
await prisma.measurementRollup.findMany({
where: {
userId: user.id,
type: type as MeasurementType,
granularity: "DAY",
bucketStart: { gte: from, lte: to },
},
orderBy: { bucketStart: "asc" },
// Fetch beyond the bucket cap pre-collapse — N sources per day can
// exceed `cap` rows before the per-day collapse reduces them.
select: {
type: true,
source: true,
bucketStart: true,
mean: true,
count: true,
// v1.4.39 W-SUM — the writer now populates `sum_value` on
// every fold. The cumulative metric path consumes the column
// directly instead of reconstructing `mean * count`; the
// legacy fallback below covers the boot-backfill convergence
// window when an existing bucket pre-dates v1.4.39.
sumValue: true,
minValue: true,
maxValue: true,
},
}),
type as MeasurementType,
const measurements = await readDailySeries({
userId: user.id,
type: type as MeasurementType,
from,
to,
limit,
priorityJson,
).slice(0, cap);
// v1.4.39.2 — coverage-mismatch fallback. v1.4.39.1 wired the
// rollup write hook into the previously-bypassed Withings sync,
// /api/import, and admin-restore paths, and widened the boot-time
// backfill discovery to per-(user, type, day). Together those
// changes ensure new writes and post-deploy restarts converge the
// rollup tier for every measurement type the user has logged. They
// do NOT, however, guarantee convergence inside a single request
// when the boot backfill has not yet finished folding the user's
// historic rows: a chart query lands first, the rollup table still
// carries 2 rows for a 30-day BP_SYS window, and the route's pre-
// v1.4.39.2 "rollup wins on length > 0" short-circuit returns the
// sparse rollup. The chart trips its `< 3 daily points` empty-state
// even though `measurements` holds the full 30 days.
//
// This branch closes that gap. When the rollup row count is
// suspiciously low for the requested window (≥ 7 days asked and
// < 3 rollup rows present), we probe `measurements` for the actual
// distinct-day count over the same window. If `measurements`
// carries strictly more days than the rollup, the rollup is
// under-converged: we fold the (user, type, DAY, [from, to])
// partition inline, re-read the rollup, and return the refreshed
// rows. The inline fold is bounded by the window + type so it is
// small (~30 buckets × one type for the dashboard chart's 30-day
// window) and finishes well inside the request budget.
//
// The probe runs only on suspicious rollups so the covered-tenant
// happy path stays a single indexed read. The maintainer's tenant on a 30-day
// BP_SYS window pre-fix returned 2 rollup rows and hit `total: 2`
// in the live trace; post-fix the probe finds ~30 distinct days in
// `measurements`, folds them, and the chart paints all 30.
const windowMs = to.getTime() - from.getTime();
const windowDays = Math.ceil(windowMs / 86_400_000);
const SUSPICIOUS_ROW_FLOOR = 3;
const SUSPICIOUS_WINDOW_DAYS = 7;
let effectiveRows = rollupRows;
let coverageFallbackFired = false;
if (
rollupRows.length > 0 &&
rollupRows.length < SUSPICIOUS_ROW_FLOOR &&
windowDays >= SUSPICIOUS_WINDOW_DAYS
) {
// Cheap probe — one indexed range scan over
// `(user_id, type, measured_at)`. Returns the count of distinct
// calendar days the live table holds for the window.
const liveDayCountRows = await prisma.$queryRaw<Array<{ days: bigint }>>`
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."type" = ${type}::measurement_type
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
`;
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
if (liveDays > rollupRows.length) {
coverageFallbackFired = true;
try {
// Inline fold of the (user, type, DAY, window) partition.
// Bounded by the window so it stays small even on a
// power-user account; the upsert is idempotent against the
// composite primary key.
await recomputeUserRollups(user.id, {
types: [type as MeasurementType],
granularities: ["DAY"],
from,
to,
});
// Re-read so this request returns the converged rows. The
// next chart paint hits the same warm rollup without paying
// the fallback cost. v1.11.1 — collapse per-source rows to the
// canonical reading per day, same as the initial read.
effectiveRows = collapseRollupRowsBySource(
await prisma.measurementRollup.findMany({
where: {
userId: user.id,
type: type as MeasurementType,
granularity: "DAY",
bucketStart: { gte: from, lte: to },
},
orderBy: { bucketStart: "asc" },
select: {
type: true,
source: true,
bucketStart: true,
mean: true,
count: true,
sumValue: true,
minValue: true,
maxValue: true,
},
}),
type as MeasurementType,
priorityJson,
).slice(0, cap);
} catch (err) {
// Best-effort — fall back to the legacy live aggregate path
// below if the fold itself failed so the chart paints
// something sensible regardless. `annotate` carries the
// surface so ops can spot a silent populator regression.
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
rollup_coverage_fallback_failed: true,
rollup_coverage_fallback_error: message,
type,
},
});
// Drop into the live `date_trunc` branch below.
effectiveRows = [];
}
}
}
if (effectiveRows.length > 0) {
// Cumulative metrics (steps, active energy, distance, flights,
// daylight) store `mean` per bucket but the chart needs the
// daily SUM. v1.4.39 W-SUM — read `sumValue` directly; fall back
// to `mean * count` only when the legacy NULL surfaces (pre-
// v1.4.39 rows still waiting for the boot-time backfill to
// converge). `mean * count` is algebraically equivalent to
// SUM(value) for a single-source day because AVG = SUM / COUNT.
const useSum =
type != null && CUMULATIVE_HK_TYPES.has(type as MeasurementType);
const measurements = effectiveRows.map((r) => ({
type: r.type,
value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
// v1.8.5 — per-day min / max so the chart can shade an
// Apple-Health-style range band around the daily mean line.
// Omitted for cumulative metrics (steps, distance, …) where the
// daily value is a SUM, not a central tendency, so an intra-day
// spread band carries no meaning.
minValue: useSum ? undefined : r.minValue,
maxValue: useSum ? undefined : r.maxValue,
}));
annotate({
action: { name: "measurement.list" },
meta: {
total: measurements.length,
type,
aggregate: "daily",
source: "rollup",
coverage_fallback: coverageFallbackFired,
},
});
});
if (measurements.length > 0) {
return apiSuccess({
measurements,
meta: {
@@ -540,10 +374,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
},
});
}
// Fall through to the live aggregate path when the rollup is
// empty for the requested window — covers brand-new accounts +
// the race between a recent write hook and the read, and the
// coverage-fallback inline-fold-failed branch above.
// `readDailySeries` already falls back to the live `date_trunc`
// aggregate on a rollup-coverage miss, so an empty result here means
// the window genuinely holds no rows. Fall through to the shared
// live aggregate branch below to keep the response shape identical
// for the truly-empty case.
}
if (aggregate && aggregate !== "raw" && from && to) {
@@ -0,0 +1,120 @@
/**
* v1.18.6 — `GET /api/measurements/series-batch` unit + parity tests.
*
* The endpoint reads each requested type through the SHARED
* `readDailySeries` reader — the exact same reader the single-type
* `GET /api/measurements?aggregate=daily&source=rollup` route delegates
* to (see `src/app/api/measurements/route.ts`). The parity guarantee is
* therefore structural: both routes call `readDailySeries`, so the rows
* are identical by construction. These tests pin that the batch route
* (a) validates its query, (b) calls the reader once per requested type
* with the right window, and (c) returns each type's reader output
* verbatim under `series[type]`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return { ...actual, annotate: vi.fn() };
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
vi.mock("@/lib/rollups/measurement-read", () => ({
loadUserSourcePriority: vi.fn(async () => null),
}));
vi.mock("@/lib/measurements/daily-series-read", () => ({
readDailySeries: vi.fn(),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { readDailySeries } from "@/lib/measurements/daily-series-read";
const SESSION_OK = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "u", role: "USER" as const },
};
const FROM = "2026-05-01T00:00:00.000Z";
const TO = "2026-05-31T23:59:59.000Z";
function req(query: string): NextRequest {
return new NextRequest(
`http://localhost/api/measurements/series-batch?${query}`,
);
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/measurements/series-batch", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req(`types=WEIGHT&from=${FROM}&to=${TO}`));
expect(res.status).toBe(401);
});
it("returns 422 when types is missing", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req(`from=${FROM}&to=${TO}`));
expect(res.status).toBe(422);
});
it("returns 422 for an unknown type", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req(`types=GARBAGE&from=${FROM}&to=${TO}`));
expect(res.status).toBe(422);
});
it("reads each type through readDailySeries and keys the result by type", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const weightRows = [
{ type: "WEIGHT", value: 80, measuredAt: FROM, count: 1 },
];
const bpRows = [
{ type: "BLOOD_PRESSURE_SYS", value: 126, measuredAt: FROM, count: 1 },
];
vi.mocked(readDailySeries).mockImplementation(async ({ type }) => {
if (type === "WEIGHT") return weightRows as never;
if (type === "BLOOD_PRESSURE_SYS") return bpRows as never;
return [] as never;
});
const res = await GET(
req(`types=WEIGHT,BLOOD_PRESSURE_SYS&from=${FROM}&to=${TO}`),
);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.series.WEIGHT).toEqual(weightRows);
expect(body.data.series.BLOOD_PRESSURE_SYS).toEqual(bpRows);
// One reader call per unique type, with the parsed window.
expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(2);
const call = vi.mocked(readDailySeries).mock.calls[0][0];
expect(call.userId).toBe("user-1");
expect(call.from.toISOString()).toBe(FROM);
expect(call.to.toISOString()).toBe(TO);
});
it("de-dupes a repeated type to one reader call", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(readDailySeries).mockResolvedValue([] as never);
const res = await GET(req(`types=WEIGHT,WEIGHT&from=${FROM}&to=${TO}`));
expect(res.status).toBe(200);
expect(vi.mocked(readDailySeries)).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,99 @@
/**
* v1.18.6 — GET /api/measurements/series-batch
*
* Batched, rollup-backed daily-series endpoint. Returns ALL requested
* measurement types in ONE response so the dashboard's chart row does a
* single round-trip instead of one self-fetch per chart (audit finding
* #2 — the per-chart fan-out).
*
* Query: `types` (comma-list of `MeasurementType`), `from`, `to`. Always
* reads `aggregate=daily` through the rollup tier (`source=rollup`) via
* the shared `readDailySeries` reader — the SAME path
* `GET /api/measurements?aggregate=daily&source=rollup` uses, so the
* per-type series are byte-identical between the two endpoints. Internal
* per-type reads are capped with `p-limit` to protect the shared Prisma
* pool.
*
* Sleep is deliberately NOT included: SLEEP_DURATION rides a per-night
* stage reconstruction (`/api/measurements/series?kind=sleep`), not the
* rollup tier, so the dashboard sleep chart stays on its existing
* `/series` call. Passing `SLEEP_DURATION` here yields an empty series
* for that key (the rollup carries no useful per-night collapse) — the
* client keeps sleep on the dedicated route.
*/
import { NextRequest } from "next/server";
import pLimit from "p-limit";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues } from "@/lib/api-response";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
import {
readDailySeries,
type DailySeriesRow,
} from "@/lib/measurements/daily-series-read";
import {
seriesBatchQuerySchema,
SERIES_BATCH_MAX_TYPES,
} from "@/lib/validations/series-batch";
import type { MeasurementType } from "@/generated/prisma/client";
/**
* Concurrency cap for the per-type rollup reads. Mirrors the W-POOL
* `p-limit(4)` discipline applied across the analytics fan-out so a
* wide dashboard never packs the shared Prisma pool.
*/
const SERIES_BATCH_CONCURRENCY = 4;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const params = Object.fromEntries(request.nextUrl.searchParams);
const parsed = seriesBatchQuerySchema.safeParse(params);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
const { types, from, to } = parsed.data;
// De-dupe while preserving order so a caller repeating a type doesn't
// double-read it.
const uniqueTypes = Array.from(new Set(types)) as MeasurementType[];
// Load the source-priority ladder ONCE and thread it across every type
// so the batched reader doesn't re-query it per type.
const priorityJson = await loadUserSourcePriority(user.id);
const limit = pLimit(SERIES_BATCH_CONCURRENCY);
const entries = await Promise.all(
uniqueTypes.map((type) =>
limit(async () => {
const rows = await readDailySeries({
userId: user.id,
type,
from,
to,
priorityJson,
});
return [type, rows] as const;
}),
),
);
const series: Record<string, DailySeriesRow[]> = {};
for (const [type, rows] of entries) {
series[type] = rows;
}
annotate({
action: { name: "measurement.series.batch" },
meta: {
type_count: uniqueTypes.length,
total: entries.reduce((sum, [, rows]) => sum + rows.length, 0),
},
});
return apiSuccess({ series });
});
// Re-export the cap so the OpenAPI table + tests share the constant.
export { SERIES_BATCH_MAX_TYPES };
+141 -134
View File
@@ -120,115 +120,20 @@ import {
} from "@/lib/analytics/pulse-targets";
import { apiGet } from "@/lib/api/api-fetch";
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
*
* `primaryLoading` must come from whichever query actually drives the
* tiles: the snapshot cell by default, the slim analytics cell when
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports
* `isLoading: false` (idle fetch status), so keying off the wrong
* source flashes the empty state for the whole fetch. Pure + exported
* so the gate has direct unit coverage without mounting the page.
*/
export function resolveDashboardFirstPaintGate(input: {
trendCardCount: number;
chartCount: number;
configuredTileCount: number;
primaryLoading: boolean;
}): { showTileStripSkeleton: boolean; showEmptyState: boolean } {
const showTileStripSkeleton =
input.trendCardCount === 0 &&
input.primaryLoading &&
input.configuredTileCount > 0;
const showEmptyState =
input.trendCardCount === 0 &&
input.chartCount === 0 &&
!showTileStripSkeleton &&
!input.primaryLoading;
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.18.2 — Vorsorge preventive-care summary card (chart-row only).
"vorsorge",
]);
/**
* 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;
}
// v1.18.6 — the pure first-paint / skeleton-reservation gates moved to
// `@/components/dashboard/dashboard-gates`. Re-exported here so the
// existing `../page` unit-test imports stay valid while `page.tsx` thins
// to an orchestrator.
export {
resolveDashboardFirstPaintGate,
resolveConfiguredTileCount,
resolveChartRowPlaceholderCount,
} from "@/components/dashboard/dashboard-gates";
import {
resolveDashboardFirstPaintGate,
resolveConfiguredTileCount,
resolveChartRowPlaceholderCount,
} from "@/components/dashboard/dashboard-gates";
export default function DashboardPage() {
const { isAuthenticated, user } = useAuth();
@@ -621,23 +526,45 @@ export default function DashboardPage() {
RANDOM: "targets.glucoseRandom",
BEDTIME: "targets.glucoseBedtime",
};
const bpTargets =
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
// v1.18.6 — band / target math is computed SERVER-side in the snapshot
// DTO (`targetBands`) so the client stops recomputing it from the
// profile (audit finding #3). When snapshot mode is on, read the
// resolved numbers straight from `snapshotQuery.data.targetBands`; the
// `!snapshotEnabled` branch keeps the legacy client-compute path so the
// non-snapshot fallback still works. The two produce byte-identical
// numbers (the server calls the SAME helpers — see
// `buildTargetBands` + its parity test).
const serverBands = snapshotEnabled
? snapshotQuery.data?.targetBands
: undefined;
const bpTargets = snapshotEnabled
? (serverBands?.bpTargets ?? null)
: user?.dateOfBirth != null
? getBpTargets(new Date(user.dateOfBirth))
: null;
const bodyFatRange = snapshotEnabled
? (serverBands?.bodyFatRange ?? getBodyFatTargetRange(user?.gender))
: getBodyFatTargetRange(user?.gender);
const weightRange = snapshotEnabled
? (serverBands?.weightRange ?? null)
: user?.heightCm
? buildWeightRangeFromHeight(user.heightCm)
: null;
const weightBands = snapshotEnabled
? (serverBands?.weightBands ?? undefined)
: user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
// The pulse target range / bands carry no i18n — read them server-side
// under snapshot mode; recompute from the profile otherwise.
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
const bodyFatRange = getBodyFatTargetRange(user?.gender);
const weightRange = user?.heightCm
? buildWeightRangeFromHeight(user.heightCm)
: null;
const weightBands = user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
const bpTargetZones = bpTargets
? [
{
@@ -660,19 +587,23 @@ export default function DashboardPage() {
},
]
: undefined;
const bpSysRange = bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null;
const bpDiaRange = bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null;
const pulseDisplayRange = {
const bpSysRange = snapshotEnabled
? (serverBands?.bpSysRange ?? null)
: bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null;
const bpDiaRange = snapshotEnabled
? (serverBands?.bpDiaRange ?? null)
: bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null;
const pulseDisplayRange = serverBands?.pulseDisplayRange ?? {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
};
const pulseBands = [
const pulseBands = serverBands?.pulseBands ?? [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
@@ -694,14 +625,84 @@ export default function DashboardPage() {
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((band) => band.max > band.min);
const bodyFatBands = buildTrafficLightBands(
bodyFatRange.min,
bodyFatRange.max,
{
const bodyFatBands =
serverBands?.bodyFatBands ??
buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
});
// v1.18.6 — ONE batched fetch for every visible non-sleep chart series
// (audit finding #2). Pre-fix, each `<HealthChart>` fired its own
// `/api/measurements?...&source=rollup` round-trip, so a 6-8 chart
// dashboard issued 6-8 parallel requests on load. The batched endpoint
// returns all of them in one response; each chart reads its slice via
// `preloadedSeries` instead of self-fetching. Sleep stays on its
// dedicated per-night `/series` adapter (not rollup-backed), so it is
// never included here and the sleep chart keeps its own fetch.
//
// The window matches the charts' default 30-point view (the dashboard
// mounts them with no range-tab interaction); a chart whose window
// widens past this slice (range tab / comparison) drops batched
// coverage and self-fetches for the new window — see `usePreloaded`.
const batchTypeSet = new Set<string>();
if (showWeightChart) batchTypeSet.add("WEIGHT");
if (showBpCharts) {
batchTypeSet.add("BLOOD_PRESSURE_SYS");
batchTypeSet.add("BLOOD_PRESSURE_DIA");
}
if (showPulseChart)
batchTypeSet.add(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE");
if (showBodyFatChart) batchTypeSet.add("BODY_FAT");
if (showStepsChart) batchTypeSet.add("ACTIVITY_STEPS");
const batchChartTypes = Array.from(batchTypeSet);
// Stable day-bucketed window so the cache key is stable across the day
// (mirrors the chart's own ISO-window stability contract). Computed
// once per mount via lazy state so a re-render never re-keys the batch.
const [batchWindow] = useState(() => {
const to = new Date();
to.setHours(23, 59, 59, 999);
const from = new Date(to.getTime() - 31 * 86_400_000);
return { from: from.toISOString(), to: to.toISOString() };
});
const { data: batchedSeries } = useQuery({
queryKey: queryKeys.chartSeriesBatch(
batchChartTypes.join(","),
batchWindow.from,
batchWindow.to,
),
enabled:
isAuthenticated && mounted && batchChartTypes.length > 0,
staleTime: 60_000,
gcTime: 5 * 60_000,
refetchOnWindowFocus: false,
queryFn: async () => {
const sp = new URLSearchParams();
sp.set("types", batchChartTypes.join(","));
sp.set("from", batchWindow.from);
sp.set("to", batchWindow.to);
const res = await apiGet<{
series: Record<
string,
Array<{
value: number;
measuredAt: string;
count?: number;
minValue?: number | null;
maxValue?: number | null;
}>
>;
}>(`/api/measurements/series-batch?${sp}`);
return res.series;
},
);
});
// Per-chart slices in the chart's `MeasurementApiRow` shape. The batched
// rows already carry `measuredAt` / `value` / `count` / min / max — the
// exact fields the chart's bucketing loop reads.
const preloadedSeries = batchedSeries;
// 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
@@ -1222,6 +1223,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="weight-chart"
onDataReady={() => markChartReady("weight-chart")}
preloadedSeries={preloadedSeries}
chartKey="weight"
types={["WEIGHT"]}
title={t("dashboard.weight")}
@@ -1242,6 +1244,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="bmi-chart"
onDataReady={() => markChartReady("bmi-chart")}
preloadedSeries={preloadedSeries}
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
@@ -1271,6 +1274,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="bp-chart"
onDataReady={() => markChartReady("bp-chart")}
preloadedSeries={preloadedSeries}
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("dashboard.bloodPressure")}
@@ -1294,6 +1298,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="pulse-chart"
onDataReady={() => markChartReady("pulse-chart")}
preloadedSeries={preloadedSeries}
chartKey="pulse"
// v1.15.12 A2 — chart the RESTING series against the
// resting band when available; otherwise chart raw heart
@@ -1320,6 +1325,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="bodyFat-chart"
onDataReady={() => markChartReady("bodyFat-chart")}
preloadedSeries={preloadedSeries}
chartKey="bodyFat"
types={["BODY_FAT"]}
title={t("dashboard.bodyFat")}
@@ -1380,6 +1386,7 @@ export default function DashboardPage() {
<HealthChartDynamic
key="steps-chart"
onDataReady={() => markChartReady("steps-chart")}
preloadedSeries={preloadedSeries}
chartKey="steps"
types={["ACTIVITY_STEPS"]}
title={t("dashboard.steps") ?? "Steps"}
+71 -12
View File
@@ -233,6 +233,23 @@ interface HealthChartProps {
* idempotent; non-dashboard mounts simply omit it.
*/
onDataReady?: () => void;
/**
* v1.18.6 — pre-fetched daily series, keyed by `MeasurementType`. When
* the dashboard batches every visible chart's series into ONE
* `/api/measurements/series-batch` round-trip (audit finding #2), it
* passes each chart its slice here. When supplied, the chart's queryFn
* consumes these rows for the covered types instead of issuing its own
* per-type `/api/measurements` fetch. Omitted on non-dashboard mounts
* (insights sub-pages, mini cards) which keep self-fetching.
* SLEEP_DURATION is never pre-fetched (it rides the per-night `/series`
* adapter), so a sleep chart self-fetches that branch regardless.
*
* The rows must already be scoped to the chart's active fetch window;
* the prop is keyed into the query cache so a range-tab change (wider
* window) re-fetches cleanly rather than blending a batched slice with
* a self-fetched one.
*/
preloadedSeries?: Record<string, MeasurementApiRow[]>;
}
interface ChartDataPoint {
@@ -251,8 +268,13 @@ interface MeasurementApiRow {
// path (non-cumulative metrics only). Drive the Apple-Health-style
// range band shaded around the mean line. Absent on the raw-row path
// (windows ≤ 7 days) and on cumulative metrics.
minValue?: number;
maxValue?: number;
// v1.18.6 — `null` accepted alongside `undefined` so the batched
// series endpoint's row shape (which carries `number | null`) feeds
// `preloadedSeries` directly. The bucketing loop already guards each
// with a `typeof … === "number"` check, so a null is ignored exactly
// like an absent value.
minValue?: number | null;
maxValue?: number | null;
// v1.4.43 W2-CHART-GATE — the rollup / daily-aggregate paths return
// the underlying raw-row count per bucket. Used downstream to gate
// the "more days needed" empty-state copy on the actual measurement
@@ -537,6 +559,7 @@ export function HealthChart({
onVisibleStats,
titleIcon,
onDataReady,
preloadedSeries,
}: HealthChartProps) {
const { isAuthenticated, user } = useAuth();
const { t, locale } = useTranslations();
@@ -620,6 +643,20 @@ export function HealthChart({
};
}, [rangePoints, effectiveCompareBaseline]);
// v1.18.6 — honour the dashboard's batched slice ONLY for the
// daily-aggregate window (`windowDays > 7`, the rollup path) and ONLY
// when EVERY non-sleep type the chart renders is present in the slice.
// SLEEP_DURATION is never batched (per-night `/series`), so a chart
// that includes it always self-fetches. A range-tab change to a ≤7-day
// window (raw rows) drops batched coverage and self-fetches.
const usePreloaded = useMemo(() => {
if (!preloadedSeries) return false;
if (fetchWindow.windowDays <= 7) return false;
return types.every(
(type) => type === "SLEEP_DURATION" || preloadedSeries[type] != null,
);
}, [preloadedSeries, fetchWindow.windowDays, types]);
const { data, isLoading, isError, refetch } = useQuery({
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
@@ -649,6 +686,14 @@ export function HealthChart({
// v1.7.0 — re-key when the display scale changes so a km/h chart
// never reads a m/s-scaled sibling out of the cache.
valueScale,
// v1.18.6 — discriminate a batched-slice render from a self-fetch
// ONLY on the dashboard mounts that pass `preloadedSeries`. When the
// prop is absent (insights sub-pages, mini cards) the empty string
// keeps the factory tuple byte-identical with the pre-v1.18.6
// 8-element key, so those callers' cache layout is unchanged. On a
// dashboard mount the discriminator flips "preloaded" → "fetch" when
// a range-tab change drops batched coverage, re-fetching cleanly.
preloadedSeries ? (usePreloaded ? "preloaded" : "fetch") : "",
),
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
// navigation between insights sub-pages does not re-fire every
@@ -760,16 +805,30 @@ export function HealthChart({
typeParams.set("source", "rollup");
}
// v1.16.8 — a failed per-type fetch rejects the whole query
// instead of silently dropping the series: the swallowed-catch
// variant cached the partial/empty result as fresh success, so
// an outage painted the empty state (or a half chart) with no
// retry path. Rejecting lets TanStack's retry semantics and the
// in-card error state below do their jobs.
const data = await apiGet<{ measurements?: MeasurementApiRow[] }>(
`/api/measurements?${typeParams}`,
);
const page = data?.measurements ?? [];
// v1.18.6 — when the dashboard handed this chart a batched slice
// for the type, fold those rows instead of a per-type fetch. The
// rows came from `/api/measurements/series-batch`, which reads
// through the SAME rollup path the per-type daily fetch hits, so
// the bucketed result is byte-identical. `usePreloaded` already
// gated the window (`> 7`) + per-type coverage above; default to
// an empty slice (paints the no-data card) rather than fetching,
// so a covered chart never issues a network request.
const page =
usePreloaded && preloadedSeries
? (preloadedSeries[type] ?? [])
: await (async () => {
// v1.16.8 — a failed per-type fetch rejects the whole
// query instead of silently dropping the series: the
// swallowed-catch variant cached the partial/empty result
// as fresh success, so an outage painted the empty state
// (or a half chart) with no retry path. Rejecting lets
// TanStack's retry semantics and the in-card error state
// below do their jobs.
const data = await apiGet<{
measurements?: MeasurementApiRow[];
}>(`/api/measurements?${typeParams}`);
return data?.measurements ?? [];
})();
for (const measurement of page) {
// v1.7.0 — fold the display-time scale into the raw value at
+119
View File
@@ -0,0 +1,119 @@
/**
* v1.18.6 — dashboard first-paint / skeleton-reservation gates.
*
* Extracted verbatim from `src/app/page.tsx` so the page component reads
* as a thinner orchestrator and these pure helpers (already unit-tested)
* live in one cohesive module. `page.tsx` re-exports them so the existing
* `../page` test imports stay valid.
*/
import type { DashboardLayout } from "@/lib/dashboard-layout";
/**
* v1.7.0 — first-paint gate for the dashboard tile strip.
*
* `primaryLoading` must come from whichever query actually drives the
* tiles: the snapshot cell by default, the slim analytics cell when
* `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false`. A disabled TanStack query reports
* `isLoading: false` (idle fetch status), so keying off the wrong
* source flashes the empty state for the whole fetch. Pure + exported
* so the gate has direct unit coverage without mounting the page.
*/
export function resolveDashboardFirstPaintGate(input: {
trendCardCount: number;
chartCount: number;
configuredTileCount: number;
primaryLoading: boolean;
}): { showTileStripSkeleton: boolean; showEmptyState: boolean } {
const showTileStripSkeleton =
input.trendCardCount === 0 &&
input.primaryLoading &&
input.configuredTileCount > 0;
const showEmptyState =
input.trendCardCount === 0 &&
input.chartCount === 0 &&
!showTileStripSkeleton &&
!input.primaryLoading;
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.18.2 — Vorsorge preventive-care summary card (chart-row only).
"vorsorge",
]);
/**
* 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;
}
@@ -6,6 +6,7 @@ import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
import { makeFormatters } from "@/lib/format-locale";
import type { DashboardSnapshot } from "@/lib/dashboard/snapshot";
import { buildTargetBands } from "@/lib/dashboard/snapshot";
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
import type { DataSummary } from "@/lib/analytics/trends";
import { DashboardHero } from "../dashboard-hero";
@@ -92,6 +93,11 @@ function baseSnapshot(
layout: { version: 1, widgets: [] },
layoutCatalogue: [],
metricStates: {},
targetBands: buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: null,
}),
tiles: {
summaries: {},
lastSeenByType: {},
@@ -0,0 +1,142 @@
/**
* v1.18.6 — parity test for the server-side band / target math
* (audit finding #3). `buildTargetBands` must produce byte-identical
* numbers to the legacy client-side derivation in `page.tsx`, which is
* just a sequence of the SAME pure helpers. The test re-derives each
* structure with those helpers and asserts deep equality for a male
* profile, a female profile, and a no-profile user.
*/
import { describe, it, expect } from "vitest";
import { buildTargetBands } from "../snapshot";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import {
buildTrafficLightBands,
buildTrafficRange,
buildWeightBandsFromHeight,
buildWeightRangeFromHeight,
getBodyFatTargetRange,
} from "@/lib/analytics/value-bands";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
/** Re-derive bands exactly as `page.tsx` did before v1.18.6. */
function clientBands(profile: {
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
}) {
const bpTargets = profile.dateOfBirth
? getBpTargets(profile.dateOfBirth)
: null;
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender);
const bodyFatRange = getBodyFatTargetRange(profile.gender);
const weightRange = profile.heightCm
? buildWeightRangeFromHeight(profile.heightCm)
: null;
const weightBands = profile.heightCm
? buildWeightBandsFromHeight(profile.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: null;
return {
bpTargets,
bpSysRange: bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null,
bpDiaRange: bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null,
pulseDisplayRange: {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
},
pulseBands: [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((b) => b.max > b.min),
bodyFatRange,
bodyFatBands: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
}),
weightRange,
weightBands,
};
}
describe("buildTargetBands — server/client parity", () => {
const profiles: Array<{
name: string;
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
}> = [
{
name: "male 180cm with DOB",
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
},
{
name: "female 165cm with DOB",
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
gender: "FEMALE",
heightCm: 165,
},
{
name: "no-profile user (null DOB / gender / height)",
dateOfBirth: null,
gender: null,
heightCm: null,
},
];
for (const profile of profiles) {
it(`matches the legacy client math for ${profile.name}`, () => {
const server = buildTargetBands(profile);
const client = clientBands(profile);
expect(server).toEqual(client);
});
}
it("nulls the profile-derived bands when no DOB / height", () => {
const bands = buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: null,
});
expect(bands.bpTargets).toBeNull();
expect(bands.bpSysRange).toBeNull();
expect(bands.bpDiaRange).toBeNull();
expect(bands.weightRange).toBeNull();
expect(bands.weightBands).toBeNull();
// Pulse + body-fat always resolve (AHA / neutral fallback).
expect(bands.pulseBands.length).toBeGreaterThan(0);
expect(bands.bodyFatBands.length).toBeGreaterThan(0);
});
});
@@ -18,6 +18,7 @@
import { describe, it, expect } from "vitest";
import { resolveDashboardVerdict } from "../verdict";
import { buildTargetBands } from "../snapshot";
import type { DashboardSnapshot } from "../snapshot";
import type { MedsTodayBlock } from "../meds-today";
import type { DataSummary } from "@/lib/analytics/trends";
@@ -73,6 +74,11 @@ function baseSnapshot(
layout: { version: 1, widgets: [] },
layoutCatalogue: [],
metricStates: {},
targetBands: buildTargetBands({
dateOfBirth: null,
gender: null,
heightCm: null,
}),
tiles: {
summaries: {},
lastSeenByType: {},
+145 -1
View File
@@ -45,7 +45,20 @@ import {
type DataPoint,
type DataSummary,
} from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
import {
buildTrafficLightBands,
buildTrafficRange,
buildWeightBandsFromHeight,
buildWeightRangeFromHeight,
getBodyFatTargetRange,
type ValueBand,
type TrafficRange,
} from "@/lib/analytics/value-bands";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
import {
computeGlucoseClinicalMetrics,
GLUCOSE_PANEL_WINDOW_DAYS,
@@ -196,6 +209,48 @@ export interface DashboardSnapshotUser {
greetingHour: number;
}
/**
* v1.18.6 — server-computed band / target math (audit finding #3).
*
* The dashboard used to derive every chart band + tile range CLIENT-side
* in `page.tsx` from `user.dateOfBirth` / `gender` / `heightCm`. This
* block resolves the same numbers server-side from the EXISTING band
* helpers (`getBpTargets`, `getPersonalizedPulseTarget`,
* `getBodyFatTargetRange`, `buildWeight*FromHeight`,
* `buildTrafficLightBands`, `buildTrafficRange`) so the client reads them
* instead of recomputing. Only the NUMERIC structures live here — the
* `bpTargetZones` array carries `t()`-localised labels, so the client
* assembles THAT from `bpTargets` (numbers) at render time. Every field
* is `null` when the driving profile fact is missing (no DOB → BP/pulse
* personalisation, no height → weight band), matching the client's
* previous null-guards exactly.
*/
export interface DashboardTargetBands {
/** Personalised BP target numbers (null when no DOB). */
bpTargets: BpTargets | null;
/** Systolic traffic range (null when no DOB). */
bpSysRange: TrafficRange | null;
/** Diastolic traffic range (null when no DOB). */
bpDiaRange: TrafficRange | null;
/** Resting-pulse display range (always present — AHA fallback). */
pulseDisplayRange: {
greenMin: number;
greenMax: number;
orangeMin: number;
orangeMax: number;
};
/** Resting-pulse chart bands (always present — AHA fallback). */
pulseBands: ValueBand[];
/** Body-fat target range (gender-aware; always present). */
bodyFatRange: { min: number; max: number };
/** Body-fat chart bands (always present). */
bodyFatBands: ValueBand[];
/** Weight traffic range (null when no height). */
weightRange: TrafficRange | null;
/** Weight chart bands (null when no height). */
weightBands: ValueBand[] | null;
}
export interface DashboardSnapshotMoodEntry {
date: string;
score: number;
@@ -301,6 +356,12 @@ export interface DashboardSnapshot {
* `MetricKind` raw value. Additive; the web page does not read it.
*/
metricStates: Record<string, DashboardMetricState>;
/**
* v1.18.6 — server-computed band / target math (audit finding #3). The
* web dashboard reads these instead of recomputing from the profile;
* additive for iOS.
*/
targetBands: DashboardTargetBands;
/** Fast phase — always present. */
tiles: {
summaries: Record<string, DataSummary>;
@@ -600,6 +661,84 @@ async function buildExtras(
};
}
/**
* v1.18.6 — resolve the band / target math server-side from the same
* helpers the client used. Pure projection over the profile facts; no DB
* read. Mirrors the exact construction `page.tsx` ran (same helper calls,
* same literal pulse-band colours/opacities, same null-guards) so the
* snapshot path and the legacy client-compute path produce byte-identical
* numbers.
*/
export function buildTargetBands(profile: {
dateOfBirth: Date | null;
gender: "MALE" | "FEMALE" | null;
heightCm: number | null;
}): DashboardTargetBands {
const bpTargets = profile.dateOfBirth
? getBpTargets(profile.dateOfBirth)
: null;
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
const pulseTarget = getPersonalizedPulseTarget(pulseAge, profile.gender);
const bodyFatRange = getBodyFatTargetRange(profile.gender);
const weightRange = profile.heightCm
? buildWeightRangeFromHeight(profile.heightCm)
: null;
const weightBands = profile.heightCm
? buildWeightBandsFromHeight(profile.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: null;
const bpSysRange = bpTargets
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
: null;
const bpDiaRange = bpTargets
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
: null;
const pulseBands = [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((band) => band.max > band.min);
const bodyFatBands = buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
lowerBound: 2,
upperBound: 55,
});
return {
bpTargets,
bpSysRange,
bpDiaRange,
pulseDisplayRange: {
greenMin: pulseTarget.greenMin,
greenMax: pulseTarget.greenMax,
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
},
pulseBands,
bodyFatRange,
bodyFatBands,
weightRange,
weightBands,
};
}
/** Parse + validate the cached `dailyBriefing` block; null when unusable. */
function parseCachedBriefing(
cachedText: string | null,
@@ -968,6 +1107,11 @@ export async function buildDashboardSnapshot(
layout,
layoutCatalogue: buildLayoutCatalogue(layout),
metricStates: buildMetricStates(slim.summaries, slim.lastSeenByType),
targetBands: buildTargetBands({
dateOfBirth: user.dateOfBirth,
gender,
heightCm: user.heightCm ?? null,
}),
tiles: {
summaries: slim.summaries,
lastSeenByType: enrichLastSeen(slim.lastSeenByType, nowMs),
+226
View File
@@ -0,0 +1,226 @@
/**
* v1.18.6 — shared rollup-backed daily-series reader.
*
* Extracted from the `GET /api/measurements` rollup-daily branch so the
* single-type route AND the batched series endpoint
* (`GET /api/measurements/series-batch`) read through the SAME path
* instead of duplicating the rollup SQL + the coverage-fallback fold.
*
* `readDailySeries` reproduces the route's `source=rollup` +
* `aggregate=daily` contract byte-for-byte: collapse per-source rollup
* rows to the ladder-canonical reading per day, probe for an
* under-converged rollup window and fold inline when the live table
* carries more distinct days, and fall through to the live `date_trunc`
* aggregate when the rollup is empty for the window (brand-new accounts /
* write-hook race). The returned rows match the wire shape the chart
* client consumes (`{ type, value, measuredAt, count, minValue?,
* maxValue? }`).
*/
import { prisma } from "@/lib/db";
import {
BUCKET_CAP,
} from "@/lib/measurements/range-aggregation";
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
import {
collapseRollupRowsBySource,
} from "@/lib/rollups/measurement-read";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
import { buildSourceRankCase } from "@/lib/analytics/source-rank-sql";
import { annotate } from "@/lib/logging/context";
import type { MeasurementType } from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
/** Wire row the chart-data client consumes — mirrors the route's shape. */
export interface DailySeriesRow {
type: string;
value: number;
measuredAt: string;
count?: number;
minValue?: number | null;
maxValue?: number | null;
}
const SUSPICIOUS_ROW_FLOOR = 3;
const SUSPICIOUS_WINDOW_DAYS = 7;
/**
* Read one type's daily series for `[from, to]` through the rollup tier,
* falling back to the live `date_trunc` aggregate on a coverage miss.
*
* `priorityJson` is the user's source-priority ladder (load once per
* request via `loadUserSourcePriority` and thread it across types so the
* batched reader doesn't re-query it per type). `cap` defaults to the
* daily bucket ceiling.
*/
export async function readDailySeries(opts: {
userId: string;
type: MeasurementType;
from: Date;
to: Date;
limit?: number;
priorityJson: unknown;
}): Promise<DailySeriesRow[]> {
const { userId, type, from, to, priorityJson } = opts;
const cap = Math.min(opts.limit ?? BUCKET_CAP.daily, BUCKET_CAP.daily);
const readRollup = async () =>
collapseRollupRowsBySource(
await prisma.measurementRollup.findMany({
where: {
userId,
type,
granularity: "DAY",
bucketStart: { gte: from, lte: to },
},
orderBy: { bucketStart: "asc" },
select: {
type: true,
source: true,
bucketStart: true,
mean: true,
count: true,
sumValue: true,
minValue: true,
maxValue: true,
},
}),
type,
priorityJson,
).slice(0, cap);
const rollupRows = await readRollup();
// Coverage-mismatch probe + inline fold (identical to the route).
const windowMs = to.getTime() - from.getTime();
const windowDays = Math.ceil(windowMs / 86_400_000);
let effectiveRows = rollupRows;
let coverageFallbackFired = false;
if (
rollupRows.length > 0 &&
rollupRows.length < SUSPICIOUS_ROW_FLOOR &&
windowDays >= SUSPICIOUS_WINDOW_DAYS
) {
const liveDayCountRows = await prisma.$queryRaw<Array<{ days: bigint }>>`
SELECT COUNT(DISTINCT date_trunc('day', m."measured_at"))::bigint AS days
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."type" = ${type}::measurement_type
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
`;
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
if (liveDays > rollupRows.length) {
coverageFallbackFired = true;
try {
await recomputeUserRollups(userId, {
types: [type],
granularities: ["DAY"],
from,
to,
});
effectiveRows = await readRollup();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
annotate({
meta: {
rollup_coverage_fallback_failed: true,
rollup_coverage_fallback_error: message,
type,
},
});
effectiveRows = [];
}
}
}
if (effectiveRows.length > 0) {
const useSum = CUMULATIVE_HK_TYPES.has(type);
annotate({
action: { name: "measurement.list" },
meta: {
total: effectiveRows.length,
type,
aggregate: "daily",
source: "rollup",
coverage_fallback: coverageFallbackFired,
},
});
return effectiveRows.map((r) => ({
type: r.type,
value: useSum ? (r.sumValue ?? r.mean * r.count) : r.mean,
measuredAt: r.bucketStart.toISOString(),
count: r.count,
minValue: useSum ? undefined : r.minValue,
maxValue: useSum ? undefined : r.maxValue,
}));
}
// Live `date_trunc` fallback — rollup empty for the window.
return readLiveDaily({ userId, type, from, to, cap, priorityJson });
}
/** Live `date_trunc('day', …)` aggregate — the rollup-empty fallback. */
async function readLiveDaily(opts: {
userId: string;
type: MeasurementType;
from: Date;
to: Date;
cap: number;
priorityJson: unknown;
}): Promise<DailySeriesRow[]> {
const { userId, type, from, to, cap, priorityJson } = opts;
const truncUnitLiteral = Prisma.raw(`'day'`);
const useSum = CUMULATIVE_HK_TYPES.has(type);
const aggregator = useSum
? Prisma.raw(`SUM(m."value")::double precision`)
: Prisma.raw(`AVG(m."value")::double precision`);
const rankRaw = Prisma.raw(
buildSourceRankCase(priorityJson, 'm."type"', 'm."source"'),
);
const buckets = await prisma.$queryRaw<
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
>`
WITH canon AS (
SELECT DISTINCT ON (m."type", date_trunc(${truncUnitLiteral}, m."measured_at"))
m."type" AS t,
date_trunc(${truncUnitLiteral}, m."measured_at") AS d,
m."source" AS canon
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
AND m."type" = ${type}::measurement_type
ORDER BY m."type", date_trunc(${truncUnitLiteral}, m."measured_at"), (${rankRaw}), m."source"
)
SELECT
m."type"::text AS type,
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
${aggregator} AS avg,
COUNT(*)::int AS cnt
FROM measurements m
JOIN canon c
ON c.t = m."type"
AND c.d = date_trunc(${truncUnitLiteral}, m."measured_at")
AND c.canon = m."source"
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
AND m."type" = ${type}::measurement_type
GROUP BY m."type", bucket_start
ORDER BY bucket_start ASC
LIMIT ${cap}
`;
annotate({
action: { name: "measurement.list" },
meta: { total: buckets.length, type, aggregate: "daily" },
});
return buckets.map((b) => ({
type: b.type,
value: Number(b.avg),
measuredAt: b.bucket_start.toISOString(),
count: Number(b.cnt),
}));
}
+68
View File
@@ -13,6 +13,7 @@ import {
measurementTypeEnum,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
@@ -392,7 +393,74 @@ const seriesResponse = z
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block.",
});
// ── Batched daily series (v1.18.6 dashboard fetch coalescing) ────────
//
// Returns every requested MeasurementType's rollup-backed daily series
// in one response so the dashboard chart row does a single round-trip
// instead of one per chart. Each row mirrors the daily-aggregate shape
// `GET /api/measurements?aggregate=daily&source=rollup` emits.
const seriesBatchRowSchema = z.object({
type: measurementTypeEnum,
value: z.number().describe("Daily mean (spot metrics) or SUM (cumulative)."),
measuredAt: z.iso
.datetime({ offset: true })
.describe("Day-bucket start (ISO-8601)."),
count: z
.number()
.int()
.nonnegative()
.optional()
.describe("Underlying raw-row count for the bucket."),
minValue: z
.number()
.nullable()
.optional()
.describe("Per-day minimum (spot metrics only; absent for cumulative)."),
maxValue: z
.number()
.nullable()
.optional()
.describe("Per-day maximum (spot metrics only; absent for cumulative)."),
});
const seriesBatchResponse = z
.object({
series: z
.record(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe("Per-type daily series, keyed by MeasurementType."),
})
.meta({
id: "MeasurementsSeriesBatchResponse",
description:
"Batched daily series — one rollup-backed series per requested type.",
});
export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/measurements/series-batch": {
get: {
tags: ["Measurements"],
summary: "Batched daily series (dashboard fetch coalescing)",
description:
"Returns the rollup-backed daily series for every requested MeasurementType in ONE response, so the dashboard chart row does a single round-trip instead of one self-fetch per chart. Each per-type series is byte-identical with `GET /api/measurements?aggregate=daily&source=rollup` for the same window. SLEEP_DURATION is not served here (it rides the per-night reconstruction at `/api/measurements/series?kind=sleep`). Auth via cookie or Bearer.",
requestParams: {
query: seriesBatchQuerySchema,
},
responses: {
"200": {
description: "Per-type daily series.",
content: {
"application/json": {
schema: dataEnvelope(
seriesBatchResponse,
"GetMeasurementsSeriesBatchResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/measurements/series": {
get: {
tags: ["Measurements"],
+40 -10
View File
@@ -85,17 +85,36 @@ export const measurementKeys = {
// (scaled) series doesn't bleed across charts that share the
// underlying raw window.
valueScale: number = 1,
// v1.18.6 — read-mode discriminator ("preloaded" vs "fetch"). The
// dashboard's batched-slice render and a self-fetch must not share a
// cache entry, otherwise a range-tab change (which drops batched
// coverage) could read a batched slice. Defaults to "" and is only
// appended when set, so every pre-v1.18.6 caller packs a
// byte-identical tuple and the existing cache layout is unchanged.
readMode: string = "",
) =>
[
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
] as const,
(readMode
? ([
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
readMode,
] as const)
: ([
"chart-data",
types,
valueMode,
bmiDivisor,
timezone,
fromIso,
toIso,
valueScale,
] as const)),
/**
* v1.8.5 — bounded recent-timestamp read powering the
@@ -106,4 +125,15 @@ export const measurementKeys = {
*/
measurementDiversity: (type: string) =>
["measurement-diversity", type] as const,
/**
* v1.18.6 — batched dashboard daily series
* (`GET /api/measurements/series-batch`). Shares the `chart-data`
* prefix so a fresh measurement evicts it alongside the per-chart
* caches (it lands in `measurementDependentKeys`). Keyed by the
* comma-joined type list + the ISO window so a changed visible-chart
* set or window re-fetches cleanly.
*/
chartSeriesBatch: (types: string, fromIso: string, toIso: string) =>
["chart-data", "series-batch", types, fromIso, toIso] as const,
};
+51
View File
@@ -0,0 +1,51 @@
/**
* v1.18.6 — query schema for `GET /api/measurements/series-batch`.
*
* Kept in its own file (not inline in the route) so the OpenAPI registry
* can import it and the wire contract stays single-source, mirroring the
* `listMeasurementsSchema` convention.
*/
import { z } from "zod/v4";
import { measurementTypeEnum } from "@/lib/validations/measurement";
/** Upper bound on the types per batch — the dashboard never asks for more. */
export const SERIES_BATCH_MAX_TYPES = 16;
export const seriesBatchQuerySchema = z
.object({
types: z
.string()
.min(1)
.transform((s) =>
s
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0),
)
.pipe(
z
.array(measurementTypeEnum)
.min(1)
.max(SERIES_BATCH_MAX_TYPES),
)
.describe(
"Comma-separated list of MeasurementType values (1.." +
`${SERIES_BATCH_MAX_TYPES}). Each is read as a daily series through ` +
"the rollup tier.",
),
from: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.describe("Window start (ISO-8601 instant)."),
to: z.iso
.datetime({ offset: true })
.transform((s) => new Date(s))
.describe("Window end (ISO-8601 instant)."),
})
.meta({
id: "MeasurementsSeriesBatchQuery",
description:
"Batched daily-series request — returns every requested type's rollup-backed daily series in one response.",
});
export type SeriesBatchInput = z.infer<typeof seriesBatchQuerySchema>;