mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The discovery matrix gains the families people log but could not test: water, caffeine and up to four varying micronutrients from the nutrient log, alcohol days from the mood tag, workout minutes (one per session) and the day's training impulse, exercise, stand and mindful minutes, State of Mind as an outcome, and the overnight room climate against that night's sleep only. Every channel runs through the same seasonal adjustment, effective-n p-value, Benjamini-Hochberg control and effect-size floor, is read only while its module is on, and drops out below the paired-day floor. State of Mind, mindful minutes and alcohol days are statistics for the person: the Coach, the period narrative and the assessment cards drop every pair that touches them, after the shared scan so the remaining pairs keep the statistics the correlations page shows. A guard walks the import closure of the analysis modules and refuses any path to a provider.
245 lines
7.0 KiB
TypeScript
245 lines
7.0 KiB
TypeScript
/**
|
||
* v1.44 — the extended correlation channel reads against a real Postgres.
|
||
*
|
||
* The unit battery (`correlation-extended-channels.test.ts`) pins the pure
|
||
* shapers; this suite proves the reads that feed them: the room-climate
|
||
* hour-mean SQL and its zone conversion, the nutrient day sum across sources,
|
||
* the alcohol tag read from the legacy flat list, and the training read with
|
||
* a session stored by two services.
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
|
||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||
import {
|
||
fetchAlcoholSeries,
|
||
fetchIndoorClimateSeries,
|
||
fetchNutrientSeries,
|
||
fetchTrainingSeries,
|
||
} from "@/lib/insights/correlation-channel-series";
|
||
|
||
vi.mock("@/lib/db-compat", () => ({
|
||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||
}));
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const TZ = "Europe/Berlin";
|
||
|
||
let seq = 0;
|
||
async function seedUser(prisma: ReturnType<typeof getPrismaClient>) {
|
||
seq += 1;
|
||
return prisma.user.create({
|
||
data: {
|
||
username: `corr-extended-${seq}`,
|
||
email: `corr-extended-${seq}@example.test`,
|
||
role: "USER",
|
||
timezone: TZ,
|
||
},
|
||
});
|
||
}
|
||
|
||
/** A UTC day key `daysAgo` days back. */
|
||
function dayKey(daysAgo: number): string {
|
||
return new Date(Date.now() - daysAgo * DAY_MS).toISOString().slice(0, 10);
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
await truncateAllTables(getPrismaClient());
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe("extended correlation channels — reads", () => {
|
||
it("folds the room climate to overnight means on the waking day, in the person's zone", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedUser(prisma);
|
||
const wake = dayKey(5);
|
||
const evening = dayKey(6);
|
||
// Berlin is UTC+1 or +2; 20:30 UTC on the evening is at least 21:30
|
||
// local, 02:00 UTC on the waking day at least 03:00 local, both inside
|
||
// the 21:00–07:00 window. Four hours each side, one reading per quarter
|
||
// hour at 600 ppm before midnight and 1000 ppm after.
|
||
const rows = [];
|
||
for (let h = 0; h < 2; h += 1) {
|
||
for (let q = 0; q < 4; q += 1) {
|
||
rows.push({
|
||
value: 600,
|
||
measuredAt: new Date(
|
||
`${evening}T2${h}:${String(q * 15).padStart(2, "0")}:00Z`,
|
||
),
|
||
});
|
||
}
|
||
}
|
||
for (let h = 1; h < 4; h += 1) {
|
||
for (let q = 0; q < 4; q += 1) {
|
||
rows.push({
|
||
value: 1000,
|
||
measuredAt: new Date(
|
||
`${wake}T0${h}:${String(q * 15).padStart(2, "0")}:00Z`,
|
||
),
|
||
});
|
||
}
|
||
}
|
||
// Midday on the waking day is outside the window.
|
||
rows.push({ value: 5000, measuredAt: new Date(`${wake}T11:00:00Z`) });
|
||
await prisma.measurement.createMany({
|
||
data: rows.map((r) => ({
|
||
userId: user.id,
|
||
type: "INDOOR_CO2" as const,
|
||
unit: "ppm",
|
||
source: "EXTERNAL" as const,
|
||
...r,
|
||
})),
|
||
});
|
||
|
||
const series = await fetchIndoorClimateSeries(
|
||
user.id,
|
||
TZ,
|
||
new Date(Date.now() - 30 * DAY_MS),
|
||
);
|
||
const co2 = series.find((s) => s.key === "INDOOR_CO2");
|
||
// Two evening hours at 600, three morning hours at 1000: (1200 + 3000) / 5.
|
||
expect(co2?.points).toEqual([{ day: wake, value: 840 }]);
|
||
expect(co2?.targets).toEqual(["SLEEP_DURATION"]);
|
||
});
|
||
|
||
it("sums a nutrient day across sources", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedUser(prisma);
|
||
await prisma.nutrientIntakeDay.createMany({
|
||
data: [
|
||
{
|
||
userId: user.id,
|
||
day: dayKey(3),
|
||
nutrient: "water",
|
||
amount: 1500,
|
||
unit: "ml",
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
{
|
||
userId: user.id,
|
||
day: dayKey(3),
|
||
nutrient: "water",
|
||
amount: 250,
|
||
unit: "ml",
|
||
source: "MANUAL",
|
||
},
|
||
{
|
||
userId: user.id,
|
||
day: dayKey(3),
|
||
nutrient: "caffeine",
|
||
amount: 160,
|
||
unit: "mg",
|
||
source: "APPLE_HEALTH",
|
||
},
|
||
],
|
||
});
|
||
const series = await fetchNutrientSeries(
|
||
user.id,
|
||
TZ,
|
||
new Date(Date.now() - 30 * DAY_MS),
|
||
);
|
||
expect(series.find((s) => s.key === "HYDRATION")?.points).toEqual([
|
||
{ day: dayKey(3), value: 1750 },
|
||
]);
|
||
expect(series.find((s) => s.key === "CAFFEINE")?.points).toEqual([
|
||
{ day: dayKey(3), value: 160 },
|
||
]);
|
||
});
|
||
|
||
it("reads alcohol days from the flat tag list and skips deleted entries", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedUser(prisma);
|
||
const data = [];
|
||
for (let d = 1; d <= 12; d += 1) {
|
||
data.push({
|
||
userId: user.id,
|
||
date: dayKey(d),
|
||
mood: "GUT",
|
||
score: 4,
|
||
source: "MANUAL",
|
||
moodLoggedAt: new Date(`${dayKey(d)}T12:00:00Z`),
|
||
tags: d % 2 === 0 ? JSON.stringify(["alcohol"]) : null,
|
||
});
|
||
}
|
||
data.push({
|
||
userId: user.id,
|
||
date: dayKey(13),
|
||
mood: "GUT",
|
||
score: 4,
|
||
source: "MANUAL",
|
||
moodLoggedAt: new Date(`${dayKey(13)}T12:00:00Z`),
|
||
tags: JSON.stringify(["alcohol"]),
|
||
deletedAt: new Date(),
|
||
});
|
||
await prisma.moodEntry.createMany({ data });
|
||
const series = await fetchAlcoholSeries(
|
||
user.id,
|
||
TZ,
|
||
new Date(Date.now() - 30 * DAY_MS),
|
||
);
|
||
expect(series.points).toHaveLength(12);
|
||
expect(series.points.filter((p) => p.value === 1)).toHaveLength(6);
|
||
});
|
||
|
||
it("counts a workout stored by two services once and reads the stored impulse", async () => {
|
||
const prisma = getPrismaClient();
|
||
const user = await seedUser(prisma);
|
||
const start = new Date(`${dayKey(4)}T10:00:00Z`);
|
||
await prisma.workout.createMany({
|
||
data: [
|
||
{
|
||
userId: user.id,
|
||
sportType: "RUNNING",
|
||
source: "APPLE_HEALTH",
|
||
startedAt: start,
|
||
endedAt: new Date(start.getTime() + 1800_000),
|
||
durationSec: 1800,
|
||
externalId: "a",
|
||
},
|
||
{
|
||
userId: user.id,
|
||
sportType: "RUNNING",
|
||
source: "STRAVA",
|
||
startedAt: new Date(start.getTime() + 30_000),
|
||
endedAt: new Date(start.getTime() + 1830_000),
|
||
durationSec: 1795,
|
||
externalId: "s",
|
||
},
|
||
],
|
||
});
|
||
await prisma.strainTrimpCache.createMany({
|
||
data: [
|
||
{
|
||
userId: user.id,
|
||
day: dayKey(4),
|
||
dayTrimp: 70,
|
||
refPersonal: 150,
|
||
anchor: "population",
|
||
trainingDays: 1,
|
||
},
|
||
{
|
||
userId: user.id,
|
||
day: dayKey(2),
|
||
dayTrimp: 30,
|
||
refPersonal: 150,
|
||
anchor: "population",
|
||
trainingDays: 2,
|
||
},
|
||
],
|
||
});
|
||
const [minutes, load] = await fetchTrainingSeries(
|
||
user.id,
|
||
TZ,
|
||
new Date(Date.now() - 30 * DAY_MS),
|
||
);
|
||
expect(minutes.points).toEqual([{ day: dayKey(4), value: 30 }]);
|
||
expect(load.points).toEqual([
|
||
{ day: dayKey(4), value: 70 },
|
||
{ day: dayKey(3), value: 0 },
|
||
{ day: dayKey(2), value: 30 },
|
||
]);
|
||
});
|
||
});
|