mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(integrations): single Nightscout entry-to-measurement mapper
Route the sync write through mapSgvEntryToMeasurement so the entry-to-measurement shape has one source of truth instead of an inline copy plus an unused parallel mapper that could drift.
This commit is contained in:
1 parent
f5d0b2f696
commit
d33fed60d4
3 files changed
+42
-40
No files matched your search
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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:<id>` when the entry carries Nightscout's `_id`, falling back to
|
||||
* `ns:date:<epochMs>` — 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:<epochMs>` (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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user