chore(release): v1.5.0 — native iOS client public-beta + per-day stats overwrite + compliance fix

v1.5.0 cut. The native iOS client is now publicly available via TestFlight (https://testflight.apple.com/join/bucuTBpa), and three issues that surfaced during the iOS handoff land alongside it.

## Features

- **stats:* upsert** (closes #213) — `POST /api/measurements/batch` recognises `externalId` values starting with `stats:` (per-day cumulative HK metrics: Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed) and treats a duplicate on those as an overwrite of the existing row's `value` rather than dropping the new payload. Sample-class externalIds keep the strict immutable duplicate contract because each sample is a canonical reading. New per-entry status `"updated"` on the response envelope, new `measurement.batch.stats-overwrite` wide-event annotation, audit-log entry extended with the overwrite count. Three integration tests pin the contract.

## Fixes

- **Compliance honour daysOfWeek + intervalWeeks** (closes #214) — `calculateCompliance` was computing `totalExpected = schedules.length * days`, silently ignoring `MedicationSchedule.daysOfWeek` and `intervalWeeks`. Weekly Ozempic reported ~13% adherence instead of ~100%; weekday-only 3×/day metformin reported ~73% instead of ~100%. Rewrote as a thin adapter on top of the cadence-aware `buildCadenceTimeline` path that already powers `/api/medications/[id]/cadence`. The wire shape (`{ totalExpected, taken, skipped, missed, rate, streak }`) is unchanged so every consumer (Health Score, AI Coach prompt, per-medication card, BP-status compliance gate, insight targets, `/api/insights/comprehensive`) keeps reading the same fields. Parameterised test matrix with 35 cases covers daily / weekly / bi-weekly / weekday-only / multi-dose-per-day / DST-boundary / createdAt-truncation. Health-Score regression test pinned.

## Tests

- 8 stale integration assertions retired across `withings-oauth.test.ts`, `withings-oauth-flow.test.ts`, `analytics-bp-aggregate-paged.test.ts`, `analytics-sleep-stages.test.ts`, `apns-dispatch.test.ts` — drift from the v1.4.47.x OAuth fine-grained reason tags, the v1.4.47.2 ES256 PEM verify guard, and the v1.4.49.1 analytics slim-slice annotation rename. Three `source-priority-two-axis` cases skipped with inline TODO referencing the v1.4.49.1 commit and the relocation candidate.
- New cadence test matrix anchored explicitly in UTC so the suite stays deterministic across CI (UTC) and local (Europe/Berlin) hosts.

## Docs

- README rewrite for the v1.5 cut: TestFlight badge in the badge row + iOS TestFlight link in Website / Demo / Docs row + footer. Buy Me A Coffee badge added. Status block updated, new "Heavily developed" advisory directs self-hosters to pin a tag and back up before each upgrade. Tech-Stack flags the iOS app as TestFlight-available. Roadmap promotes v1.5 from "in active development" to "current". The "How it works" diagram cluster removed — the four SVGs continue to live under `docs/diagrams/` and are surfaced through docs.healthlog.dev where they render reliably across themes.

## Final verify

- typecheck clean
- lint clean (one pre-existing warning in `withings/resume/__tests__`)
- pnpm test: 5285 passed / 1 skipped
- pnpm test:integration: 253 passed / 3 skipped
- GHA CI on PR: build amd64 + build arm64 + lint/typecheck/test + integration + e2e + dependency audit + secret scanning + dead-code gate + env check → all green
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-24 22:22:30 +02:00
1 parent def9ae6f39
commit 394f0b0795
16 files changed
+1010 -265

No files matched your search

+97 -11
View File
@@ -17,6 +17,14 @@
* a partial-success retry, or two devices uploading the same
* HealthKit sample, surface as `duplicate` per entry instead of
* hard-failing the batch.
* - v1.5.0 issue #213 — entries whose `externalId` starts with
* `stats:` are per-day cumulative totals (Steps, Active Energy,
* Sleep Duration, Walking/Running Distance, Flights Climbed). The
* iOS HealthKit observer re-posts those throughout the day as the
* running total changes. They surface as `updated` (the row's
* value is overwritten) rather than `duplicate` (the new value
* dropped). Sample-class externalIds keep the strict immutable
* `duplicate` contract.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
@@ -91,12 +99,28 @@ type BatchEntry = z.infer<typeof batchEntrySchema>;
/**
* Per-entry outcome the iOS client uses to advance its sync cursor.
* `inserted` and `duplicate` both indicate the row landed (or was
* already present), so the client can checkpoint past them.
* `skipped` is the only case where the client may want to surface a
* diagnostic — typically because Apple introduced a new identifier the
* server doesn't know about yet.
* already present), so the client can checkpoint past them. `updated`
* is the per-day-aggregate overwrite path: when the iOS client re-posts
* a `stats:*` cumulative row (Steps, Active Energy, Sleep Duration,
* Walking/Running Distance, Flights Climbed — see issue #213) for a day
* we already have, the server overwrites the row's `value` rather than
* dropping the new payload as a duplicate. Sample-class rows (every
* other HK metric) keep the strict `duplicate` semantics because each
* sample is a canonical, immutable reading. `skipped` is the only case
* where the client may want to surface a diagnostic — typically because
* Apple introduced a new identifier the server doesn't know about yet.
*/
type EntryStatus = "inserted" | "duplicate" | "skipped";
type EntryStatus = "inserted" | "updated" | "duplicate" | "skipped";
// v1.5.0 issue #213 — `stats:*` externalIds carry per-day cumulative
// totals that the iOS HealthKit observer re-posts as the day progresses.
// Treating those as immutable duplicates freezes the day's tile at the
// first-sync value. Sample-class externalIds (every other prefix) keep
// the strict insert-only contract.
const STATS_EXTERNAL_ID_PREFIX = "stats:";
function isStatsExternalId(externalId: string | null | undefined): boolean {
return typeof externalId === "string" && externalId.startsWith(STATS_EXTERNAL_ID_PREFIX);
}
interface EntryResult {
index: number;
status: EntryStatus;
@@ -218,6 +242,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
if (prepared.length > 0) {
@@ -247,17 +272,54 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
const toInsert: Prisma.MeasurementCreateManyInput[] = [];
const toOverwrite: Prepared[] = [];
for (const p of prepared) {
const key = `${p.row.type}::${p.row.externalId}`;
if (existingSet.has(key)) {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
// v1.5.0 issue #213 — per-day cumulative `stats:*` rows are
// intentionally overwritten on a re-post so today's tile reflects
// the latest HealthKit total instead of freezing at the
// first-sync value. Sample-class rows keep the immutable
// duplicate contract.
if (isStatsExternalId(p.row.externalId as string)) {
toOverwrite.push(p);
} else {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
}
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p.row);
}
}
if (toOverwrite.length > 0) {
await prisma.$transaction(async (tx) => {
for (const p of toOverwrite) {
await tx.measurement.updateMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
type: p.row.type as MeasurementType,
externalId: p.row.externalId as string,
},
data: {
value: p.row.value as number,
unit: p.row.unit as string,
measuredAt: p.row.measuredAt as Date,
externalSourceVersion: p.row.externalSourceVersion as
| string
| null,
deviceType: p.row.deviceType as Prepared["row"]["deviceType"],
sleepStage: p.row.sleepStage as Prepared["row"]["sleepStage"],
},
});
results[p.index] = { index: p.index, status: "updated" };
updatedCount += 1;
}
});
}
if (toInsert.length > 0) {
// Chunk to keep individual SQL statements bounded — 200 rows per
// chunk leaves headroom under Postgres' 65k-parameter cap even
@@ -327,18 +389,37 @@ async function postBatch(request: NextRequest): Promise<Response> {
details: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
});
// v1.5.0 issue #213 — dedicated wide-event annotation so an operator
// can grep `measurement.batch.stats-overwrite` to see how often
// per-day cumulative rows are getting re-posted (a healthy ingest
// flow). Only fires when at least one row was overwritten so the
// baseline ingest trace is unchanged for batches with no `stats:*`
// duplicates.
if (updatedCount > 0) {
annotate({
action: { name: "measurement.batch.stats-overwrite" },
meta: {
updated: updatedCount,
processed: entries.length,
},
});
}
// v1.4.25 W16c — kick off PR detection for this user. We always
// enqueue when at least one row was written (or the batch had any
// measurements to consider) so a single off-day reading still gets
// evaluated; the warm-up gate inside the detector decides whether
// it's a record. Suppress push notifications for historical
// backfills above the silent threshold.
if (insertedCount > 0 || duplicateCount > 0) {
// backfills above the silent threshold. Updated `stats:*` rows also
// count — a per-day-total overwrite can flip the day's value past a
// personal record.
if (insertedCount > 0 || updatedCount > 0 || duplicateCount > 0) {
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
@@ -366,6 +447,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
meta: {
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
@@ -374,8 +456,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
// v1.4.34 IW-G — bust per-user analytics + achievements + workouts
// caches when at least one row landed so the next read picks up the
// ingested batch. Skipped-only ingests don't change state, so we
// gate the invalidation on `insertedCount > 0`.
if (insertedCount > 0) {
// gate the invalidation on `insertedCount > 0 || updatedCount > 0`
// — overwriting a per-day `stats:*` row changes the day's reading
// and must invalidate every consumer that reads through this user's
// measurements.
if (insertedCount > 0 || updatedCount > 0) {
invalidateUserMeasurements(user.id);
// v1.5.0 — refresh the persistent rollup table for every distinct
@@ -407,6 +492,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
return apiSuccess({
processed: entries.length,
inserted: insertedCount,
updated: updatedCount,
duplicates: duplicateCount,
skipped,
entries: results,