mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
perf(dashboard): batched rollup series endpoint + server-side target bands + gate-helper split
This commit is contained in:
commit
82c6947633
16 files changed
+1648
-343
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user