mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(measurements): 10-minute aggregated heart-rate upload contract
Refine the go-forward aggregated HR wire contract (iOS #34) from the never- adopted hourly bucket to a 10-minute UTC bucket, so one contract serves both the measurements-list de-explosion and the intraday-pulse 10-min curve. - Batch route accepts stats:HKQuantityTypeIdentifierHeartRate:<10-min ISO-Z> with avg + min/max, overwrite semantics, malformed-suffix rejection; the validator is identifier-generic (allowlist) so respiratoryRate/SpO2 can join later without a grammar change. - The intraday reader classifies a day's PULSE rows three ways by externalId shape (raw / uploaded 10-min bucket / server-folded hourly), builds the 10-min curve directly from uploaded buckets with the min/max envelope and tension detection enabled, and overlays buckets over raw on the one cutover-straddling day. - DAY rollup min/max read COALESCE(value_min, value) so a bucket-native day's band reflects the true intra-bucket extremes. No schema change (value_min/value_max exist since v1.19.2). Reconciliation is go-forward cutover; the nightly retention fold handles the raw backlog.
This commit is contained in:
1 parent
389bdb5d5d
commit
5fa18a3754
10 files changed
+531
-206
No files matched your search
+12
-12
@@ -3,10 +3,10 @@
|
||||
* `POST /api/measurements/batch`.
|
||||
*
|
||||
* Asserts:
|
||||
* - a fresh hourly HR bucket inserts;
|
||||
* - a re-post of the same hour OVERWRITES (status `updated`, not a
|
||||
* - a fresh 10-min HR bucket inserts;
|
||||
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
|
||||
* duplicate row);
|
||||
* - a malformed hourly-HR bucket externalId is `skipped`;
|
||||
* - a malformed aggregated HR bucket externalId is `skipped`;
|
||||
* - the per-sample uuid HR path is unaffected (immutable duplicate);
|
||||
* - the existing per-day step `stats:` overwrite path is unaffected.
|
||||
*/
|
||||
@@ -131,8 +131,8 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 1 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh hourly HR bucket as a PULSE row", async () => {
|
||||
describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)", () => {
|
||||
it("inserts a fresh 10-min HR bucket as a PULSE row", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72)] }),
|
||||
@@ -143,7 +143,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.updated).toBe(0);
|
||||
expect(data.entries[0].status).toBe("inserted");
|
||||
|
||||
// Stored as a PULSE row carrying the hourly average value.
|
||||
// Stored as a PULSE row carrying the 10-min average value.
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock.calls[0][0];
|
||||
const rows = (createArg as { data: { type: string; value: number }[] })
|
||||
.data;
|
||||
@@ -151,7 +151,7 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].value).toBe(72);
|
||||
});
|
||||
|
||||
it("persists per-bucket min/max on a fresh hourly HR bucket (iOS #34 ext)", async () => {
|
||||
it("persists per-bucket min/max on a fresh 10-min HR bucket (iOS #34 ext)", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
@@ -214,8 +214,8 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
});
|
||||
|
||||
it("overwrites the same hour bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-hour post.
|
||||
it("overwrites the same bucket on a re-post (updated, not duplicate)", async () => {
|
||||
// The bucket already exists from an earlier within-bucket post.
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{
|
||||
type: "PULSE",
|
||||
@@ -234,19 +234,19 @@ describe("POST /api/measurements/batch — hourly HR wire contract (iOS #34)", (
|
||||
expect(data.duplicates).toBe(0);
|
||||
expect(data.entries[0].status).toBe("updated");
|
||||
|
||||
// The overwrite went through updateMany with the new hourly average.
|
||||
// The overwrite went through updateMany with the new 10-min average.
|
||||
expect(prisma.measurement.updateMany).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.measurement.updateMany).mock.calls[0][0];
|
||||
expect((updateArg as { data: { value: number } }).data.value).toBe(78);
|
||||
expect(prisma.measurement.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips a malformed hourly HR bucket externalId", async () => {
|
||||
it("skips a malformed aggregated HR bucket externalId", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
hrBucketEntry(
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:30:00.000Z",
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-06-21T14:35:00.000Z",
|
||||
70,
|
||||
),
|
||||
],
|
||||
@@ -50,8 +50,8 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import {
|
||||
mapAppleHealthEntry,
|
||||
isHourlyHeartRateStatsExternalId,
|
||||
targetsHourlyHeartRateBucket,
|
||||
isAggregatedBucketExternalId,
|
||||
targetsAggregatedBucket,
|
||||
} from "@/lib/measurements/apple-health-mapping";
|
||||
import {
|
||||
MEASURED_AT_TOLERANCE_MS,
|
||||
@@ -292,19 +292,18 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<hour>` row
|
||||
// is the hourly-average PULSE bucket; it rides the generic `stats:`
|
||||
// overwrite path below so a within-hour re-post (the running mean
|
||||
// shifts) replaces the value instead of duplicating. Reject a row
|
||||
// that targets the HR-bucket prefix but carries a malformed hour
|
||||
// suffix — a garbage suffix would mint an un-overwriteable row that
|
||||
// the next hour's re-post can't collapse onto. A well-formed bucket
|
||||
// (and every non-HR `stats:` / per-sample `uuid` externalId) passes
|
||||
// through unchanged.
|
||||
// v1.30.7 (iOS #34) — go-forward aggregated heart-rate wire
|
||||
// contract. A `stats:HKQuantityTypeIdentifierHeartRate:<10-min-ISO-Z>`
|
||||
// row is the 10-minute-average PULSE bucket; it rides the generic
|
||||
// `stats:` overwrite path below so a within-bucket re-post (the running
|
||||
// mean shifts) replaces the value instead of duplicating. Reject a row
|
||||
// that targets a bucket prefix but carries a malformed suffix — a
|
||||
// garbage suffix would mint an un-overwriteable row the next re-post
|
||||
// can't collapse onto. A well-formed bucket (and every non-bucket
|
||||
// `stats:` / per-sample `uuid` externalId) passes through unchanged.
|
||||
if (
|
||||
targetsHourlyHeartRateBucket(entry.externalId) &&
|
||||
!isHourlyHeartRateStatsExternalId(entry.externalId)
|
||||
targetsAggregatedBucket(entry.externalId) &&
|
||||
!isAggregatedBucketExternalId(entry.externalId)
|
||||
) {
|
||||
results[index] = {
|
||||
index,
|
||||
@@ -314,12 +313,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// v1.19.2 (iOS #34 extension) — the per-bucket MIN / MAX are persisted
|
||||
// ONLY on a well-formed hourly HR bucket row. A per-sample reading, a
|
||||
// v1.30.7 (iOS #34) — the per-bucket MIN / MAX are persisted ONLY on a
|
||||
// well-formed 10-min aggregated bucket row. A per-sample reading, a
|
||||
// per-day cumulative `stats:` total, or a manual entry never carries a
|
||||
// spread, so we pin them to null there even if a client mis-sends the
|
||||
// fields — `value` stays the single source of truth for those rows.
|
||||
const isHrBucket = isHourlyHeartRateStatsExternalId(entry.externalId);
|
||||
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
|
||||
const valueMin =
|
||||
isHrBucket && typeof mapped.valueMin === "number"
|
||||
? mapped.valueMin
|
||||
|
||||
Reference in new issue
Block a user