mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(api): lock the daily-stats externalId shape for cumulative HealthKit types
R-A Option A handoff prep. Adds dailyStatsExternalId() helper alongside the v1.4.29 CUMULATIVE_HK_TYPES set; the helper mints "stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>" so iOS can UPSERT one row per day per cumulative type via the existing batch endpoint, the unique index collapses re-syncs idempotently, and a future PATCH-on- divergence call updates a late-watch-sync revision without inserting a second row. Handoff docs (06 + 08) lock the shape for the iOS client. Cumulative-type set unchanged from v1.4.29; spot metrics keep the per-sample uuid externalId. Server tolerates both shapes during the cutover window — the drain script (next commit) collapses pre-existing per-sample rows post-cutover. Tests cover the helper round-trip, the cumulative-type identifier wall, and the iOS-trusted date-string passthrough.
This commit is contained in:
1 parent
1a2447295d
commit
6e6bc61800
4 files changed
+182
-1
No files matched your search
@@ -2,7 +2,9 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
APPLE_HEALTH_SLEEP_STAGE_MAP,
|
||||
APPLE_HEALTH_TYPE_MAP,
|
||||
CUMULATIVE_HK_TYPES,
|
||||
HK_QUANTITY_TYPE_DEFERRED,
|
||||
dailyStatsExternalId,
|
||||
mapAppleHealthEntry,
|
||||
} from "../apple-health-mapping";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
@@ -343,3 +345,55 @@ describe("mapAppleHealthEntry", () => {
|
||||
expect(out).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dailyStatsExternalId (v1.4.30 — R-A Option A handoff lock)", () => {
|
||||
it("produces the canonical stats:<type>:<date> shape for stepCount", () => {
|
||||
expect(
|
||||
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", "2026-05-16"),
|
||||
).toBe("stats:HKQuantityTypeIdentifierStepCount:2026-05-16");
|
||||
});
|
||||
|
||||
it("produces the same shape for every cumulative-type identifier", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["HKQuantityTypeIdentifierStepCount", "2026-01-01"],
|
||||
["HKQuantityTypeIdentifierActiveEnergyBurned", "2026-02-29"],
|
||||
["HKQuantityTypeIdentifierFlightsClimbed", "2025-12-31"],
|
||||
["HKQuantityTypeIdentifierDistanceWalkingRunning", "2024-02-29"],
|
||||
["HKQuantityTypeIdentifierTimeInDaylight", "2026-05-16"],
|
||||
];
|
||||
for (const [id, day] of cases) {
|
||||
expect(dailyStatsExternalId(id, day)).toBe(`stats:${id}:${day}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the date string as-is — iOS owns the format", () => {
|
||||
// Per R-A §5, iOS generates the date string from the user's IANA
|
||||
// timezone via DateFormatter with the `yyyy-MM-dd` pattern. The
|
||||
// server trusts the inbound shape rather than re-validating; the
|
||||
// receiving Zod schema already caps `externalId` at 120 chars.
|
||||
expect(
|
||||
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", " 2026-05-16 "),
|
||||
).toBe("stats:HKQuantityTypeIdentifierStepCount: 2026-05-16 ");
|
||||
expect(
|
||||
dailyStatsExternalId("HKQuantityTypeIdentifierStepCount", ""),
|
||||
).toBe("stats:HKQuantityTypeIdentifierStepCount:");
|
||||
});
|
||||
|
||||
it("covers every CUMULATIVE_HK_TYPES MeasurementType via a known HK identifier", () => {
|
||||
// Sanity wall: every cumulative MeasurementType has a forward-map
|
||||
// entry in APPLE_HEALTH_TYPE_MAP so the iOS-side daily-stats
|
||||
// service can mint the externalId without a server round-trip.
|
||||
const cumulativeIdentifiers = Object.values(APPLE_HEALTH_TYPE_MAP)
|
||||
.filter((m) => CUMULATIVE_HK_TYPES.has(m.measurementType))
|
||||
.map((m) => m.hkIdentifier);
|
||||
expect(cumulativeIdentifiers.sort()).toEqual(
|
||||
[
|
||||
"HKQuantityTypeIdentifierStepCount",
|
||||
"HKQuantityTypeIdentifierActiveEnergyBurned",
|
||||
"HKQuantityTypeIdentifierFlightsClimbed",
|
||||
"HKQuantityTypeIdentifierDistanceWalkingRunning",
|
||||
"HKQuantityTypeIdentifierTimeInDaylight",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -310,6 +310,41 @@ export const CUMULATIVE_HK_TYPES: ReadonlySet<MeasurementType> = new Set<Measure
|
||||
"TIME_IN_DAYLIGHT",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.4.30 — externalId shape for daily-aggregated cumulative
|
||||
* HealthKit rows. iOS emits one row per day per cumulative type via
|
||||
* `HKStatisticsCollectionQuery` per R-A Option A; the externalId
|
||||
* UPSERTs the matching server row idempotently across re-syncs.
|
||||
*
|
||||
* Format: `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>`.
|
||||
*
|
||||
* Cumulative HK types only — the spot-sample path keeps using
|
||||
* `HKSample.uuid` as `externalId`. The shape is intentionally
|
||||
* stable across the cutover: iOS clients still posting per-sample
|
||||
* rows round-trip through the existing `(userId, type, source,
|
||||
* externalId)` unique index; iOS clients on the daily-stats path
|
||||
* collide on the deterministic `"stats:..."` key for idempotent
|
||||
* UPSERTs.
|
||||
*
|
||||
* Locked contract — see
|
||||
* `.planning/v15-ios-handoff/08-locked-contracts.md` §13 and
|
||||
* `.planning/v15-ios-handoff/06-ios-responsibilities.md` Domain 1
|
||||
* "Cumulative metrics: daily aggregation on iOS".
|
||||
*
|
||||
* The helper accepts the date string as-is: iOS generates it from
|
||||
* the user's IANA timezone via `DateFormatter` with the
|
||||
* `yyyy-MM-dd` pattern; the server trusts that format rather than
|
||||
* re-validating per ingest because the iOS handoff doc locks the
|
||||
* shape and the receiving Zod schema already caps `externalId` at
|
||||
* 120 characters.
|
||||
*/
|
||||
export function dailyStatsExternalId(
|
||||
hkIdentifier: string,
|
||||
dateYYYYMMDD: string,
|
||||
): string {
|
||||
return `stats:${hkIdentifier}:${dateYYYYMMDD}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* HK identifiers the iOS app may emit that HealthLog deliberately does
|
||||
* NOT map yet. Listing them here means the batch route can log a
|
||||
|
||||
Reference in new issue
Block a user