diff --git a/src/lib/validations/__tests__/source-priority.test.ts b/src/lib/validations/__tests__/source-priority.test.ts index 555e60483..2ec68be46 100644 --- a/src/lib/validations/__tests__/source-priority.test.ts +++ b/src/lib/validations/__tests__/source-priority.test.ts @@ -266,8 +266,16 @@ describe("parseSourcePriority", () => { weight: ["MANUAL", "WITHINGS"] as const, }; const out = parseSourcePriority(partial); - expect(out.weight).toEqual(["MANUAL", "WITHINGS"]); - expect(out.metricPriority.weight).toEqual(["MANUAL", "WITHINGS"]); + // v1.16.11 — the stored order leads; default-ladder sources the + // stored array lacks append after it (reconciliation). + expect(out.weight.slice(0, 2)).toEqual(["MANUAL", "WITHINGS"]); + expect(out.metricPriority.weight.slice(0, 2)).toEqual([ + "MANUAL", + "WITHINGS", + ]); + for (const source of DEFAULT_SOURCE_PRIORITY.weight) { + expect(out.weight).toContain(source); + } // Other keys keep their defaults. expect(out.steps).toEqual(DEFAULT_SOURCE_PRIORITY.steps); expect(out.bloodPressure).toEqual(DEFAULT_SOURCE_PRIORITY.bloodPressure); @@ -281,8 +289,14 @@ describe("parseSourcePriority", () => { }, }; const out = parseSourcePriority(partial); - expect(out.metricPriority.weight).toEqual(["MANUAL", "APPLE_HEALTH"]); - expect(out.metricPriority.steps).toEqual(["WITHINGS"]); + expect(out.metricPriority.weight.slice(0, 2)).toEqual([ + "MANUAL", + "APPLE_HEALTH", + ]); + expect(out.metricPriority.steps[0]).toBe("WITHINGS"); + for (const source of DEFAULT_SOURCE_PRIORITY.steps) { + expect(out.metricPriority.steps).toContain(source); + } // Unchanged metrics keep the constant default. expect(out.metricPriority.bloodPressure).toEqual( DEFAULT_SOURCE_PRIORITY.bloodPressure, @@ -298,8 +312,11 @@ describe("parseSourcePriority", () => { weight: ["MANUAL", "WITHINGS"], }, }); - expect(out.weight).toEqual(["MANUAL", "WITHINGS"]); - expect(out.metricPriority.weight).toEqual(["MANUAL", "WITHINGS"]); + expect(out.weight.slice(0, 2)).toEqual(["MANUAL", "WITHINGS"]); + expect(out.metricPriority.weight.slice(0, 2)).toEqual([ + "MANUAL", + "WITHINGS", + ]); }); it("surfaces `deviceTypePriority` round-trip unchanged", () => { @@ -319,7 +336,10 @@ describe("parseSourcePriority", () => { weight: ["MANUAL", "APPLE_HEALTH"] as const, }; const out = parseSourcePriority(input); - expect(out.weight).toEqual(["MANUAL", "APPLE_HEALTH"]); + expect(out.weight.slice(0, 2)).toEqual(["MANUAL", "APPLE_HEALTH"]); + for (const source of DEFAULT_SOURCE_PRIORITY.weight) { + expect(out.weight).toContain(source); + } }); }); @@ -351,7 +371,7 @@ describe("getSourceLadder + getDeviceTypeLadder helpers", () => { const resolved = parseSourcePriority({ metricPriority: { weight: ["MANUAL"] }, }); - expect(getSourceLadder(resolved, "weight")).toEqual(["MANUAL"]); + expect(getSourceLadder(resolved, "weight")[0]).toBe("MANUAL"); expect(getSourceLadder(resolved, "bloodPressure")).toEqual( DEFAULT_SOURCE_PRIORITY.bloodPressure, ); @@ -389,3 +409,51 @@ describe("getSourceLadder + getDeviceTypeLadder helpers", () => { ); }); }); + +describe("parseSourcePriority — stored-ladder reconciliation (v1.16.11)", () => { + // A ladder saved before a source existed used to hide that source + // forever: the stored array replaced the default wholesale, the + // settings UI offers reorder but not add, and the picker ranked the + // invisible source last. The resolver now appends default-ladder + // sources missing from the stored ladder AFTER the user's explicit + // order — visible, reorderable, same effective rank as before. + it("appends sources the default ladder gained after the user saved", () => { + // A pre-v1.11 sleep ladder: no WHOOP, no FITBIT. + const resolved = parseSourcePriority({ + metricPriority: { + sleep: ["APPLE_HEALTH", "MANUAL", "WITHINGS", "IMPORT"], + }, + }); + expect(resolved.metricPriority.sleep.slice(0, 4)).toEqual([ + "APPLE_HEALTH", + "MANUAL", + "WITHINGS", + "IMPORT", + ]); + // Appended in default-ladder order, after the stored entries. + expect(resolved.metricPriority.sleep).toContain("WHOOP"); + expect(resolved.metricPriority.sleep).toContain("FITBIT"); + expect(resolved.metricPriority.sleep.indexOf("WHOOP")).toBeGreaterThan(3); + expect( + resolved.metricPriority.sleep.indexOf("WHOOP"), + ).toBeLessThan(resolved.metricPriority.sleep.indexOf("FITBIT")); + }); + + it("leaves a complete stored ladder byte-identical", () => { + const full = [...DEFAULT_SOURCE_PRIORITY.sleep].reverse(); + const resolved = parseSourcePriority({ + metricPriority: { sleep: full }, + }); + expect(resolved.metricPriority.sleep).toEqual(full); + }); + + it("keeps stored entries the default ladder does not know", () => { + const resolved = parseSourcePriority({ + metricPriority: { sleep: ["MANUAL", "WHOOP"] }, + }); + expect(resolved.metricPriority.sleep[0]).toBe("MANUAL"); + expect(resolved.metricPriority.sleep[1]).toBe("WHOOP"); + expect(resolved.metricPriority.sleep).toContain("APPLE_HEALTH"); + expect(resolved.metricPriority.sleep).toContain("WITHINGS"); + }); +}); diff --git a/src/lib/validations/source-priority.ts b/src/lib/validations/source-priority.ts index 0ef2c6df0..9a9b14de5 100644 --- a/src/lib/validations/source-priority.ts +++ b/src/lib/validations/source-priority.ts @@ -344,11 +344,29 @@ function buildResolved( deviceTypePriority: DeviceTypePriority, ): ResolvedSourcePriority { // Merge: defaults < flat (W5e) < nested (W8c). - const merged: Required = { + const replaced: Required = { ...DEFAULT_SOURCE_PRIORITY, ...flat, ...nested, }; + // v1.16.11 — reconcile every stored ladder against the metric's + // default ladder. A stored ladder used to REPLACE the default + // wholesale, so a source integrated after the user last saved + // (WHOOP on a pre-v1.11 sleep ladder, Fitbit on pre-v1.12 ones) + // never surfaced again: the settings UI renders only the stored + // array and offers reorder, not add, and the picker ranked the + // invisible source last. The user's explicit order stays untouched; + // sources the default ladder knows and the stored ladder lacks + // append at the end — visible, reorderable, and ranked exactly where + // the old behaviour ranked them (after everything ranked). + const merged = {} as Required; + for (const key of Object.keys(replaced) as (keyof MetricPriority)[]) { + const stored = replaced[key]; + const missing = DEFAULT_SOURCE_PRIORITY[key].filter( + (source) => !stored.includes(source), + ); + merged[key] = missing.length > 0 ? [...stored, ...missing] : stored; + } const resolved: ResolvedSourcePriority = { ...merged, metricPriority: merged,