mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
def9ae6f39
commit
394f0b0795
16 files changed
+1010
-265
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user