mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427b5f0b7c | ||
|
|
d4f78811d9 | ||
|
|
6af0e36c88 |
No files matched your search
@@ -2,6 +2,15 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.30.8] — 2026-07-18
|
||||
|
||||
Hardening for the new heart-rate upload validation.
|
||||
|
||||
- **A malformed bucket timestamp is rejected, not silently misfiled.** A bucket whose time rolled over (a 24:00, an impossible day) is now refused rather than stored under a non-standard key, where it would have failed to merge with a re-post and could land on the wrong day.
|
||||
- **The per-window low/high is sanity-checked before it counts.** A window's low and high are kept only when they are in a plausible range and sit either side of the average; a glitched reading no longer skews the day's high/low band. The average is always kept.
|
||||
|
||||
No migration. No breaking changes.
|
||||
|
||||
## [1.30.7] — 2026-07-18
|
||||
|
||||
Groundwork for a tidier heart-rate history.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.30.7
|
||||
version: 1.30.8
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.30.7",
|
||||
"version": "1.30.8",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.30.7";
|
||||
/* @sw-version-fallback */ "v1.30.8";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* v1.19.0 (iOS #34) — go-forward aggregated heart-rate wire contract on
|
||||
* `POST /api/measurements/batch`.
|
||||
* v1.30.7/v1.30.8 (iOS #34) — go-forward aggregated 10-min heart-rate wire
|
||||
* contract on `POST /api/measurements/batch`.
|
||||
*
|
||||
* Asserts:
|
||||
* - a fresh 10-min HR bucket inserts;
|
||||
* - a fresh 10-min HR bucket inserts, with its per-bucket min/max spread;
|
||||
* - a re-post of the same bucket OVERWRITES (status `updated`, not a
|
||||
* duplicate row);
|
||||
* - a malformed aggregated HR bucket externalId is `skipped`;
|
||||
* - a malformed / off-grid aggregated HR bucket externalId is `skipped`;
|
||||
* - v1.30.8: an out-of-range or mis-ordered spread is dropped to null while
|
||||
* the trustworthy average survives;
|
||||
* - the per-sample uuid HR path is unaffected (immutable duplicate);
|
||||
* - the existing per-day step `stats:` overwrite path is unaffected.
|
||||
*/
|
||||
@@ -172,6 +174,39 @@ describe("POST /api/measurements/batch — aggregated HR wire contract (iOS #34)
|
||||
expect(rows[0].valueMax).toBe(96);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// out-of-plausible-range spread (a sensor glitch / spurious discreteMax)
|
||||
{ valueMin: 58, valueMax: 99999 },
|
||||
{ valueMin: -9999, valueMax: 96 },
|
||||
// mis-ordered: valueMin above the average, or valueMax below it
|
||||
{ valueMin: 80, valueMax: 96 },
|
||||
{ valueMin: 58, valueMax: 70 },
|
||||
])(
|
||||
"v1.30.8 — drops an out-of-range or mis-ordered spread to null but keeps the average %o",
|
||||
async (spread) => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ entries: [hrBucketEntry(HR_BUCKET_ID, 72, spread)] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const createArg = vi.mocked(prisma.measurement.createMany).mock
|
||||
.calls[0][0];
|
||||
const rows = (
|
||||
createArg as {
|
||||
data: {
|
||||
value: number;
|
||||
valueMin: number | null;
|
||||
valueMax: number | null;
|
||||
}[];
|
||||
}
|
||||
).data;
|
||||
// The trustworthy average survives; the invalid spread is dropped.
|
||||
expect(rows[0].value).toBe(72);
|
||||
expect(rows[0].valueMin).toBeNull();
|
||||
expect(rows[0].valueMax).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("overwrites min/max alongside the average on a re-post", async () => {
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ type: "PULSE", source: "APPLE_HEALTH", externalId: HR_BUCKET_ID },
|
||||
|
||||
@@ -318,15 +318,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// 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.
|
||||
//
|
||||
// v1.30.8 — validate the spread the same way `value` is guarded (above):
|
||||
// it now drives the DAY min/max band + the intraday envelope, so an
|
||||
// out-of-range or mis-ordered spread (a spurious `discreteMin` of 0, a
|
||||
// sensor glitch) would poison the aggregate exactly like a bogus `value`
|
||||
// would. Persist the spread ONLY when both extremes are present, in range,
|
||||
// and correctly ordered around `value`; otherwise drop to null and keep the
|
||||
// trustworthy average. Partial or invalid spread → avg-only, still valid.
|
||||
const isHrBucket = isAggregatedBucketExternalId(entry.externalId);
|
||||
const valueMin =
|
||||
isHrBucket && typeof mapped.valueMin === "number"
|
||||
? mapped.valueMin
|
||||
: null;
|
||||
const valueMax =
|
||||
isHrBucket && typeof mapped.valueMax === "number"
|
||||
? mapped.valueMax
|
||||
: null;
|
||||
const spreadValid =
|
||||
isHrBucket &&
|
||||
typeof mapped.valueMin === "number" &&
|
||||
typeof mapped.valueMax === "number" &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMin) === null &&
|
||||
validateMeasurementRange(mapped.type, mapped.valueMax) === null &&
|
||||
mapped.valueMin <= mapped.value &&
|
||||
mapped.value <= mapped.valueMax;
|
||||
const valueMin = spreadValid ? mapped.valueMin : null;
|
||||
const valueMax = spreadValid ? mapped.valueMax : null;
|
||||
|
||||
prepared.push({
|
||||
index,
|
||||
|
||||
@@ -90,6 +90,19 @@ describe("isAggregatedBucketExternalId", () => {
|
||||
])("returns false for a non-bucket externalId %s", (id) => {
|
||||
expect(isAggregatedBucketExternalId(id as string | null)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
// v1.30.8 — these PARSE via V8's silent rollover but are NOT canonical, so
|
||||
// they'd key a row the canonical (toISOString) client never emits → an
|
||||
// un-mergeable duplicate + wrong-day placement. Must be rejected.
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-18T24:00:00.000Z", // → 07-19T00
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-04-31T14:30:00.000Z", // → 05-01
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-02-29T14:30:00.000Z", // non-leap → 03-01
|
||||
"stats:HKQuantityTypeIdentifierHeartRate:2026-12-31T24:00:00.000Z", // → 2027-01-01
|
||||
])("rejects a non-canonical rollover instant %s", (id) => {
|
||||
expect(isAggregatedBucketExternalId(id)).toBe(false);
|
||||
expect(parseAggregatedBucketStart(id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetsAggregatedBucket", () => {
|
||||
|
||||
@@ -897,7 +897,14 @@ export function isAggregatedBucketExternalId(
|
||||
const suffix = externalId.slice(prefix.length);
|
||||
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return false;
|
||||
const parsed = new Date(suffix);
|
||||
return !Number.isNaN(parsed.getTime());
|
||||
if (Number.isNaN(parsed.getTime())) return false;
|
||||
// Reject a non-canonical instant that PARSES via V8's silent rollover
|
||||
// (`T24:00`, `2026-04-31`, a non-leap `02-29`) — it would key a row under
|
||||
// a literal string the canonical client (`heartRateBucketExternalId`, which
|
||||
// mints via `toISOString()`) never emits, so the two would never collapse
|
||||
// on the overwrite key. Round-trip equality pins the suffix to the one
|
||||
// canonical form.
|
||||
return parsed.toISOString() === suffix;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -937,7 +944,10 @@ export function parseAggregatedBucketStart(
|
||||
const suffix = externalId.slice(prefix.length);
|
||||
if (!AGGREGATED_BUCKET_SUFFIX_RE.test(suffix)) return null;
|
||||
const parsed = new Date(suffix);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
// Canonical-form gate (mirrors `isAggregatedBucketExternalId`): a rolled-over
|
||||
// instant would place the bucket on the wrong local day.
|
||||
return parsed.toISOString() === suffix ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user