From a099d813eebe65c3397cf1aefb817b81ba23aa6d Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:38:31 +0000 Subject: [PATCH] Document what EXTERNAL does to a contested day, and pin it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught a behaviour change this branch causes but never wrote down. `pickCanonicalSourceRows` takes the first ladder source present in a day's bucket and filters the day to that source. Before, a typed reading and a bridged one were both MANUAL, so both survived. Now MANUAL ranks, EXTERNAL does not, and the bridged row leaves the canonical set on any day that also has a typed one. That reaches every surface needing one source per day — the daily totals behind the charts, the health score, the doctor report, correlations, mood crosstabs and personal-record detection. A day with only bridged readings is untouched: nothing ranked is present, so the picker's pass-through keeps everything. The changelog says so in the user's terms, including the part that is a fix rather than a change: two indistinguishable MANUAL rows were both counted, which inflated a running total on a day somebody did both. Four cases pin it, because it is emergent from a rank ladder and stated in no single place: the contested day, the bridged-only day, the both-MANUAL shape the change moves away from, and a stored ladder that names EXTERNAL being honoured. Ranking EXTERNAL turns the first two red, which is the review anybody adding it to a ladder should have to pass. --- .../__tests__/source-priority.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index 400153096..f886505e2 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -515,3 +515,112 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker", expect(out.canonicalRows[0].source).toBe("WITHINGS"); }); }); + +/** + * v1.38.x — what the EXTERNAL source does to a contested day. + * + * `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2` + * defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry + * would come back from `GET /api/auth/me/source-priority`, be PUT back whole + * by the settings page, and 422 — breaking ladder saves for everyone. + * + * That omission is not inert. It decides a real behaviour, and one that + * CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed + * reading and a bridged one were the same source and both survived a day; now + * `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the + * canonical set for that day. Seven call sites consume this picker (daily + * totals, the health score, the doctor report, correlations, mood crosstabs, + * PR detection), so the effect is user-visible and is written up in the + * changelog. + * + * Pinned here because it is emergent from a rank ladder rather than stated + * anywhere in code: put `EXTERNAL` on a ladder and the first case below goes + * red, which is the review anybody adding it deserves. + */ +describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => { + const day = "2026-09-02"; + + function weightRow( + source: "MANUAL" | "EXTERNAL", + value: number, + hour: string, + ) { + return { + measuredAt: new Date(`${day}T${hour}:00Z`), + source, + deviceType: null, + type: "WEIGHT" as const, + value, + }; + } + + it("keeps the typed reading and drops the bridged one on a contested day", () => { + // The behaviour change itself. `MANUAL` is on the weight ladder and + // `EXTERNAL` is not, so the day resolves to the reading the person + // entered — a hand correction still wins where one number is needed. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("MANUAL"); + expect(out.canonicalRows[0].value).toBe(81.9); + expect(out.pickedByDay.get(day)).toBe("MANUAL"); + }); + + it("keeps a bridged-only day, since nothing on the ladder is present", () => { + // The `!picked` pass-through. Without this arm an account that ONLY ever + // pushes readings would go dark on every surface listed above, which + // would be a far worse regression than the one above is a change. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("EXTERNAL", 82.1, "20:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true); + // No ladder source won, so the day names no pick. + expect(out.pickedByDay.has(day)).toBe(false); + }); + + it("kept both when the bridged row was still labelled MANUAL", () => { + // The pre-change shape, asserted so the diff between old and new + // behaviour lives in the suite rather than only in the changelog. Two + // `MANUAL` rows are indistinguishable to the picker and both survive — + // which for a cumulative metric is the double-count the new labelling + // removes. + const out = pickCanonicalSourceRows( + [weightRow("MANUAL", 82.4, "06:30"), weightRow("MANUAL", 81.9, "08:00")], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + }); + + it("lets a user who ranks EXTERNAL themselves have it win", () => { + // Not reachable from the settings UI today (the ladder cap keeps it off + // the page), but the picker resolves whatever ladder it is handed, and a + // stored ladder naming it must be honoured rather than ignored. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + { metricPriority: { weight: ["EXTERNAL", "MANUAL"] } }, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("EXTERNAL"); + }); +});