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:
Marc-André Bombeck committed 2026-10-21 22:24:01 +02:00
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);
});
});
+251
View File
@@ -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 });
});
});
+174
View File
@@ -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" });
});
});