mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge branch 'feat/v144-userneeds' into release/v1.44.0
# Conflicts: # src/__tests__/sharing-surface-guard.test.ts # src/__tests__/workout-canonical-identity-guard.test.ts
This commit is contained in:
commit
aa899a9d54
86 files changed
+12949
-9
No files matched your search
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The activity calendar's reader against a real Postgres (D1036).
|
||||
*
|
||||
* - steps resolve to the source the person's ladder picks per day, so a day
|
||||
* two devices counted shows one device's total, never the sum;
|
||||
* - the day is the person's local day: a reading at 23:30 Berlin time lands
|
||||
* on that day, not on the UTC day after;
|
||||
* - a workout two devices recorded counts once, its minutes once;
|
||||
* - the window is exact: nothing before `fromKey` or after `toKey`.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { readActivityDays } from "@/lib/activity/calendar-read";
|
||||
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const TZ = "Europe/Berlin";
|
||||
const USER = "activity-reader";
|
||||
const DAY = 86_400_000;
|
||||
const NOW = new Date();
|
||||
|
||||
function daysAgo(n: number, hourUtc = 10): Date {
|
||||
const d = new Date(NOW.getTime() - n * DAY);
|
||||
d.setUTCHours(hourUtc, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER,
|
||||
username: USER,
|
||||
timezone: TZ,
|
||||
sourcePriorityJson: {
|
||||
metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
invalidateUserTimezone(USER);
|
||||
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
// Day 3: both devices; the ladder picks Fitbit's 8 000.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 9500,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8000,
|
||||
unit: "steps",
|
||||
source: "FITBIT",
|
||||
measuredAt: daysAgo(3),
|
||||
},
|
||||
// Day 2: Apple only, two slices that add up.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(2, 8),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 4000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(2, 15),
|
||||
},
|
||||
// 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(5, 21),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "EXERCISE_MINUTES",
|
||||
value: 25,
|
||||
unit: "min",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3, 9),
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
type: "EXERCISE_MINUTES",
|
||||
value: 15,
|
||||
unit: "min",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(3, 17),
|
||||
},
|
||||
// Outside the window asked for below.
|
||||
{
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 777,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: daysAgo(40),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const start = daysAgo(3, 7);
|
||||
await prisma.workout.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: USER,
|
||||
sportType: "running",
|
||||
startedAt: start,
|
||||
endedAt: new Date(start.getTime() + 40 * 60_000),
|
||||
durationSec: 2400,
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "hk-1",
|
||||
},
|
||||
{
|
||||
userId: USER,
|
||||
sportType: "running",
|
||||
startedAt: new Date(start.getTime() + 30_000),
|
||||
endedAt: new Date(start.getTime() + 40 * 60_000),
|
||||
durationSec: 2370,
|
||||
source: "FITBIT",
|
||||
externalId: "fb-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("readActivityDays", () => {
|
||||
it("follows the ladder, the local day and the workout collapse", async () => {
|
||||
const fromKey = userDayKey(daysAgo(10), TZ);
|
||||
const toKey = userDayKey(NOW, TZ);
|
||||
const { days } = await readActivityDays({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
fromKey,
|
||||
toKey,
|
||||
now: NOW,
|
||||
includeWorkouts: true,
|
||||
});
|
||||
const by = new Map(days.map((d) => [d.date, d]));
|
||||
|
||||
const d3 = by.get(userDayKey(daysAgo(3), TZ));
|
||||
expect(d3?.steps).toBe(8000);
|
||||
expect(d3?.exerciseMinutes).toBe(40);
|
||||
expect(d3?.workoutCount).toBe(1);
|
||||
expect(d3?.workoutsBySport).toEqual({ running: 1 });
|
||||
expect(d3?.workoutMinutes).toBe(40);
|
||||
|
||||
expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000);
|
||||
expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200);
|
||||
expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true);
|
||||
expect(days.some((d) => d.steps === 777)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* The lab and medication CSV imports against a real Postgres (#1215).
|
||||
*
|
||||
* - a dry run writes nothing (no reading, no marker, no medication) and
|
||||
* reports what the real run then does;
|
||||
* - names resolve to existing markers, new analytes mint one marker each,
|
||||
* a reading in another unit is converted and keeps what the file said;
|
||||
* - re-uploading the same file writes nothing: every row is a duplicate;
|
||||
* - a refused row is reported by line and does not stop the rest;
|
||||
* - medications take a daily schedule, reminders off, as-needed without
|
||||
* schedule, and a dated window opens a course.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { textToStream } from "@/lib/import/csv-stream";
|
||||
import { importLabsCsv } from "@/lib/import/labs-csv";
|
||||
import { importMedicationsCsv } from "@/lib/import/medications-csv";
|
||||
import {
|
||||
labsCsvQuerySchema,
|
||||
medicationsCsvQuerySchema,
|
||||
} from "@/lib/validations/record-import";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "csv-import-user";
|
||||
const TZ = "Europe/Berlin";
|
||||
|
||||
const LABS = [
|
||||
"Analyt;Wert;Einheit;Datum;Referenz",
|
||||
"Glucose;5,5;mmol/L;01.03.2025;3,9 - 5,5",
|
||||
"Glukose;99;mg/dL;02.03.2025;",
|
||||
"HbA1c;5,4;%;01.03.2025;4,0-5,6",
|
||||
"Urinstatus Eiweiß;negativ;;01.03.2025;",
|
||||
"Ferritin;1.234;ng/mL;01.03.2025;",
|
||||
"Ferritin;85;;01.03.2025;",
|
||||
"Neuer Marker;12;U/l;03.03.2025;",
|
||||
].join("\n");
|
||||
|
||||
function labsQuery(dryRun: boolean) {
|
||||
return labsCsvQuerySchema.parse({
|
||||
analyte: "0",
|
||||
value: "1",
|
||||
unit: "2",
|
||||
date: "3",
|
||||
referenceRange: "4",
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
dryRun: dryRun ? "1" : "0",
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({
|
||||
data: { id: USER, username: USER, timezone: TZ },
|
||||
});
|
||||
await prisma.biomarker.create({
|
||||
// No analyte key stored: the import derives it the way the write does.
|
||||
data: { userId: USER, name: "Glucose", unit: "mg/dL" },
|
||||
});
|
||||
await prisma.biomarkerAlias.create({
|
||||
data: {
|
||||
userId: USER,
|
||||
biomarkerId: (
|
||||
await prisma.biomarker.findFirstOrThrow({ where: { userId: USER } })
|
||||
).id,
|
||||
name: "Glukose",
|
||||
nameFolded: "glukose",
|
||||
source: "MANUAL",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("lab CSV import", () => {
|
||||
it("a dry run writes nothing and reports the plan", async () => {
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(LABS, 16),
|
||||
maxBytes: 1_000_000,
|
||||
query: labsQuery(true),
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
rows: 7,
|
||||
valid: 5,
|
||||
inserted: 5,
|
||||
failed: 2,
|
||||
duplicates: 0,
|
||||
});
|
||||
expect(result.errors).toEqual([
|
||||
{ line: 6, reason: "ambiguous_number", field: "value" },
|
||||
{ line: 7, reason: "missing_unit", field: "unit" },
|
||||
]);
|
||||
expect(result.markers?.created.sort()).toEqual([
|
||||
"HbA1c",
|
||||
"Neuer Marker",
|
||||
"Urinstatus Eiweiß",
|
||||
]);
|
||||
expect(result.markers?.matched.map((m) => m.how).sort()).toEqual([
|
||||
"alias",
|
||||
"name",
|
||||
]);
|
||||
expect(await prisma.labResult.count({ where: { userId: USER } })).toBe(0);
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(1);
|
||||
});
|
||||
|
||||
it("the import converts, mints and keeps what the file said", async () => {
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(LABS, 16),
|
||||
maxBytes: 1_000_000,
|
||||
query: labsQuery(false),
|
||||
});
|
||||
expect(result).toMatchObject({ inserted: 5, failed: 2 });
|
||||
const glucose = await prisma.labResult.findMany({
|
||||
where: { userId: USER, analyte: "Glucose" },
|
||||
orderBy: { takenAt: "asc" },
|
||||
});
|
||||
expect(glucose).toHaveLength(2);
|
||||
expect(glucose[0].unit).toBe("mg/dL");
|
||||
expect(glucose[0].value).toBeCloseTo(99.1, 0);
|
||||
expect(glucose[0].sourceValue).toBe(5.5);
|
||||
expect(glucose[0].sourceUnit).toBe("mmol/L");
|
||||
expect(glucose[0].source).toBe("IMPORT");
|
||||
expect(glucose[1].value).toBe(99);
|
||||
const text = await prisma.labResult.findFirstOrThrow({
|
||||
where: { userId: USER, valueText: "negativ" },
|
||||
});
|
||||
expect(text.value).toBeNull();
|
||||
expect(await prisma.biomarker.count({ where: { userId: USER } })).toBe(4);
|
||||
});
|
||||
|
||||
it("re-uploading the same file writes nothing", async () => {
|
||||
const before = await prisma.labResult.count({ where: { userId: USER } });
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(LABS, 7),
|
||||
maxBytes: 1_000_000,
|
||||
query: labsQuery(false),
|
||||
});
|
||||
expect(result).toMatchObject({ inserted: 0, duplicates: 5, failed: 2 });
|
||||
expect(await prisma.labResult.count({ where: { userId: USER } })).toBe(
|
||||
before,
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a unit that does not convert into the marker's", async () => {
|
||||
const result = await importLabsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(
|
||||
"Analyt;Wert;Einheit;Datum\nGlucose;7;mmHg;05.03.2025\n",
|
||||
),
|
||||
maxBytes: 1_000_000,
|
||||
query: labsCsvQuerySchema.parse({
|
||||
analyte: "0",
|
||||
value: "1",
|
||||
unit: "2",
|
||||
date: "3",
|
||||
decimal: "comma",
|
||||
dateOrder: "dmy",
|
||||
}),
|
||||
});
|
||||
expect(result.errors).toEqual([
|
||||
{ line: 2, reason: "unit_mismatch", field: "unit" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("medication CSV import", () => {
|
||||
const MEDS = [
|
||||
"name,dose,times,as needed,start,end",
|
||||
"Ramipril,5 mg,08:00,,2025-01-01,",
|
||||
"Metformin,500 mg,08:00 20:00,,,",
|
||||
"Ibuprofen,400 mg,,yes,,",
|
||||
"Vitamin D,1000 IE,,,,",
|
||||
"ramipril,5 MG,08:00,,,",
|
||||
].join("\n");
|
||||
const query = (dryRun: boolean) =>
|
||||
medicationsCsvQuerySchema.parse({
|
||||
name: "0",
|
||||
dose: "1",
|
||||
times: "2",
|
||||
asNeeded: "3",
|
||||
startsOn: "4",
|
||||
endsOn: "5",
|
||||
dryRun: dryRun ? "1" : "0",
|
||||
});
|
||||
|
||||
it("checks, then imports, then skips everything on a second upload", async () => {
|
||||
const dry = await importMedicationsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(MEDS),
|
||||
maxBytes: 100_000,
|
||||
query: query(true),
|
||||
});
|
||||
expect(dry).toMatchObject({
|
||||
rows: 5,
|
||||
inserted: 3,
|
||||
duplicates: 1,
|
||||
failed: 1,
|
||||
});
|
||||
expect(dry.errors).toEqual([
|
||||
{ line: 5, reason: "missing_times", field: "times" },
|
||||
{ line: 6, reason: "duplicate" },
|
||||
]);
|
||||
expect(await prisma.medication.count({ where: { userId: USER } })).toBe(0);
|
||||
|
||||
const real = await importMedicationsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(MEDS),
|
||||
maxBytes: 100_000,
|
||||
query: query(false),
|
||||
});
|
||||
expect(real).toMatchObject({ inserted: 3, duplicates: 1, failed: 1 });
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { userId: USER },
|
||||
include: { schedules: true, courses: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
const byName = new Map(meds.map((m) => [m.name, m]));
|
||||
expect(byName.get("Metformin")?.schedules[0]).toMatchObject({
|
||||
rrule: "FREQ=DAILY",
|
||||
timesOfDay: ["08:00", "20:00"],
|
||||
});
|
||||
expect(byName.get("Metformin")?.notificationsEnabled).toBe(false);
|
||||
expect(byName.get("Ibuprofen")).toMatchObject({ asNeeded: true });
|
||||
expect(byName.get("Ibuprofen")?.schedules).toHaveLength(0);
|
||||
expect(byName.get("Ramipril")?.courses).toHaveLength(1);
|
||||
|
||||
const again = await importMedicationsCsv({
|
||||
userId: USER,
|
||||
tz: TZ,
|
||||
stream: textToStream(MEDS),
|
||||
maxBytes: 100_000,
|
||||
query: query(false),
|
||||
});
|
||||
expect(again).toMatchObject({ inserted: 0, duplicates: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The provenance page names the source the charts actually use (#1204).
|
||||
*
|
||||
* Against a real Postgres: for every day of a two-source account, the source
|
||||
* `readDayProvenance` reports as picked is the source the canonical CTE keeps
|
||||
* (`canonicalMeasurementsCte`, which the series, rollups and summaries read
|
||||
* through), under the default ladder and after the person reorders it. Sleep
|
||||
* day values count asleep stages only, and the comparison lines the two
|
||||
* sources up per day.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import {
|
||||
buildSourceRankCase,
|
||||
canonicalMeasurementsCte,
|
||||
} from "@/lib/analytics/source-rank-sql";
|
||||
import {
|
||||
readDayProvenance,
|
||||
readSourceComparison,
|
||||
} from "@/lib/provenance/source-provenance";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const USER = "provenance-user";
|
||||
const DAY = 86_400_000;
|
||||
const base = new Date(Date.now() - 10 * DAY);
|
||||
base.setUTCHours(0, 0, 0, 0);
|
||||
const dayKey = (n: number) =>
|
||||
new Date(base.getTime() + n * DAY).toISOString().slice(0, 10);
|
||||
const at = (n: number, hour: number) =>
|
||||
new Date(base.getTime() + n * DAY + hour * 3_600_000);
|
||||
|
||||
beforeAll(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
await prisma.user.create({ data: { id: USER, username: USER } });
|
||||
const rows: Prisma.MeasurementCreateManyInput[] = [];
|
||||
for (let d = 0; d < 5; d++) {
|
||||
// Oura on every day, WHOOP on days 0, 2, 4.
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 300 + d,
|
||||
unit: "min",
|
||||
source: "OURA",
|
||||
measuredAt: at(d, 3),
|
||||
sleepStage: "CORE",
|
||||
});
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 90,
|
||||
unit: "min",
|
||||
source: "OURA",
|
||||
measuredAt: at(d, 5),
|
||||
sleepStage: "DEEP",
|
||||
});
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 30,
|
||||
unit: "min",
|
||||
source: "OURA",
|
||||
measuredAt: at(d, 6),
|
||||
sleepStage: "AWAKE",
|
||||
});
|
||||
if (d % 2 === 0) {
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
value: 420,
|
||||
unit: "min",
|
||||
source: "WHOOP",
|
||||
measuredAt: at(d, 4),
|
||||
sleepStage: "ASLEEP",
|
||||
});
|
||||
}
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 8000,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: at(d, 12),
|
||||
});
|
||||
rows.push({
|
||||
userId: USER,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 7000,
|
||||
unit: "steps",
|
||||
source: "FITBIT",
|
||||
measuredAt: at(d, 12),
|
||||
});
|
||||
}
|
||||
await prisma.measurement.createMany({ data: rows });
|
||||
});
|
||||
|
||||
async function canonicalSource(type: string, date: string, priority: unknown) {
|
||||
const rank = buildSourceRankCase(priority, '"type"', '"source"');
|
||||
const found = await prisma.$queryRawUnsafe<Array<{ source: string }>>(
|
||||
`SELECT DISTINCT m."source"::text AS source FROM (${canonicalMeasurementsCte(rank, "30 days")}) m
|
||||
WHERE m."type"::text = $2 AND to_char(date_trunc('day', m."measured_at"), 'YYYY-MM-DD') = $3`,
|
||||
USER,
|
||||
type,
|
||||
date,
|
||||
);
|
||||
return found.map((r) => r.source);
|
||||
}
|
||||
|
||||
describe("provenance against the canonical pick", () => {
|
||||
it.each([
|
||||
["default ladder", null],
|
||||
[
|
||||
"reordered",
|
||||
{
|
||||
metricPriority: {
|
||||
sleep: ["OURA", "WHOOP"],
|
||||
steps: ["FITBIT", "APPLE_HEALTH"],
|
||||
},
|
||||
},
|
||||
],
|
||||
])("%s", async (_label, priority) => {
|
||||
await prisma.user.update({
|
||||
where: { id: USER },
|
||||
data: { sourcePriorityJson: priority ?? undefined },
|
||||
});
|
||||
if (priority === null) {
|
||||
await prisma.$executeRawUnsafe(
|
||||
`UPDATE users SET source_priority_json = NULL WHERE id = $1`,
|
||||
USER,
|
||||
);
|
||||
}
|
||||
for (const type of ["SLEEP_DURATION", "ACTIVITY_STEPS"] as const) {
|
||||
for (let d = 0; d < 5; d++) {
|
||||
const p = await readDayProvenance({
|
||||
userId: USER,
|
||||
type,
|
||||
date: dayKey(d),
|
||||
});
|
||||
expect(await canonicalSource(type, dayKey(d), priority)).toEqual([
|
||||
p.picked,
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("counts asleep stages only and compares per day", async () => {
|
||||
const p = await readDayProvenance({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
date: dayKey(1),
|
||||
});
|
||||
expect(p.sources).toHaveLength(1);
|
||||
expect(p.sources[0].value).toBe(301 + 90);
|
||||
expect(p.pickReason).toBe("only_source");
|
||||
|
||||
const c = await readSourceComparison({
|
||||
userId: USER,
|
||||
type: "SLEEP_DURATION",
|
||||
from: dayKey(0),
|
||||
to: dayKey(4),
|
||||
a: "OURA",
|
||||
b: "WHOOP",
|
||||
});
|
||||
expect(c.summary.daysA).toBe(5);
|
||||
expect(c.summary.daysB).toBe(3);
|
||||
expect(c.summary.daysBoth).toBe(3);
|
||||
expect(c.days[1]).toMatchObject({ a: 391, b: null, picked: "OURA" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user