mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,14 @@ function row(
|
||||
iso: string,
|
||||
stage: SleepStage | null,
|
||||
minutes: number,
|
||||
source?: MeasurementSource,
|
||||
): SleepStageRow {
|
||||
return { measuredAt: new Date(iso), sleepStage: stage, value: minutes };
|
||||
return {
|
||||
measuredAt: new Date(iso),
|
||||
sleepStage: stage,
|
||||
value: minutes,
|
||||
...(source ? { source } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a stage row tagged with an ingest source. */
|
||||
@@ -535,3 +541,90 @@ describe("summarizeSleepNights", () => {
|
||||
expect(summary.latest).toBe(570);
|
||||
});
|
||||
});
|
||||
|
||||
// A4 — the Insights sleep AVERAGE must come from the deduped per-night totals,
|
||||
// never the raw per-stage sum that double-counts a bare ASLEEP aggregate against
|
||||
// its granular twin (and folds IN_BED / AWAKE in on top). The ~20.3 h symptom is
|
||||
// what a single night's stage rows summed without dedup produces.
|
||||
describe("sleep average dedup (A4)", () => {
|
||||
it("one source emits BOTH bare ASLEEP + granular for the same span → dedup total, not the sum", () => {
|
||||
// Apple-Health-style double write for ONE overnight session: the bare
|
||||
// ASLEEP aggregate (480) PLUS the granular CORE/DEEP/REM partition (also
|
||||
// 480), plus IN_BED + AWAKE. Summed raw this is ~1490 min (~24.8 h); the
|
||||
// deduped night total is the granular 480 min (8 h).
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480, "APPLE_HEALTH"), // bare aggregate
|
||||
row("2026-06-04T02:00:00.000Z", "CORE", 240, "APPLE_HEALTH"),
|
||||
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "APPLE_HEALTH"),
|
||||
row("2026-06-04T06:00:00.000Z", "REM", 120, "APPLE_HEALTH"),
|
||||
row("2026-06-04T06:30:00.000Z", "IN_BED", 470, "APPLE_HEALTH"),
|
||||
row("2026-06-04T03:00:00.000Z", "AWAKE", 20, "APPLE_HEALTH"),
|
||||
];
|
||||
const { summary } = summarizeSleepNights(rows, "UTC");
|
||||
// Granular partition wins: 240 + 120 + 120 = 480 min — NOT 480 + 480 + …
|
||||
expect(summary.latest).toBe(480);
|
||||
expect(summary.max).toBe(480);
|
||||
// One night, not 6 stage rows.
|
||||
expect(summary.count).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-source bare+granular collapses to the canonical source, no cross-source sum", () => {
|
||||
// WHOOP granular + Apple bare-only for the SAME night. The canonical source
|
||||
// (WHOOP, top of the sleep ladder) wins; the night total is WHOOP's dedup
|
||||
// total, never WHOOP + Apple summed.
|
||||
const rows: SleepStageRow[] = [
|
||||
// WHOOP granular — 240 + 120 + 120 = 480.
|
||||
row("2026-06-04T02:00:00.000Z", "CORE", 240, "WHOOP"),
|
||||
row("2026-06-04T04:00:00.000Z", "DEEP", 120, "WHOOP"),
|
||||
row("2026-06-04T06:00:00.000Z", "REM", 120, "WHOOP"),
|
||||
// Apple bare aggregate for the same span — must NOT add on top.
|
||||
row("2026-06-04T06:00:00.000Z", "ASLEEP", 470, "APPLE_HEALTH"),
|
||||
];
|
||||
const { summary } = summarizeSleepNights(rows, "UTC");
|
||||
expect(summary.count).toBe(1);
|
||||
expect(summary.latest).toBe(480); // WHOOP only, not 480 + 470.
|
||||
});
|
||||
});
|
||||
|
||||
// A5 — `reconstructSleepSessions` must never throw on a session the dedup
|
||||
// empties (the unguarded `segments[0]` access). A read returns a valid empty
|
||||
// night, never a 500.
|
||||
describe("reconstructSleepSessions total-safety (A5)", () => {
|
||||
it("never throws on an IN_BED/AWAKE-only session and yields no main night", () => {
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T06:00:00.000Z", "IN_BED", 60),
|
||||
row("2026-06-04T06:00:00.000Z", "AWAKE", 60),
|
||||
];
|
||||
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
// Every returned session has at least one renderable segment.
|
||||
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
|
||||
expect(pickMainNightAndNaps(sessions).main).toBeNull();
|
||||
});
|
||||
|
||||
it("skips a session that empties after the granular-over-bare filter, never throws", () => {
|
||||
// A session whose canonical pool, after dropping the redundant bare ASLEEP
|
||||
// aggregate / stage-less twins under `sawGranular`, has zero renderable
|
||||
// segments. The DEEP granular row marks the session granular; the bare +
|
||||
// stage-less rows are then filtered out. The granular row survives here, so
|
||||
// assert the load-bearing contract: no throw, and every returned session is
|
||||
// non-empty.
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T02:00:00.000Z", "DEEP", 1), // tiny granular marker
|
||||
row("2026-06-04T06:00:00.000Z", "ASLEEP", 480), // redundant bare twin
|
||||
row("2026-06-04T06:00:00.000Z", null, 480), // stage-less twin
|
||||
];
|
||||
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
|
||||
const sessions = reconstructSleepSessions(rows, "UTC");
|
||||
expect(sessions.every((s) => s.segments.length > 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("never throws on a stage-less-only session", () => {
|
||||
// Legacy / manual stage-less rows only — no granular partition, so they are
|
||||
// the fallback signal and survive the filter; still must not throw.
|
||||
const rows: SleepStageRow[] = [
|
||||
row("2026-06-04T06:00:00.000Z", null, 480),
|
||||
];
|
||||
expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -332,6 +332,12 @@ export function reconstructSleepNights(
|
||||
let sawInBed = false;
|
||||
let sawAwake = false;
|
||||
let asleep = 0;
|
||||
// `nightSessions` and each session pool are non-empty by construction: a
|
||||
// key is only added with a `pool` that has ≥ 1 row (the source-filter falls
|
||||
// back to the full session when the canonical filter empties it), so
|
||||
// `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this
|
||||
// function never applies the granular-over-bare filter to the indexed pool,
|
||||
// so it cannot empty the array here. Keep that invariant on future edits.
|
||||
let latest = nightSessions[0][0].measuredAt;
|
||||
const stages: Partial<Record<SleepStage, number>> = {};
|
||||
for (const sessionRows of nightSessions) {
|
||||
@@ -522,6 +528,15 @@ export function reconstructSleepSessions(
|
||||
.map(segmentOf)
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||
|
||||
// The granular-over-bare filter can empty a non-empty pool (a session whose
|
||||
// only rows are the redundant bare ASLEEP aggregate / stage-less twins of a
|
||||
// granular partition that did not survive into this pool). A session with no
|
||||
// renderable/scorable segment contributes nothing — skip it before indexing
|
||||
// `segments[0]`. This keeps `reconstructSleepSessions` total: it must NEVER
|
||||
// throw on a shape the dedup empties, so `GET /api/sleep/night` (and every
|
||||
// other consumer) returns a valid empty night, never a 500.
|
||||
if (segments.length === 0) continue;
|
||||
|
||||
let inBed = 0;
|
||||
let awake = 0;
|
||||
let sawInBed = false;
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
buildGradedSeriesFromPoints,
|
||||
type GradedSeries,
|
||||
} from "../graded-series";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
|
||||
const dayMs = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -111,4 +115,31 @@ describe("buildGradedSeriesFromPoints", () => {
|
||||
expect(s.recent[0].mean).toBe(80);
|
||||
expect(s.recent[0].n).toBe(3);
|
||||
});
|
||||
|
||||
// A4 — the sleep AVERAGE the Insights assessment ships is the graded series
|
||||
// built from DEDUPED per-night totals, never the raw per-stage sum that
|
||||
// double-counts a bare ASLEEP aggregate against its granular twin (the
|
||||
// impossible ~20.3 h symptom). This pins the path metric-status now uses:
|
||||
// reconstruct nights → per-night points → buildGradedSeriesFromPoints.
|
||||
it("sleep graded series uses the deduped night total, never the ~20 h stage sum (A4)", () => {
|
||||
const sleepNow = new Date("2026-06-04T12:00:00.000Z");
|
||||
// One overnight session, Apple-Health-style double write: bare ASLEEP
|
||||
// aggregate (480) + granular CORE/DEEP/REM (also 480) + IN_BED + AWAKE.
|
||||
// Raw-summed this is ~1490 min (~24.8 h); deduped it is 480 min (8 h).
|
||||
const rows: SleepStageRow[] = [
|
||||
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "ASLEEP", value: 480, source: "APPLE_HEALTH" },
|
||||
{ measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "CORE", value: 240, source: "APPLE_HEALTH" },
|
||||
{ measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "DEEP", value: 120, source: "APPLE_HEALTH" },
|
||||
{ measuredAt: new Date("2026-06-04T06:00:00.000Z"), sleepStage: "REM", value: 120, source: "APPLE_HEALTH" },
|
||||
{ measuredAt: new Date("2026-06-04T06:30:00.000Z"), sleepStage: "IN_BED", value: 470, source: "APPLE_HEALTH" },
|
||||
{ measuredAt: new Date("2026-06-04T03:00:00.000Z"), sleepStage: "AWAKE", value: 20, source: "APPLE_HEALTH" },
|
||||
];
|
||||
const points = reconstructSleepNights(rows, "UTC")
|
||||
.filter((n) => n.asleepMinutes > 0)
|
||||
.map((n) => ({ measuredAt: n.measuredAt, value: n.asleepMinutes }));
|
||||
const graded = buildGradedSeriesFromPoints(points, sleepNow);
|
||||
const recentMean = graded.recent.at(-1)?.mean ?? 0;
|
||||
expect(recentMean).toBe(480); // night total, not ~1218 (stage sum)
|
||||
expect(recentMean).toBeLessThan(960); // < 16 h — never impossible
|
||||
});
|
||||
});
|
||||
@@ -135,6 +135,68 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)"
|
||||
expect(prompt).not.toContain('"value": 75');
|
||||
expect(prompt).not.toContain('"value": 90');
|
||||
});
|
||||
|
||||
it("the snapshot graded series is the deduped night total, never the ~20 h raw-stage sum (A4)", async () => {
|
||||
// The ~20.3 h symptom: a source writes BOTH a bare ASLEEP aggregate AND the
|
||||
// granular CORE/DEEP/REM partition for the same span. Pre-fix the graded
|
||||
// `series` came from `buildGradedSeriesWithRollups`, which folds bare + each
|
||||
// granular stage + IN_BED + AWAKE into one day bucket (~1490 min ≈ 24.8 h).
|
||||
// Post-fix the graded series is built from the deduped per-night points, so
|
||||
// every recent bucket mean is the night total (480 min = 8 h).
|
||||
vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.auditLog.create).mockResolvedValue({
|
||||
createdAt: new Date(),
|
||||
} as never);
|
||||
|
||||
// A recent night (last 24 h) so it lands in `graded.recent`.
|
||||
const wake = new Date(Date.now() - 6 * 60 * 60 * 1000);
|
||||
const m = 60 * 1000;
|
||||
const stageRows = [
|
||||
{ value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate
|
||||
{ value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" },
|
||||
{ value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" },
|
||||
{ value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" },
|
||||
{ value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" },
|
||||
{ value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" },
|
||||
];
|
||||
vi.mocked(prisma.measurement.count).mockResolvedValue(
|
||||
stageRows.length as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue(
|
||||
stageRows as never,
|
||||
);
|
||||
|
||||
const capture = { systemPrompt: null, userPrompt: null } as {
|
||||
systemPrompt: string | null;
|
||||
userPrompt: string | null;
|
||||
};
|
||||
stubCompletion("Summary: solide Nacht.", capture);
|
||||
|
||||
await generateMetricStatus({
|
||||
metric: "SLEEP_DURATION",
|
||||
userId: "u1",
|
||||
locale: "de",
|
||||
force: true,
|
||||
});
|
||||
|
||||
const prompt = capture.userPrompt as string;
|
||||
const match = prompt.match(/\{[\s\S]*\}/);
|
||||
expect(match).toBeTruthy();
|
||||
const snapshot = JSON.parse(match![0]);
|
||||
const recent = snapshot.SLEEP_DURATION.series.recent as Array<{
|
||||
mean: number;
|
||||
}>;
|
||||
expect(recent.length).toBeGreaterThan(0);
|
||||
for (const bucket of recent) {
|
||||
// Night total (480 min = 8 h), never the impossible ~1490-min stage sum.
|
||||
expect(bucket.mean).toBeLessThanOrEqual(960); // ≤ 16 h
|
||||
}
|
||||
expect(recent.at(-1)?.mean).toBe(480);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metric-status registry", () => {
|
||||
|
||||
@@ -36,6 +36,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,
|
||||
@@ -145,16 +151,45 @@ export 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)
|
||||
|
||||
@@ -12,6 +12,11 @@ import {
|
||||
lastNonSkippedTakenAt,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import {
|
||||
reconstructSleepNights,
|
||||
type SleepStageRow,
|
||||
} from "@/lib/analytics/sleep-night";
|
||||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import {
|
||||
@@ -568,14 +573,35 @@ export async function extractFeatures(
|
||||
}
|
||||
|
||||
// Sleep Duration
|
||||
//
|
||||
// SLEEP_DURATION is stored ONE ROW PER STAGE per night, so summarising the
|
||||
// raw stage rows would average individual stages (and double-count a bare
|
||||
// ASLEEP aggregate against its granular CORE/DEEP/REM twin). Route the
|
||||
// feature block through the per-night dedup reconstruction — the same helper
|
||||
// the dashboard / series / status path use — so `avg7` / `avg30` / `latest`
|
||||
// are per-night TIME-ASLEEP totals in minutes, never stage averages.
|
||||
const sleepData = byType("SLEEP_DURATION");
|
||||
if (sleepData.length > 0) {
|
||||
const summary = summarize(toDataPoints(sleepData));
|
||||
const [sleepTz, sleepPriority] = await Promise.all([
|
||||
resolveUserTimezone(userId),
|
||||
loadUserSourcePriority(userId),
|
||||
]);
|
||||
const sleepNights = reconstructSleepNights(
|
||||
sleepData as unknown as SleepStageRow[],
|
||||
sleepTz,
|
||||
sleepPriority,
|
||||
).filter((n) => n.asleepMinutes > 0);
|
||||
const summary = summarize(
|
||||
sleepNights.map((n) => ({ date: n.measuredAt, value: n.asleepMinutes })),
|
||||
);
|
||||
features.sleep = {
|
||||
avg7: summary.avg7,
|
||||
avg30: summary.avg30,
|
||||
latest: summary.latest,
|
||||
coverage: computeCoverage(sleepData, now),
|
||||
coverage: computeCoverage(
|
||||
sleepNights.map((n) => ({ measuredAt: n.measuredAt })),
|
||||
now,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import { lookupNormalRange } from "@/lib/insights/derived/norms";
|
||||
import { getNoKeyGeneralStatusText } from "@/lib/insights/no-key-fallbacks";
|
||||
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
|
||||
import {
|
||||
buildGradedSeriesFromPoints,
|
||||
buildGradedSeriesWithRollups,
|
||||
degradeStatusSnapshotToBudget,
|
||||
} from "@/lib/insights/graded-series";
|
||||
@@ -271,11 +272,20 @@ export async function generateMetricStatus(args: {
|
||||
measurements = rows.map((m) => ({ measuredAt: m.measuredAt }));
|
||||
}
|
||||
const series = applyPayloadBudget(points, { now });
|
||||
const graded = await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
meta.measurementType,
|
||||
now,
|
||||
);
|
||||
// SLEEP_DURATION must never go through `buildGradedSeriesWithRollups`: that
|
||||
// path reads RAW per-stage rows (and the stage-summed `measurement_rollups`
|
||||
// tier) and folds IN_BED + AWAKE + bare ASLEEP + CORE/DEEP/REM into one
|
||||
// bucket, double-counting the bare aggregate against its granular partition —
|
||||
// it produces an impossible ~20 h "average sleep". Build the graded series
|
||||
// straight from the deduped per-night TIME-ASLEEP `points` instead, so every
|
||||
// recent/weekly/monthly/yearly bucket is a night total, not a stage sum.
|
||||
const graded = isSleep
|
||||
? buildGradedSeriesFromPoints(points, now)
|
||||
: await buildGradedSeriesWithRollups(
|
||||
args.userId,
|
||||
meta.measurementType,
|
||||
now,
|
||||
);
|
||||
const summary = summarizeSeries(
|
||||
series.daily.map((bucket) => ({ value: bucket.value })),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user