mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(sources): two-axis priority — additive metricPriority + deviceTypePriority Zod keys
Layer two new optional keys on top of the existing User.sourcePriorityJson shape — metricPriority (nested per-metric ladder, canonical going forward) and deviceTypePriority (default ladder plus per-metric overrides). The W5e flat top-level metric keys stay accepted as a backward-compat alias so existing rows keep working without a DB migration or forced reconfig. parseSourcePriority() now returns a fully-resolved shape where every metric ladder is defaulted and the device-type axis defaults to the documented DEFAULT_DEVICE_TYPE_PRIORITY (watch first). Two helpers (getSourceLadder, getDeviceTypeLadder) centralise the lookup so the picker, the UI and downstream call sites share one path. Tests: 30 unit tests pass (up from 19). Covers both axes' parse, fallback chain (W8c nested > W5e flat > defaults), enum guards, and the two helper functions. Pre-existing typecheck failures unrelated to this change: src/lib/i18n/* and a handful of consumers expect a 'de' | 'en' locale union but i18n config declares the 6-locale superset.
This commit is contained in:
1 parent
8f54993df5
commit
f05c55f638
2 files changed
+476
-52
No files matched your search
@@ -1,14 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_DEVICE_TYPE_PRIORITY,
|
||||
DEFAULT_SOURCE_PRIORITY,
|
||||
SOURCE_PRIORITY_METRIC_KEYS,
|
||||
deviceTypeEnum,
|
||||
getDeviceTypeLadder,
|
||||
getSourceLadder,
|
||||
normalizeDeviceType,
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
} from "../source-priority";
|
||||
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source-priority foundation.
|
||||
* v1.4.25 W8c — two-axis extension: `metricPriority` + `deviceTypePriority`.
|
||||
*/
|
||||
describe("sourcePrioritySchema", () => {
|
||||
it("accepts an empty object (every key is optional)", () => {
|
||||
@@ -16,7 +22,7 @@ describe("sourcePrioritySchema", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a partial shape (one metric class only)", () => {
|
||||
it("accepts a partial flat shape (one metric class only) — W5e compat", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: ["WITHINGS", "MANUAL"],
|
||||
});
|
||||
@@ -26,21 +32,74 @@ describe("sourcePrioritySchema", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the full shape and round-trips it", () => {
|
||||
const input = {
|
||||
steps: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] as const,
|
||||
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] as const,
|
||||
sleep: ["APPLE_HEALTH", "WITHINGS"] as const,
|
||||
};
|
||||
const result = sourcePrioritySchema.safeParse(input);
|
||||
it("accepts the W8c nested `metricPriority` shape", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
metricPriority: {
|
||||
weight: ["MANUAL", "WITHINGS"],
|
||||
steps: ["WITHINGS", "APPLE_HEALTH"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.steps).toEqual(input.steps);
|
||||
expect(result.data.weight).toEqual(input.weight);
|
||||
expect(result.data.sleep).toEqual(input.sleep);
|
||||
expect(result.data.metricPriority?.weight).toEqual([
|
||||
"MANUAL",
|
||||
"WITHINGS",
|
||||
]);
|
||||
expect(result.data.metricPriority?.steps).toEqual([
|
||||
"WITHINGS",
|
||||
"APPLE_HEALTH",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the W8c `deviceTypePriority` global default", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
deviceTypePriority: {
|
||||
default: ["watch", "phone", "ring"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.deviceTypePriority?.default).toEqual([
|
||||
"watch",
|
||||
"phone",
|
||||
"ring",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts the W8c `deviceTypePriority` per-metric override", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
deviceTypePriority: {
|
||||
default: ["watch", "ring", "phone"],
|
||||
ACTIVITY_STEPS: ["phone", "watch"],
|
||||
WEIGHT: ["scale"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
// The catchall path stores per-metric overrides under their
|
||||
// MeasurementType-enum key.
|
||||
expect(result.data.deviceTypePriority?.ACTIVITY_STEPS).toEqual([
|
||||
"phone",
|
||||
"watch",
|
||||
]);
|
||||
expect(result.data.deviceTypePriority?.WEIGHT).toEqual(["scale"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts both axes in the same payload", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
metricPriority: {
|
||||
weight: ["MANUAL", "WITHINGS"],
|
||||
},
|
||||
deviceTypePriority: {
|
||||
default: ["watch", "ring"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown source string", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: ["FITBIT", "WITHINGS"],
|
||||
@@ -48,6 +107,15 @@ describe("sourcePrioritySchema", () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown device-type string", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
deviceTypePriority: {
|
||||
default: ["fitbit-tracker"],
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 8 sources in a single metric class", () => {
|
||||
const result = sourcePrioritySchema.safeParse({
|
||||
weight: [
|
||||
@@ -73,6 +141,22 @@ describe("sourcePrioritySchema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviceTypeEnum", () => {
|
||||
it("accepts every documented device-type slot", () => {
|
||||
for (const value of [
|
||||
"watch",
|
||||
"band",
|
||||
"ring",
|
||||
"phone",
|
||||
"scale",
|
||||
"other",
|
||||
"unknown",
|
||||
] as const) {
|
||||
expect(deviceTypeEnum.safeParse(value).success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_SOURCE_PRIORITY", () => {
|
||||
it("covers every metric key declared in SOURCE_PRIORITY_METRIC_KEYS", () => {
|
||||
// A new metric class added to the constant list without a default
|
||||
@@ -120,44 +204,97 @@ describe("DEFAULT_SOURCE_PRIORITY", () => {
|
||||
expect(DEFAULT_SOURCE_PRIORITY[key][0]).toBe("APPLE_HEALTH");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("validates against the Zod schema (no drift between defaults and validator)", () => {
|
||||
expect(() =>
|
||||
sourcePrioritySchema.parse(DEFAULT_SOURCE_PRIORITY),
|
||||
).not.toThrow();
|
||||
describe("DEFAULT_DEVICE_TYPE_PRIORITY", () => {
|
||||
it("is non-empty and contains every enum slot", () => {
|
||||
// The picker treats an unknown device-type as `"unknown"` — to
|
||||
// stay safe the constant must include the `unknown` slot or
|
||||
// legacy/NULL-tagged rows would never resolve a rank.
|
||||
expect(DEFAULT_DEVICE_TYPE_PRIORITY.length).toBe(7);
|
||||
expect(DEFAULT_DEVICE_TYPE_PRIORITY).toContain("unknown");
|
||||
});
|
||||
|
||||
it("places `watch` first (open-wearables-compatible default)", () => {
|
||||
expect(DEFAULT_DEVICE_TYPE_PRIORITY[0]).toBe("watch");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSourcePriority", () => {
|
||||
it("returns defaults for null input", () => {
|
||||
expect(parseSourcePriority(null)).toEqual(DEFAULT_SOURCE_PRIORITY);
|
||||
const out = parseSourcePriority(null);
|
||||
expect(out).toMatchObject(DEFAULT_SOURCE_PRIORITY);
|
||||
expect(out.metricPriority).toEqual(DEFAULT_SOURCE_PRIORITY);
|
||||
expect(out.deviceTypePriority).toEqual({});
|
||||
});
|
||||
|
||||
it("returns defaults for undefined input", () => {
|
||||
expect(parseSourcePriority(undefined)).toEqual(DEFAULT_SOURCE_PRIORITY);
|
||||
const out = parseSourcePriority(undefined);
|
||||
expect(out).toMatchObject(DEFAULT_SOURCE_PRIORITY);
|
||||
});
|
||||
|
||||
it("returns defaults for malformed input (forward-compat fallback)", () => {
|
||||
expect(parseSourcePriority({ weight: "WITHINGS" })).toEqual(
|
||||
expect(parseSourcePriority({ weight: "WITHINGS" })).toMatchObject(
|
||||
DEFAULT_SOURCE_PRIORITY,
|
||||
);
|
||||
expect(parseSourcePriority("not an object")).toEqual(
|
||||
expect(parseSourcePriority("not an object")).toMatchObject(
|
||||
DEFAULT_SOURCE_PRIORITY,
|
||||
);
|
||||
});
|
||||
|
||||
it("merges a partial shape onto defaults (missing keys keep defaults)", () => {
|
||||
it("merges a flat partial shape onto defaults (W5e compat)", () => {
|
||||
const partial = {
|
||||
weight: ["MANUAL", "WITHINGS"] as const,
|
||||
};
|
||||
const out = parseSourcePriority(partial);
|
||||
expect(out.weight).toEqual(["MANUAL", "WITHINGS"]);
|
||||
expect(out.metricPriority.weight).toEqual(["MANUAL", "WITHINGS"]);
|
||||
// Other keys keep their defaults.
|
||||
expect(out.steps).toEqual(DEFAULT_SOURCE_PRIORITY.steps);
|
||||
expect(out.bloodPressure).toEqual(DEFAULT_SOURCE_PRIORITY.bloodPressure);
|
||||
});
|
||||
|
||||
it("preserves a fully-specified shape", () => {
|
||||
it("merges a W8c nested `metricPriority` onto defaults", () => {
|
||||
const partial = {
|
||||
metricPriority: {
|
||||
weight: ["MANUAL", "APPLE_HEALTH"] as const,
|
||||
steps: ["WITHINGS"] as const,
|
||||
},
|
||||
};
|
||||
const out = parseSourcePriority(partial);
|
||||
expect(out.metricPriority.weight).toEqual(["MANUAL", "APPLE_HEALTH"]);
|
||||
expect(out.metricPriority.steps).toEqual(["WITHINGS"]);
|
||||
// Unchanged metrics keep the constant default.
|
||||
expect(out.metricPriority.bloodPressure).toEqual(
|
||||
DEFAULT_SOURCE_PRIORITY.bloodPressure,
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers W8c nested over W5e flat when both shapes are present", () => {
|
||||
// A user who edits the new UI ships the nested shape; if a
|
||||
// legacy/admin tool also stashed the flat shape, the nested wins.
|
||||
const out = parseSourcePriority({
|
||||
weight: ["WITHINGS"],
|
||||
metricPriority: {
|
||||
weight: ["MANUAL", "WITHINGS"],
|
||||
},
|
||||
});
|
||||
expect(out.weight).toEqual(["MANUAL", "WITHINGS"]);
|
||||
expect(out.metricPriority.weight).toEqual(["MANUAL", "WITHINGS"]);
|
||||
});
|
||||
|
||||
it("surfaces `deviceTypePriority` round-trip unchanged", () => {
|
||||
const out = parseSourcePriority({
|
||||
deviceTypePriority: {
|
||||
default: ["watch", "ring"],
|
||||
WEIGHT: ["scale"],
|
||||
},
|
||||
});
|
||||
expect(out.deviceTypePriority.default).toEqual(["watch", "ring"]);
|
||||
expect(out.deviceTypePriority.WEIGHT).toEqual(["scale"]);
|
||||
});
|
||||
|
||||
it("preserves a fully-specified flat shape (W5e back-compat path)", () => {
|
||||
const input = {
|
||||
...DEFAULT_SOURCE_PRIORITY,
|
||||
weight: ["MANUAL", "APPLE_HEALTH"] as const,
|
||||
@@ -166,3 +303,70 @@ describe("parseSourcePriority", () => {
|
||||
expect(out.weight).toEqual(["MANUAL", "APPLE_HEALTH"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeDeviceType", () => {
|
||||
it("returns the canonical value for every enum slot", () => {
|
||||
for (const value of [
|
||||
"watch",
|
||||
"band",
|
||||
"ring",
|
||||
"phone",
|
||||
"scale",
|
||||
"other",
|
||||
"unknown",
|
||||
] as const) {
|
||||
expect(normalizeDeviceType(value)).toBe(value);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns `unknown` for null / undefined / unknown strings", () => {
|
||||
expect(normalizeDeviceType(null)).toBe("unknown");
|
||||
expect(normalizeDeviceType(undefined)).toBe("unknown");
|
||||
expect(normalizeDeviceType("fitbit")).toBe("unknown");
|
||||
expect(normalizeDeviceType(42)).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSourceLadder + getDeviceTypeLadder helpers", () => {
|
||||
it("getSourceLadder reads the resolved metric ladder", () => {
|
||||
const resolved = parseSourcePriority({
|
||||
metricPriority: { weight: ["MANUAL"] },
|
||||
});
|
||||
expect(getSourceLadder(resolved, "weight")).toEqual(["MANUAL"]);
|
||||
expect(getSourceLadder(resolved, "bloodPressure")).toEqual(
|
||||
DEFAULT_SOURCE_PRIORITY.bloodPressure,
|
||||
);
|
||||
});
|
||||
|
||||
it("getDeviceTypeLadder prefers a per-metric override", () => {
|
||||
const resolved = parseSourcePriority({
|
||||
deviceTypePriority: {
|
||||
default: ["phone", "watch"],
|
||||
WEIGHT: ["scale", "watch"],
|
||||
},
|
||||
});
|
||||
expect(getDeviceTypeLadder(resolved, "WEIGHT")).toEqual([
|
||||
"scale",
|
||||
"watch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getDeviceTypeLadder falls back to the user default ladder", () => {
|
||||
const resolved = parseSourcePriority({
|
||||
deviceTypePriority: {
|
||||
default: ["phone", "watch"],
|
||||
},
|
||||
});
|
||||
expect(getDeviceTypeLadder(resolved, "ACTIVITY_STEPS")).toEqual([
|
||||
"phone",
|
||||
"watch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("getDeviceTypeLadder falls back to the constant default ladder when nothing is set", () => {
|
||||
const resolved = parseSourcePriority(null);
|
||||
expect(getDeviceTypeLadder(resolved, "ACTIVITY_STEPS")).toEqual(
|
||||
DEFAULT_DEVICE_TYPE_PRIORITY,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,45 +13,147 @@
|
||||
*
|
||||
* Persisted as `User.sourcePriorityJson` (nullable Jsonb). Null = use
|
||||
* `DEFAULT_SOURCE_PRIORITY` verbatim.
|
||||
*
|
||||
* v1.4.25 W8c — two-axis extension. The original (W5e) shape carried a
|
||||
* per-metric ladder at the top level (e.g. `weight: ["WITHINGS", …]`).
|
||||
* W8c adds two optional containers ON TOP of that:
|
||||
* - `metricPriority` — the per-metric ladder, identical in shape
|
||||
* to the top-level keys but nested. The
|
||||
* nested form is canonical going forward;
|
||||
* the flat form is kept as a backward-compat
|
||||
* shim (no migration / no UI for it).
|
||||
* - `deviceTypePriority` — within a source, which device wins. Keyed
|
||||
* by metric type with an optional `default`
|
||||
* ladder that covers metrics where the user
|
||||
* hasn't set a per-metric override.
|
||||
*
|
||||
* Both new keys are optional; existing rows with the flat shape keep
|
||||
* working unchanged. `parseSourcePriority()` merges everything onto
|
||||
* `DEFAULT_SOURCE_PRIORITY` plus an empty two-axis state so the call
|
||||
* site never has to think about which keys exist.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { measurementSourceEnum } from "@/lib/validations/measurement";
|
||||
import {
|
||||
measurementSourceEnum,
|
||||
measurementTypeEnum,
|
||||
} from "@/lib/validations/measurement";
|
||||
|
||||
/**
|
||||
* Per-metric-class priority list. First wins when multiple sources have
|
||||
* data for the same day on a cumulative metric. For point measurements
|
||||
* the list controls "display preference" but every source's row stays
|
||||
* in the DB.
|
||||
* Device-type tag attached to a `Measurement` row. Mirrors
|
||||
* open-wearables (`watch | band | ring | phone | scale | other`) plus
|
||||
* an explicit `unknown` slot for legacy rows where the iOS app or
|
||||
* Withings webhook never told us which device produced the sample.
|
||||
*
|
||||
* Stored on `Measurement.deviceType` (nullable). NULL is treated as
|
||||
* `unknown` by `pickCanonicalSource()` — it falls through to the
|
||||
* source-only axis when no device-type override applies.
|
||||
*/
|
||||
export const deviceTypeEnum = z.enum([
|
||||
"watch",
|
||||
"band",
|
||||
"ring",
|
||||
"phone",
|
||||
"scale",
|
||||
"other",
|
||||
"unknown",
|
||||
]);
|
||||
|
||||
export type DeviceType = z.infer<typeof deviceTypeEnum>;
|
||||
|
||||
/**
|
||||
* Per-metric source ladder. Same shape as the v1.4.25 W5e top-level
|
||||
* keys; lifted into a nested object so the schema has room for the new
|
||||
* `deviceTypePriority` sibling without clobbering the metric keys.
|
||||
*
|
||||
* The 8-entry cap is a sanity bound (we have 4 sources today, the cap
|
||||
* leaves headroom without inviting a megabyte-blob payload).
|
||||
*/
|
||||
export const sourcePrioritySchema = z
|
||||
const metricSourceLadder = z.array(measurementSourceEnum).max(8);
|
||||
|
||||
const metricPriorityObjectSchema = z
|
||||
.object({
|
||||
// ── Cumulative metrics — sum-per-day; pick ONE source per day. ──
|
||||
steps: z.array(measurementSourceEnum).max(8),
|
||||
activeEnergy: z.array(measurementSourceEnum).max(8),
|
||||
walkingRunningDistance: z.array(measurementSourceEnum).max(8),
|
||||
flightsClimbed: z.array(measurementSourceEnum).max(8),
|
||||
steps: metricSourceLadder,
|
||||
activeEnergy: metricSourceLadder,
|
||||
walkingRunningDistance: metricSourceLadder,
|
||||
flightsClimbed: metricSourceLadder,
|
||||
// ── Sleep — pick best-resolution source per night. ──
|
||||
sleep: z.array(measurementSourceEnum).max(8),
|
||||
sleep: metricSourceLadder,
|
||||
// ── Point measurements — all sources kept; this controls
|
||||
// "display preference" when multiple sources happen to capture
|
||||
// the same metric on the same day.
|
||||
weight: z.array(measurementSourceEnum).max(8),
|
||||
bloodPressure: z.array(measurementSourceEnum).max(8),
|
||||
pulse: z.array(measurementSourceEnum).max(8),
|
||||
bodyFat: z.array(measurementSourceEnum).max(8),
|
||||
bodyTemperature: z.array(measurementSourceEnum).max(8),
|
||||
spo2: z.array(measurementSourceEnum).max(8),
|
||||
hrv: z.array(measurementSourceEnum).max(8),
|
||||
restingHeartRate: z.array(measurementSourceEnum).max(8),
|
||||
vo2Max: z.array(measurementSourceEnum).max(8),
|
||||
weight: metricSourceLadder,
|
||||
bloodPressure: metricSourceLadder,
|
||||
pulse: metricSourceLadder,
|
||||
bodyFat: metricSourceLadder,
|
||||
bodyTemperature: metricSourceLadder,
|
||||
spo2: metricSourceLadder,
|
||||
hrv: metricSourceLadder,
|
||||
restingHeartRate: metricSourceLadder,
|
||||
vo2Max: metricSourceLadder,
|
||||
})
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* v1.4.25 W8c — device-type ladder per metric class. Walked by
|
||||
* `pickCanonicalSource()` AFTER the source axis has narrowed the
|
||||
* candidate row down to the winning source.
|
||||
*
|
||||
* Two-level lookup:
|
||||
* - `deviceTypePriority[metric]` — per-metric override (wins).
|
||||
* - `deviceTypePriority.default` — global fallback (used when the
|
||||
* metric has no per-key entry).
|
||||
*
|
||||
* Cap mirrors the source ladder (8 entries) and is generous; the enum
|
||||
* itself has 7 slots.
|
||||
*/
|
||||
const deviceTypeLadder = z.array(deviceTypeEnum).max(8);
|
||||
|
||||
const deviceTypePrioritySchema = z
|
||||
.object({
|
||||
// Global fallback applied when no per-metric override exists.
|
||||
default: deviceTypeLadder,
|
||||
// Per-metric overrides — keyed by MeasurementType enum value so
|
||||
// iOS-emitted device-type tags survive a future per-metric tweak
|
||||
// without schema churn.
|
||||
})
|
||||
.catchall(deviceTypeLadder)
|
||||
// `partial()` so callers can supply only `default`, only an override,
|
||||
// or omit the key entirely.
|
||||
.partial();
|
||||
|
||||
/**
|
||||
* Top-level schema. Flat metric keys (W5e shape) sit alongside the new
|
||||
* `metricPriority` + `deviceTypePriority` containers. The flat shape is
|
||||
* a backward-compat alias — when both exist, `metricPriority` wins.
|
||||
*/
|
||||
export const sourcePrioritySchema = z
|
||||
.object({
|
||||
// ── v1.4.25 W5e flat shape (backward-compat) ──
|
||||
steps: metricSourceLadder,
|
||||
activeEnergy: metricSourceLadder,
|
||||
walkingRunningDistance: metricSourceLadder,
|
||||
flightsClimbed: metricSourceLadder,
|
||||
sleep: metricSourceLadder,
|
||||
weight: metricSourceLadder,
|
||||
bloodPressure: metricSourceLadder,
|
||||
pulse: metricSourceLadder,
|
||||
bodyFat: metricSourceLadder,
|
||||
bodyTemperature: metricSourceLadder,
|
||||
spo2: metricSourceLadder,
|
||||
hrv: metricSourceLadder,
|
||||
restingHeartRate: metricSourceLadder,
|
||||
vo2Max: metricSourceLadder,
|
||||
// ── v1.4.25 W8c additions ──
|
||||
metricPriority: metricPriorityObjectSchema,
|
||||
deviceTypePriority: deviceTypePrioritySchema,
|
||||
})
|
||||
.partial();
|
||||
|
||||
export type SourcePriority = z.infer<typeof sourcePrioritySchema>;
|
||||
export type MetricPriority = z.infer<typeof metricPriorityObjectSchema>;
|
||||
export type DeviceTypePriority = z.infer<typeof deviceTypePrioritySchema>;
|
||||
|
||||
/**
|
||||
* Metric-class keys carried by `SourcePriority`. Listed once here so
|
||||
@@ -94,7 +196,7 @@ export type SourcePriorityMetricKey =
|
||||
* pulse-ox, Thermo). Apple Health is second-hand (HealthKit
|
||||
* receives the same reading via Withings' Health Mate iOS app).
|
||||
*/
|
||||
export const DEFAULT_SOURCE_PRIORITY: Required<SourcePriority> = {
|
||||
export const DEFAULT_SOURCE_PRIORITY: Required<MetricPriority> = {
|
||||
steps: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
|
||||
activeEnergy: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
|
||||
walkingRunningDistance: ["APPLE_HEALTH", "WITHINGS", "MANUAL"],
|
||||
@@ -111,22 +213,140 @@ export const DEFAULT_SOURCE_PRIORITY: Required<SourcePriority> = {
|
||||
vo2Max: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.25 W8c — default device-type ladder. Open-wearables' research
|
||||
* recommendation: watch first (highest fidelity for cumulative + HR
|
||||
* metrics), then ring/band (wrist-adjacent), then phone (fallback
|
||||
* cumulative when wearables are off-wrist), then scale (only writes
|
||||
* weight/body comp anyway), then catch-all `other` / `unknown`.
|
||||
*
|
||||
* Note: the picker treats a row with `deviceType = null` as `unknown`
|
||||
* — legacy Withings rows (pre-v1.4.25 W8c) fall to the bottom of the
|
||||
* ladder but never get filtered out, because the source-axis pick
|
||||
* already narrowed to one source.
|
||||
*/
|
||||
export const DEFAULT_DEVICE_TYPE_PRIORITY: readonly DeviceType[] = [
|
||||
"watch",
|
||||
"ring",
|
||||
"band",
|
||||
"phone",
|
||||
"scale",
|
||||
"other",
|
||||
"unknown",
|
||||
];
|
||||
|
||||
/**
|
||||
* Fully-resolved shape returned by `parseSourcePriority`. The flat
|
||||
* per-metric ladder is always populated (defaulted); the two-axis
|
||||
* containers default to empty objects so call sites can read
|
||||
* `resolved.metricPriority.weight` etc. without an `undefined` guard.
|
||||
*/
|
||||
export interface ResolvedSourcePriority extends Required<MetricPriority> {
|
||||
/**
|
||||
* Canonical per-metric ladder. Merged from `metricPriority` (W8c
|
||||
* nested shape) over the flat top-level keys (W5e shape) over
|
||||
* `DEFAULT_SOURCE_PRIORITY`. Always populated for every metric.
|
||||
*/
|
||||
metricPriority: Required<MetricPriority>;
|
||||
/**
|
||||
* Per-metric device-type override + a global fallback ladder. Both
|
||||
* keys default to the empty state (no overrides) so the picker uses
|
||||
* `DEFAULT_DEVICE_TYPE_PRIORITY` as the implicit fallback.
|
||||
*/
|
||||
deviceTypePriority: DeviceTypePriority;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persisted Json blob into a fully-defaulted priority map.
|
||||
* Missing keys fall back to `DEFAULT_SOURCE_PRIORITY` — the UI never
|
||||
* has to think about which keys exist, and a future schema additions
|
||||
* (a new metric class) carry their default automatically until the
|
||||
* has to think about which keys exist, and a future schema addition
|
||||
* (a new metric class) carries its default automatically until the
|
||||
* user edits the field.
|
||||
*
|
||||
* Merge order (high → low):
|
||||
* 1. `raw.metricPriority` (W8c nested shape; canonical going forward)
|
||||
* 2. `raw` top-level flat keys (W5e backward-compat)
|
||||
* 3. `DEFAULT_SOURCE_PRIORITY`
|
||||
*/
|
||||
export function parseSourcePriority(raw: unknown): Required<SourcePriority> {
|
||||
if (raw == null) return DEFAULT_SOURCE_PRIORITY;
|
||||
export function parseSourcePriority(raw: unknown): ResolvedSourcePriority {
|
||||
if (raw == null) return buildResolved({}, {}, {});
|
||||
|
||||
const parsed = sourcePrioritySchema.safeParse(raw);
|
||||
if (!parsed.success) return DEFAULT_SOURCE_PRIORITY;
|
||||
// Merge the parsed partial onto defaults so missing keys read as
|
||||
// defaults — same intuition as `Object.assign({}, defaults, parsed)`
|
||||
// but typed.
|
||||
return {
|
||||
if (!parsed.success) return buildResolved({}, {}, {});
|
||||
|
||||
// Pull the W5e flat per-metric ladder off the top level. Drop the
|
||||
// two W8c container keys so the rest of the object is just the flat
|
||||
// metric ladder we can spread without TS complaining.
|
||||
const {
|
||||
metricPriority: nested,
|
||||
deviceTypePriority,
|
||||
...flatMetricLadder
|
||||
} = parsed.data;
|
||||
return buildResolved(flatMetricLadder, nested ?? {}, deviceTypePriority ?? {});
|
||||
}
|
||||
|
||||
function buildResolved(
|
||||
flat: Partial<MetricPriority>,
|
||||
nested: Partial<MetricPriority>,
|
||||
deviceTypePriority: DeviceTypePriority,
|
||||
): ResolvedSourcePriority {
|
||||
// Merge: defaults < flat (W5e) < nested (W8c).
|
||||
const merged: Required<MetricPriority> = {
|
||||
...DEFAULT_SOURCE_PRIORITY,
|
||||
...parsed.data,
|
||||
...flat,
|
||||
...nested,
|
||||
};
|
||||
return {
|
||||
...merged,
|
||||
metricPriority: merged,
|
||||
deviceTypePriority,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8c — runtime device-type checker for ingest paths. Returns
|
||||
* a `DeviceType` for any input the iOS app or Withings webhook might
|
||||
* supply; falls back to `"unknown"` when the value isn't part of the
|
||||
* canonical enum. Keeps the picker on the happy path even when a
|
||||
* legacy row has `deviceType = null` or a typoed value.
|
||||
*/
|
||||
export function normalizeDeviceType(raw: unknown): DeviceType {
|
||||
const parsed = deviceTypeEnum.safeParse(raw);
|
||||
return parsed.success ? parsed.data : "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: walk the metric-key list and return the source ladder
|
||||
* for one metric. Centralised so the picker, the UI, and tests share
|
||||
* one path; future axis additions slot in here.
|
||||
*/
|
||||
export function getSourceLadder(
|
||||
resolved: ResolvedSourcePriority,
|
||||
metricKey: SourcePriorityMetricKey,
|
||||
): readonly z.infer<typeof measurementSourceEnum>[] {
|
||||
return resolved.metricPriority[metricKey];
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8c — resolve the device-type ladder for a given metric.
|
||||
* Lookup order:
|
||||
* 1. `deviceTypePriority[metricType]` — per-metric override.
|
||||
* 2. `deviceTypePriority.default` — global fallback the user set.
|
||||
* 3. `DEFAULT_DEVICE_TYPE_PRIORITY` — the constant ladder.
|
||||
*
|
||||
* The metric key is a `MeasurementType` enum value (e.g. `"WEIGHT"`),
|
||||
* not the `SourcePriorityMetricKey` (e.g. `"weight"`), because the
|
||||
* iOS-emitted `deviceType` field on `Measurement` is tagged per
|
||||
* `MeasurementType` row, not per `SourcePriorityMetricKey` aggregation
|
||||
* bucket. The Settings UI translates between the two.
|
||||
*/
|
||||
export function getDeviceTypeLadder(
|
||||
resolved: ResolvedSourcePriority,
|
||||
metricType: z.infer<typeof measurementTypeEnum> | string,
|
||||
): readonly DeviceType[] {
|
||||
const override = resolved.deviceTypePriority[metricType];
|
||||
if (override && override.length > 0) return override;
|
||||
const fallback = resolved.deviceTypePriority.default;
|
||||
if (fallback && fallback.length > 0) return fallback;
|
||||
return DEFAULT_DEVICE_TYPE_PRIORITY;
|
||||
}
|
||||
Reference in new issue
Block a user