From f05c55f638c7593479d3bc23087262c546fc6719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 15:24:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(sources):=20two-axis=20priority=20?= =?UTF-8?q?=E2=80=94=20additive=20metricPriority=20+=20deviceTypePriority?= =?UTF-8?q?=20Zod=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../__tests__/source-priority.test.ts | 246 +++++++++++++-- src/lib/validations/source-priority.ts | 282 ++++++++++++++++-- 2 files changed, 476 insertions(+), 52 deletions(-) diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index 831f18996..766d4f2a8 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -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, + ); + }); +}); diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 6a1d67978..db6a0b35e 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -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; + +/** + * 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; +export type MetricPriority = z.infer; +export type DeviceTypePriority = z.infer; /** * 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 = { +export const DEFAULT_SOURCE_PRIORITY: Required = { 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 = { 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 { + /** + * 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; + /** + * 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 { - 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, + nested: Partial, + deviceTypePriority: DeviceTypePriority, +): ResolvedSourcePriority { + // Merge: defaults < flat (W5e) < nested (W8c). + const merged: Required = { ...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[] { + 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 | 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; +}