fix(settings): saved source ladders learn about sources added later

A stored per-metric ladder replaced 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
page renders only the stored array and offers reorder, not add, and
the picker ranked the invisible source behind everything. The resolver
now appends default-ladder sources missing from the stored ladder
after the user's explicit order — visible, reorderable, and ranked
where the old behaviour already ranked them.
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 22:16:47 +02:00
1 parent c150983dc0
commit a86c59b77f
2 files changed
+95 -9

No files matched your search

@@ -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");
});
});
+19 -1
View File
@@ -344,11 +344,29 @@ function buildResolved(
deviceTypePriority: DeviceTypePriority,
): ResolvedSourcePriority {
// Merge: defaults < flat (W5e) < nested (W8c).
const merged: Required<MetricPriority> = {
const replaced: Required<MetricPriority> = {
...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<MetricPriority>;
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,