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:
Marc-André Bombeck committed 2026-05-14 15:24:23 +02:00
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,
);
});
});
+251 -31
View File
@@ -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;
}