diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index b48d95bce..2fea3bc27 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -73,8 +73,12 @@ describe("parseSgvEntries", () => { }); describe("mapSgvEntryToMeasurement", () => { - it("maps an SGV entry to a BLOOD_GLUCOSE mg/dL measurement", () => { - const m = mapSgvEntryToMeasurement(SAMPLE[0]!); + it("maps a parsed SGV entry to a BLOOD_GLUCOSE mg/dL measurement", () => { + const m = mapSgvEntryToMeasurement({ + id: "abc123", + sgv: 112, + date: 1718000000000, + }); expect(m.type).toBe("BLOOD_GLUCOSE"); expect(m.unit).toBe("mg/dL"); expect(m.value).toBe(112); @@ -82,15 +86,16 @@ describe("mapSgvEntryToMeasurement", () => { }); it("derives a stable externalId so re-sync is idempotent", () => { - const a = mapSgvEntryToMeasurement(SAMPLE[0]!); - const b = mapSgvEntryToMeasurement(SAMPLE[0]!); + const entry = { id: "abc123", sgv: 112, date: 1718000000000 }; + const a = mapSgvEntryToMeasurement(entry); + const b = mapSgvEntryToMeasurement(entry); expect(a.externalId).toBe(b.externalId); - expect(a.externalId).toContain("abc123"); + expect(a.externalId).toBe("ns:abc123"); }); - it("falls back to the timestamp when the entry has no _id", () => { + it("falls back to the timestamp when the entry has no id", () => { const m = mapSgvEntryToMeasurement({ - type: "sgv", + id: null, sgv: 90, date: 1718000000000, }); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index daf2ab22c..e40f8b785 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -135,34 +135,33 @@ export function parseSgvEntries(payload: unknown): ParsedSgvEntry[] { return out; } -/** - * Map one SGV entry to a `BLOOD_GLUCOSE` mg/dL measurement. The `externalId` - * is `ns:` when the entry carries Nightscout's `_id`, falling back to - * `ns:date:` — both stable per reading, so a re-sync collapses onto - * the existing row via the `(userId, type, source, externalId)` unique. mg/dL - * is stored canonical (Nightscout always reports mg/dL; the display unit is a - * user preference resolved elsewhere). - */ -export function mapSgvEntryToMeasurement( - entry: NightscoutSgvEntry, -): NightscoutMeasurement { - const externalId = entry._id - ? `ns:${entry._id}` - : `ns:date:${entry.date ?? 0}`; - return { - type: "BLOOD_GLUCOSE", - value: entry.sgv as number, - unit: NIGHTSCOUT_GLUCOSE_UNIT, - measuredAt: new Date(entry.date as number), - externalId, - }; -} - /** Externalid for a parsed entry — the canonical idempotency key. */ export function externalIdFor(entry: ParsedSgvEntry): string { return entry.id ? `ns:${entry.id}` : `ns:date:${entry.date}`; } +/** + * Map one parsed SGV entry to a `BLOOD_GLUCOSE` mg/dL measurement. The + * `externalId` is `ns:<_id>` when the entry carries Nightscout's `_id`, falling + * back to `ns:date:` (via `externalIdFor`) — both stable per reading, + * so a re-sync collapses onto the existing row via the + * `(userId, type, source, externalId)` unique. mg/dL is stored canonical + * (Nightscout always reports mg/dL; the display unit is a user preference + * resolved elsewhere). This is the SINGLE entry→measurement mapping — the sync + * write path consumes it, so the shape can never drift between two copies. + */ +export function mapSgvEntryToMeasurement( + entry: ParsedSgvEntry, +): NightscoutMeasurement { + return { + type: "BLOOD_GLUCOSE", + value: entry.sgv, + unit: NIGHTSCOUT_GLUCOSE_UNIT, + measuredAt: new Date(entry.date), + externalId: externalIdFor(entry), + }; +} + /** * Raised when an instance answers non-2xx or is unreachable. The connect * route + sync classifier switch on `status` to tell "wrong token" (401/403) diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 7d0c438db..df29b9989 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -33,10 +33,9 @@ import { } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; import { - externalIdFor, fetchSgvEntries, + mapSgvEntryToMeasurement, NightscoutApiError, - NIGHTSCOUT_GLUCOSE_UNIT, type ParsedSgvEntry, } from "./client"; import { getUserNightscoutCredentials } from "./credentials"; @@ -119,8 +118,7 @@ export async function upsertNightscoutEntries( const touched: Array<{ type: MeasurementType; measuredAt: Date }> = []; for (const entry of entries) { - const externalId = externalIdFor(entry); - const measuredAt = new Date(entry.date); + const mapped = mapSgvEntryToMeasurement(entry); try { await prisma.measurement.upsert({ where: { @@ -128,23 +126,23 @@ export async function upsertNightscoutEntries( userId, type, source: "NIGHTSCOUT", - externalId, + externalId: mapped.externalId, }, }, create: { userId, type, source: "NIGHTSCOUT", - value: entry.sgv, - unit: NIGHTSCOUT_GLUCOSE_UNIT, - measuredAt, - externalId, + value: mapped.value, + unit: mapped.unit, + measuredAt: mapped.measuredAt, + externalId: mapped.externalId, }, // First-write-wins: a CGM sample is immutable, so a re-sync of the // same reading must not rewrite the stored value or measuredAt. update: {}, }); - touched.push({ type, measuredAt }); + touched.push({ type, measuredAt: mapped.measuredAt }); imported++; } catch (err) { getEvent()?.addWarning(