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;