From bd8055774953636118f4f212c59e80b5869b0b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 15:29:09 +0200 Subject: [PATCH 1/3] perf(dashboard): compute chart band/target math server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the BP/pulse/body-fat/weight band + target derivation out of the dashboard client component and into the snapshot DTO. The page recomputed every band from the user profile on each render via the analytics band helpers; the snapshot builder now resolves the same numeric structures once server-side under a new targetBands block, calling the identical helpers. Emit only the i18n-free numbers — the label-bearing BP target zones stay client-assembled from the server numbers. Add a parity test asserting buildTargetBands matches the prior client derivation for a male, a female, and a no-profile user. --- e2e/utils/mock-dashboard-snapshot.ts | 73 +++++++++ .../hero/__tests__/dashboard-hero.test.tsx | 6 + .../dashboard/__tests__/target-bands.test.ts | 142 +++++++++++++++++ src/lib/dashboard/__tests__/verdict.test.ts | 6 + src/lib/dashboard/snapshot.ts | 146 +++++++++++++++++- 5 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/lib/dashboard/__tests__/target-bands.test.ts diff --git a/e2e/utils/mock-dashboard-snapshot.ts b/e2e/utils/mock-dashboard-snapshot.ts index cdc207dd8..e1f626569 100644 --- a/e2e/utils/mock-dashboard-snapshot.ts +++ b/e2e/utils/mock-dashboard-snapshot.ts @@ -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, diff --git a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx index 451b8af0a..85c65068c 100644 --- a/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx +++ b/src/components/dashboard/hero/__tests__/dashboard-hero.test.tsx @@ -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: {}, diff --git a/src/lib/dashboard/__tests__/target-bands.test.ts b/src/lib/dashboard/__tests__/target-bands.test.ts new file mode 100644 index 000000000..56aa6d1ab --- /dev/null +++ b/src/lib/dashboard/__tests__/target-bands.test.ts @@ -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); + }); +}); diff --git a/src/lib/dashboard/__tests__/verdict.test.ts b/src/lib/dashboard/__tests__/verdict.test.ts index c5f704c19..3aec74909 100644 --- a/src/lib/dashboard/__tests__/verdict.test.ts +++ b/src/lib/dashboard/__tests__/verdict.test.ts @@ -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: {}, diff --git a/src/lib/dashboard/snapshot.ts b/src/lib/dashboard/snapshot.ts index 8452a9dcd..969e4e9fa 100644 --- a/src/lib/dashboard/snapshot.ts +++ b/src/lib/dashboard/snapshot.ts @@ -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; + /** + * 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; @@ -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), From 143aefc709501d10df3dc632325b585b4c739c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 15:29:20 +0200 Subject: [PATCH 2/3] feat(api): batched rollup-backed series endpoint to coalesce dashboard fetches Add GET /api/measurements/series-batch returning every requested MeasurementType's daily series in one response, so the dashboard chart row stops issuing one self-fetch per chart. Extract the rollup-daily read (per-source collapse, coverage probe + inline fold, live-SQL fallback) into a shared readDailySeries reader so the single-type route and the batch route read through the same path with no SQL duplication; the single route now delegates to it. Cap the batch's per-type reads with p-limit to protect the shared pool. Sleep stays on its per-night /series adapter and is not batched. HealthChart gains an optional preloadedSeries prop: when the dashboard hands it a covered slice for the daily window it folds those rows instead of self-fetching, keyed so a range-tab change re-fetches cleanly. Non- dashboard mounts are unchanged (empty discriminator keeps the cache tuple byte-identical). Register the endpoint in the OpenAPI table and regen the spec; add unit + parity coverage for the batch route. --- docs/api/openapi.yaml | 320 ++++++++++++++++++ src/app/api/measurements/route.ts | 207 ++--------- .../series-batch/__tests__/route.test.ts | 120 +++++++ .../api/measurements/series-batch/route.ts | 99 ++++++ src/components/charts/health-chart.tsx | 83 ++++- src/lib/measurements/daily-series-read.ts | 226 +++++++++++++ src/lib/openapi/routes/measurements.ts | 68 ++++ src/lib/query-keys/measurements.ts | 50 ++- src/lib/validations/series-batch.ts | 51 +++ 9 files changed, 1016 insertions(+), 208 deletions(-) create mode 100644 src/app/api/measurements/series-batch/__tests__/route.test.ts create mode 100644 src/app/api/measurements/series-batch/route.ts create mode 100644 src/lib/measurements/daily-series-read.ts create mode 100644 src/lib/validations/series-batch.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2490f1275..a66cc44c5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 2345089d2..77c23833a 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -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>` - 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) { diff --git a/src/app/api/measurements/series-batch/__tests__/route.test.ts b/src/app/api/measurements/series-batch/__tests__/route.test.ts new file mode 100644 index 000000000..dc6e31c02 --- /dev/null +++ b/src/app/api/measurements/series-batch/__tests__/route.test.ts @@ -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(); + 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); + }); +}); diff --git a/src/app/api/measurements/series-batch/route.ts b/src/app/api/measurements/series-batch/route.ts new file mode 100644 index 000000000..8f685926c --- /dev/null +++ b/src/app/api/measurements/series-batch/route.ts @@ -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 = {}; + 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 }; diff --git a/src/components/charts/health-chart.tsx b/src/components/charts/health-chart.tsx index aef13400e..c26b57a24 100644 --- a/src/components/charts/health-chart.tsx +++ b/src/components/charts/health-chart.tsx @@ -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; } 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 diff --git a/src/lib/measurements/daily-series-read.ts b/src/lib/measurements/daily-series-read.ts new file mode 100644 index 000000000..64bb3134e --- /dev/null +++ b/src/lib/measurements/daily-series-read.ts @@ -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 { + 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>` + 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 { + 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), + })); +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 7a3b082ea..546934e1e 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -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 = { + "/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"], diff --git a/src/lib/query-keys/measurements.ts b/src/lib/query-keys/measurements.ts index 42c326302..4a3d12cb5 100644 --- a/src/lib/query-keys/measurements.ts +++ b/src/lib/query-keys/measurements.ts @@ -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, }; diff --git a/src/lib/validations/series-batch.ts b/src/lib/validations/series-batch.ts new file mode 100644 index 000000000..1b97d52b8 --- /dev/null +++ b/src/lib/validations/series-batch.ts @@ -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; From 437e501536228d7116741aa9b217f89b0ce2d05f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 18 Jun 2026 15:29:29 +0200 Subject: [PATCH 3/3] refactor(dashboard): thin page orchestrator + consume server bands and batched series Extract the pure first-paint / skeleton-reservation gates (resolveDashboardFirstPaintGate, resolveConfiguredTileCount, resolveChartRowPlaceholderCount + their widget-id sets) into components/dashboard/dashboard-gates; page.tsx re-exports them so the existing unit-test imports stay valid. Wire the page to read the server-computed targetBands when snapshot mode is on (legacy client compute kept for the snapshot-off fallback) and to issue one series-batch fetch for the visible non-sleep charts, threading each chart its slice via preloadedSeries. --- src/app/page.tsx | 275 ++++++++++---------- src/components/dashboard/dashboard-gates.ts | 119 +++++++++ 2 files changed, 260 insertions(+), 134 deletions(-) create mode 100644 src/components/dashboard/dashboard-gates.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index c58eca290..c7a111143 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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([ - "weight", - "bp", - "pulse", - "bodyFat", - "mood", - "sleep", - "steps", - "glucose", - "bpInTarget", - "vo2Max", -]); - -/** - * v1.16.8 — silhouette count for the tile-strip skeleton: one card per - * tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia, - * see the render block below) so it counts double; `glucose` can fan - * out to one tile per logged context, but the contexts are unknown - * until the snapshot lands, so it reserves one card (the sane floor). - * Pure + exported for direct unit coverage. - */ -export function resolveConfiguredTileCount(layout: DashboardLayout): number { - let count = 0; - for (const widget of layout.widgets) { - if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; - if (!(widget.tileVisible ?? widget.visible)) continue; - count += widget.id === "bp" ? 2 : 1; - } - return count; -} - -/** - * v1.16.8 — widget ids with a chart-row surface on the web dashboard, - * mirrored from the `charts[]` entries below. The achievements + - * recent-workouts cards stay out: they self-skeleton, carry no - * chart-shaped footprint, and gate on `layoutResolved`. - */ -const CHART_CAPABLE_WIDGET_IDS = new Set([ - "weight", - "bp", - "pulse", - "bodyFat", - "mood", - "sleep", - "steps", - "medications", - // v1.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 `` 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(); + 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() { markChartReady("weight-chart")} + preloadedSeries={preloadedSeries} chartKey="weight" types={["WEIGHT"]} title={t("dashboard.weight")} @@ -1242,6 +1244,7 @@ export default function DashboardPage() { markChartReady("bmi-chart")} + preloadedSeries={preloadedSeries} chartKey="bmi" types={["WEIGHT"]} title={t("targets.bmi")} @@ -1271,6 +1274,7 @@ export default function DashboardPage() { 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() { 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() { markChartReady("bodyFat-chart")} + preloadedSeries={preloadedSeries} chartKey="bodyFat" types={["BODY_FAT"]} title={t("dashboard.bodyFat")} @@ -1380,6 +1386,7 @@ export default function DashboardPage() { markChartReady("steps-chart")} + preloadedSeries={preloadedSeries} chartKey="steps" types={["ACTIVITY_STEPS"]} title={t("dashboard.steps") ?? "Steps"} diff --git a/src/components/dashboard/dashboard-gates.ts b/src/components/dashboard/dashboard-gates.ts new file mode 100644 index 000000000..3b74ff113 --- /dev/null +++ b/src/components/dashboard/dashboard-gates.ts @@ -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([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "glucose", + "bpInTarget", + "vo2Max", +]); + +/** + * v1.16.8 — silhouette count for the tile-strip skeleton: one card per + * tile-capable, tile-visible widget. `bp` paints TWO tiles (sys + dia, + * see the render block below) so it counts double; `glucose` can fan + * out to one tile per logged context, but the contexts are unknown + * until the snapshot lands, so it reserves one card (the sane floor). + * Pure + exported for direct unit coverage. + */ +export function resolveConfiguredTileCount(layout: DashboardLayout): number { + let count = 0; + for (const widget of layout.widgets) { + if (!TILE_CAPABLE_WIDGET_IDS.has(widget.id)) continue; + if (!(widget.tileVisible ?? widget.visible)) continue; + count += widget.id === "bp" ? 2 : 1; + } + return count; +} + +/** + * v1.16.8 — widget ids with a chart-row surface on the web dashboard, + * mirrored from the `charts[]` entries below. The achievements + + * recent-workouts cards stay out: they self-skeleton, carry no + * chart-shaped footprint, and gate on `layoutResolved`. + */ +const CHART_CAPABLE_WIDGET_IDS = new Set([ + "weight", + "bp", + "pulse", + "bodyFat", + "mood", + "sleep", + "steps", + "medications", + // v1.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; +}