mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
fix(insights): sleep average via deduped reconstruction + night-reconstruction guard
This commit is contained in:
1 parent
a55816bf58
commit
874794e0e4
9 files changed
+366
-26
No files matched your search
@@ -23,6 +23,12 @@ import {
|
||||
import { buildSystemPromptWithReferences } from "@/lib/ai/prompts/insight-generator";
|
||||
import { metricsFromPresentSections } from "@/lib/ai/medical-references";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
type ComparisonBaseline,
|
||||
@@ -153,16 +159,45 @@ async function buildComparisonSnapshotForUser(
|
||||
const types = Object.keys(typeToSnapshotKey) as MeasurementType[];
|
||||
const rows = await Promise.all(
|
||||
types.map(async (type) => {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
const summary = summarize(
|
||||
measurements.map(
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night; summarising the
|
||||
// raw stage rows mislabels a per-stage average as a night total (and
|
||||
// double-counts a bare ASLEEP aggregate against its granular twin). Route
|
||||
// the comparison through the per-night dedup reconstruction so the
|
||||
// current/baseline averages are per-night TIME-ASLEEP totals in minutes.
|
||||
let dataPoints: DataPoint[];
|
||||
if (type === "SLEEP_DURATION") {
|
||||
const [sleepRows, sleepTz, sleepPriority] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
measuredAt: true,
|
||||
value: true,
|
||||
sleepStage: true,
|
||||
source: true,
|
||||
},
|
||||
}),
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
dataPoints = reconstructSleepNights(
|
||||
sleepRows as SleepStageRow[],
|
||||
sleepTz,
|
||||
sleepPriority,
|
||||
)
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes }));
|
||||
} else {
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where: { userId, type, deletedAt: null },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
});
|
||||
dataPoints = measurements.map(
|
||||
(m): DataPoint => ({ date: m.measuredAt, value: m.value }),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
const summary = summarize(dataPoints);
|
||||
const baselineAvg =
|
||||
baseline === "lastMonth"
|
||||
? (summary.avg30LastMonth ?? null)
|
||||
|
||||
@@ -139,4 +139,37 @@ describe("GET /api/sleep/night", () => {
|
||||
expect(body.data.main).toBeNull();
|
||||
expect(body.data.naps).toEqual([]);
|
||||
});
|
||||
|
||||
// A5 — a reconstruction edge (a session of only bare ASLEEP / stage-less rows
|
||||
// alongside a granular partition for the same span) must return a valid empty
|
||||
// night, NEVER a 500 from the unguarded `segments[0]` access.
|
||||
it("returns 200 + empty night on an IN_BED/AWAKE-only reconstruction, never 500", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2026-06-04T06:00:00.000Z", "IN_BED", 60),
|
||||
stage("2026-06-04T06:00:00.000Z", "AWAKE", 60),
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
expect(res.status).toBe(200); // not 500
|
||||
const body = await res.json();
|
||||
expect(body.data.main).toBeNull();
|
||||
expect(body.data.naps).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 200 on a bare ASLEEP + stage-less + granular mix, never 500", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
stage("2026-06-04T02:00:00.000Z", "DEEP", 1),
|
||||
stage("2026-06-04T06:00:00.000Z", "ASLEEP", 480),
|
||||
// stage-less twin (sleepStage: null) for the same span.
|
||||
{
|
||||
value: 480,
|
||||
measuredAt: new Date("2026-06-04T06:00:00.000Z"),
|
||||
sleepStage: null,
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
] as never);
|
||||
const res = await GET(req("date=2026-06-04"));
|
||||
expect(res.status).toBe(200); // never 500 on the reconstruction edge
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user