mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(source-priority): resolve device-type ladder per row in mixed-type buckets
The W8c picker pinned the device-type ladder to `pickedRows[0].type` for the entire bucket and walked one ladder for every row. The inline comment promised per-row resolution against the cached ladder map; the implementation never delivered it. Today's only caller — the `SLEEP_DURATION` aggregation in `/api/analytics` — feeds single-type rows so the gap is latent. Future callers (Coach evidence rollup, doctor-PDF section, correlations engine) that batch multiple MeasurementTypes through one picker call would have tested non-matching rows against the wrong ladder. Group present device-types per row-type, resolve each type's ladder from the cache, pick the winning device-type independently per type. Per-type fall-through covers the case where the user's custom ladder doesn't enumerate any device-type present for a given row-type. Cover the multi-type path with two new tests: - per-row ladder resolution preserves each type's winner - per-type fall-through when a custom ladder is unreachable
This commit is contained in:
1 parent
bd1cb2c109
commit
db5e07a944
2 files changed
+194
-26
No files matched your search
@@ -374,6 +374,135 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
|
||||
expect(out.canonicalRows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("resolves the device-type ladder per row when the bucket carries mixed MeasurementTypes", () => {
|
||||
// v1.4.25 W10 reconcile (Sr-H2) — when a caller batches more than
|
||||
// one MeasurementType through a single picker call (Coach evidence
|
||||
// rollup, doctor-PDF section, correlations engine), each row-type
|
||||
// must resolve against its OWN ladder. The pre-W10 picker pinned
|
||||
// the ladder to `pickedRows[0].type` for the whole bucket, so a
|
||||
// bucket with WEIGHT-prefers-scale + ACTIVITY_STEPS-prefers-watch
|
||||
// rows would test the ACTIVITY_STEPS rows against the WEIGHT
|
||||
// ladder. Verify each type's winner survives independently.
|
||||
const rows = [
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:00:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "scale",
|
||||
type: "WEIGHT" as const,
|
||||
value: 82.4,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:05:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "watch",
|
||||
type: "WEIGHT" as const,
|
||||
value: 82.5,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:30:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "watch",
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
value: 1500,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:35:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "phone",
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
value: 4100,
|
||||
},
|
||||
];
|
||||
const userPriority = {
|
||||
deviceTypePriority: {
|
||||
// Per-type override: scale wins for WEIGHT (not watch — that's
|
||||
// the bucket's first row, which the pre-W10 picker would have
|
||||
// used to resolve the ladder for every row).
|
||||
WEIGHT: ["scale", "watch"],
|
||||
// ACTIVITY_STEPS keeps watch on top so its winner stays watch
|
||||
// and the bucket survives the per-row ladder walk with both
|
||||
// types' canonical rows preserved.
|
||||
ACTIVITY_STEPS: ["watch", "phone"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
// Expect: WEIGHT's scale row + ACTIVITY_STEPS' watch row. Both
|
||||
// types' losers (WEIGHT's watch row, ACTIVITY_STEPS' phone row)
|
||||
// are dropped against their OWN ladder, not the bucket's first
|
||||
// row's ladder.
|
||||
expect(out.canonicalRows).toHaveLength(2);
|
||||
const byType = new Map(
|
||||
out.canonicalRows.map((r) => [r.type, r.deviceType]),
|
||||
);
|
||||
expect(byType.get("WEIGHT")).toBe("scale");
|
||||
expect(byType.get("ACTIVITY_STEPS")).toBe("watch");
|
||||
});
|
||||
|
||||
it("falls through per row-type when a custom ladder doesn't enumerate that type's present device-types", () => {
|
||||
// Mixed-type bucket where ONE row-type's user ladder doesn't
|
||||
// include any present device-type. Per-type fall-through: keep
|
||||
// every row of that type, drop the other type's losers normally.
|
||||
const rows = [
|
||||
// WEIGHT — user's ladder says `ring` wins, but only `scale`
|
||||
// present → fall through, keep both WEIGHT rows.
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:00:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "scale",
|
||||
type: "WEIGHT" as const,
|
||||
value: 82.4,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:05:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "scale",
|
||||
type: "WEIGHT" as const,
|
||||
value: 82.5,
|
||||
},
|
||||
// ACTIVITY_STEPS — normal default ladder, watch wins over phone.
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:30:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "watch",
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
value: 1500,
|
||||
},
|
||||
{
|
||||
measuredAt: new Date("2026-05-12T07:35:00Z"),
|
||||
source: "APPLE_HEALTH" as const,
|
||||
deviceType: "phone",
|
||||
type: "ACTIVITY_STEPS" as const,
|
||||
value: 4100,
|
||||
},
|
||||
];
|
||||
const userPriority = {
|
||||
deviceTypePriority: {
|
||||
WEIGHT: ["ring"],
|
||||
},
|
||||
};
|
||||
const out = pickCanonicalSourceRows(
|
||||
rows,
|
||||
"steps",
|
||||
userPriority,
|
||||
isoDayKey,
|
||||
);
|
||||
// 2 WEIGHT rows kept (per-type fall-through) + 1 ACTIVITY_STEPS
|
||||
// watch row (phone dropped against default ladder).
|
||||
expect(out.canonicalRows).toHaveLength(3);
|
||||
const weightRows = out.canonicalRows.filter((r) => r.type === "WEIGHT");
|
||||
const stepRows = out.canonicalRows.filter(
|
||||
(r) => r.type === "ACTIVITY_STEPS",
|
||||
);
|
||||
expect(weightRows).toHaveLength(2);
|
||||
expect(stepRows).toHaveLength(1);
|
||||
expect(stepRows[0].deviceType).toBe("watch");
|
||||
});
|
||||
|
||||
it("two-axis lookup order: per-metric override > metricPriority > flat default", () => {
|
||||
// Full-stack test: nested W8c metricPriority pins WITHINGS first
|
||||
// for weight even though the constant default ladder has WITHINGS
|
||||
|
||||
@@ -90,6 +90,12 @@ export interface SourcePickerRow {
|
||||
* paths (timezone-aware) and tests (deterministic ISO date) can share
|
||||
* code without dragging a TZ runtime into the helper.
|
||||
*
|
||||
* Determinism precondition: input rows MUST be in a deterministic
|
||||
* order (typically `ORDER BY measuredAt ASC, id ASC` from the caller's
|
||||
* Prisma read). The picker's bucket map preserves insertion order, so
|
||||
* the output's stability — including tie-breaking on first-seen
|
||||
* device-type — depends on the caller honouring this contract.
|
||||
*
|
||||
* @returns the filtered row list (subset of input) plus the
|
||||
* per-day picked source — useful for debug overlays / audit
|
||||
* logging downstream.
|
||||
@@ -194,36 +200,69 @@ export function pickCanonicalSourceRows<T extends SourcePickerRow>(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve the ladder from the first row's type — buckets are
|
||||
// typically same-type so this only resolves once per bucket. If
|
||||
// the bucket carries mixed types, `resolveLadder` is cached and
|
||||
// walked per row inside the device-type walk below.
|
||||
const sampleLadder = resolveLadder(pickedRows[0].type);
|
||||
const presentDeviceTypes = new Set<DeviceType>();
|
||||
for (const row of pickedRows)
|
||||
presentDeviceTypes.add(normalizeDeviceType(row.deviceType));
|
||||
|
||||
let pickedDeviceType: DeviceType | undefined;
|
||||
for (const dt of sampleLadder) {
|
||||
if (presentDeviceTypes.has(dt)) {
|
||||
pickedDeviceType = dt;
|
||||
break;
|
||||
}
|
||||
// Per-row ladder resolution. Single-type buckets (today's only
|
||||
// call site — `SLEEP_DURATION` aggregation in `/api/analytics`)
|
||||
// hit the `ladderCache` once and walk an O(rows) loop. Mixed-type
|
||||
// buckets (future Coach evidence rollup, doctor-PDF section,
|
||||
// correlations engine) resolve their per-row ladders from the
|
||||
// cache and pick the winning device-type independently per type
|
||||
// — every MeasurementType in the bucket keeps its own winner so
|
||||
// a callsite that batches WEIGHT + BODY_FAT through one picker
|
||||
// call doesn't drop one type's rows against the wrong ladder.
|
||||
//
|
||||
// Group present device-types per row-type so each type's winning
|
||||
// device-type is resolved against the right ladder.
|
||||
const presentByType = new Map<
|
||||
MeasurementType | "__default__",
|
||||
Set<DeviceType>
|
||||
>();
|
||||
for (const row of pickedRows) {
|
||||
const typeKey = (row.type ?? "__default__") as
|
||||
| MeasurementType
|
||||
| "__default__";
|
||||
const set = presentByType.get(typeKey) ?? new Set<DeviceType>();
|
||||
set.add(normalizeDeviceType(row.deviceType));
|
||||
presentByType.set(typeKey, set);
|
||||
}
|
||||
// Edge case: the user's custom ladder doesn't enumerate the
|
||||
// device-types we have in the bucket (e.g. user typed
|
||||
// `["watch","phone"]`, the bucket has `["scale"]` rows). Fall
|
||||
// through to keep every row — same intent as the source-axis
|
||||
// fallback. The `DEFAULT_DEVICE_TYPE_PRIORITY` constant covers
|
||||
// every enum slot so this only fires when the user typed a custom
|
||||
// ladder that's missing one of the present types.
|
||||
if (!pickedDeviceType) {
|
||||
canonicalRows.push(...pickedRows);
|
||||
continue;
|
||||
|
||||
// Pick the winning device-type per row-type. `__default__` covers
|
||||
// legacy rows that arrive without a `type` field — they fall back
|
||||
// to the user-default ladder so the picker never silently drops
|
||||
// them.
|
||||
const winningDeviceTypeByRowType = new Map<
|
||||
MeasurementType | "__default__",
|
||||
DeviceType | "__fallback__"
|
||||
>();
|
||||
for (const [typeKey, presentSet] of presentByType) {
|
||||
const ladder = resolveLadder(
|
||||
typeKey === "__default__" ? null : (typeKey as MeasurementType),
|
||||
);
|
||||
let winner: DeviceType | undefined;
|
||||
for (const dt of ladder) {
|
||||
if (presentSet.has(dt)) {
|
||||
winner = dt;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Edge case: the user's custom ladder for THIS row-type doesn't
|
||||
// enumerate any device-type in the bucket. Fall through to keep
|
||||
// every row of that type — same intent as the source-axis
|
||||
// fallback (never silently drop data when the picker has no
|
||||
// ladder signal to filter on).
|
||||
winningDeviceTypeByRowType.set(typeKey, winner ?? "__fallback__");
|
||||
}
|
||||
|
||||
for (const row of pickedRows) {
|
||||
if (normalizeDeviceType(row.deviceType) === pickedDeviceType) {
|
||||
const typeKey = (row.type ?? "__default__") as
|
||||
| MeasurementType
|
||||
| "__default__";
|
||||
const winner = winningDeviceTypeByRowType.get(typeKey);
|
||||
if (winner === "__fallback__") {
|
||||
// Per-type fallback: keep every row of this type.
|
||||
canonicalRows.push(row);
|
||||
continue;
|
||||
}
|
||||
if (normalizeDeviceType(row.deviceType) === winner) {
|
||||
canonicalRows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user