diff --git a/src/app/api/insights/targets/__tests__/route.test.ts b/src/app/api/insights/targets/__tests__/route.test.ts index d9fe9db14..9338f928d 100644 --- a/src/app/api/insights/targets/__tests__/route.test.ts +++ b/src/app/api/insights/targets/__tests__/route.test.ts @@ -39,10 +39,10 @@ vi.mock("@/lib/targets/glucose-read", async (importOriginal) => ({ vi.mock("@/lib/analytics/resting-pulse-read", () => ({ readRestingPulseProxy: vi.fn(), })); -// The verdict's basis: the dashboard's summary slice. Empty here, so every -// item is placed with its own value (`targetVerdictReference`). +// The verdict's basis: the dashboard's seven-day means. Empty here, so every +// item is placed with its newest reading, else its own value. vi.mock("@/lib/analytics/summaries-slice", () => ({ - computeSummariesSlice: vi.fn(async () => ({ summaries: {} })), + readSevenDayMeans: vi.fn(async () => ({})), })); vi.mock("@/lib/db", () => ({ diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 9766410a9..b1a5519a2 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -500,6 +500,47 @@ async function withSleepNightTotals( return slice; } +/** + * The seven-day mean per type, exactly as the slice computes `avg7` (the same + * canonical-source CTE, the same day weighting, the same rounding), without + * the rest of the slice. Sleep is absent: the slice summarises it per night. For a reader that needs only the dashboard's basis, + * such as the target verdicts: one aggregate query, no reading ever leaves + * the database as a row. + */ +export async function readSevenDayMeans( + userId: string, +): Promise> { + const priorityJson = await loadUserSourcePriority(userId); + const rankUnqualified = buildSourceRankCase( + priorityJson, + '"type"', + '"source"', + ); + const rows = await prisma.$queryRawUnsafe< + Array<{ type: string; avg7: number | null }> + >( + ` + WITH cm AS (${canonicalMeasurementsCte(rankUnqualified, "90 days")} + ) + SELECT + m."type"::text AS type, + ${AVG7_SQL}::double precision AS avg7 + FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m + GROUP BY m."type" + `, + userId, + ); + const out: Record = {}; + for (const row of rows) { + // Stored per stage, summarised per night by `withSleepNightTotals`: a + // mean over stage rows is not the slice's sleep figure, so it is left + // out rather than served wrong. Callers fold the nights themselves. + if (row.type === "SLEEP_DURATION") continue; + out[row.type] = round2(row.avg7); + } + return out; +} + /** * Happy path — DAY buckets carry `count / min / max / mean`; a narrow * `$queryRaw` carries only the windowed avgs + regression columns the diff --git a/src/lib/targets/build-response.ts b/src/lib/targets/build-response.ts index 73a0e162f..7b0ddc569 100644 --- a/src/lib/targets/build-response.ts +++ b/src/lib/targets/build-response.ts @@ -31,7 +31,11 @@ import { readMoodDayRollups, } from "@/lib/rollups/mood-rollups"; import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read"; -import { computeSummariesSlice } from "@/lib/analytics/summaries-slice"; +import { readSevenDayMeans } from "@/lib/analytics/summaries-slice"; +import { + summarizeSleepNights, + type SleepStageRow, +} from "@/lib/analytics/sleep-night"; import { buildGlucoseTargets } from "./glucose-builder"; import { readGlucoseTargetSummaries } from "./glucose-read"; import { buildMedicationTarget } from "./medication-builder"; @@ -113,7 +117,7 @@ export async function buildTargetsResponse(user: AuthedUser) { latestMoodEntry, glucoseSummaries, restingPulseProxy, - dashboardSummaries, + sevenDayMeans, ] = await Promise.all([ limit(() => prisma.user.findUnique({ @@ -250,10 +254,9 @@ export async function buildTargetsResponse(user: AuthedUser) { }), ), // The verdict is placed with the value the dashboard tile places it - // with (the seven-day mean, else the latest reading), read from the same - // summary slice, so a target and its tile never disagree on which way a - // change counts. - limit(() => computeSummariesSlice(userId)), + // with: the seven-day mean, computed by the summary slice's own query, + // so a target and its tile never disagree on which way a change counts. + limit(() => readSevenDayMeans(userId)), ]); // Rollup coverage fallback stays an explicit repository read here rather @@ -386,7 +389,15 @@ export async function buildTargetsResponse(user: AuthedUser) { }, thresholdsJson: dbUser?.thresholdsJson ?? null, }, - dashboardSummaries.summaries, + verdictSummaries(sevenDayMeans, latestByType, { + // Sleep the way the dashboard summarises it: per night, from the + // same stage rows and source ladder. + SLEEP_DURATION: summarizeSleepNights( + sleepStageRows as SleepStageRow[], + timezone, + dbUser?.sourcePriorityJson ?? null, + ).summary, + }), ); targets.splice(0, targets.length, ...verdictTargets); @@ -446,6 +457,32 @@ type VerdictSummaries = Readonly< Record >; +/** + * The dashboard's `{ avg7, latest }` per type: the slice's seven-day mean, + * and the newest reading where there is none in the last seven days. + */ +function verdictSummaries( + sevenDayMeans: Readonly>, + latestByType: TargetValueByType, + folded: Readonly< + Record + >, +): VerdictSummaries { + const out: Record = + {}; + const types = new Set([ + ...Object.keys(sevenDayMeans), + ...Object.keys(latestByType), + ]); + for (const type of types) { + out[type] = { + avg7: sevenDayMeans[type] ?? null, + latest: latestByType[type as keyof TargetValueByType] ?? null, + }; + } + return { ...out, ...folded }; +} + /** * The value a target's verdict is placed with, in the stored unit: the same * basis as the dashboard tile (`resolveSummaryVerdicts`), the seven-day mean diff --git a/tests/integration/dense-targets-reads.test.ts b/tests/integration/dense-targets-reads.test.ts index 5cad59b59..f2bf0c2ce 100644 --- a/tests/integration/dense-targets-reads.test.ts +++ b/tests/integration/dense-targets-reads.test.ts @@ -13,6 +13,10 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; import { getPrismaClient, truncateAllTables } from "./setup"; import { deriveRestingProxyFromPulse } from "@/lib/analytics/resting-pulse"; +import { + computeSummariesSlice, + readSevenDayMeans, +} from "@/lib/analytics/summaries-slice"; import { buildTargetsResponse } from "@/lib/targets/build-response"; import { buildGlucoseTargets } from "@/lib/targets/glucose-builder"; import { @@ -294,3 +298,22 @@ describe("targets fold equals the raw-reading fold", () => { }); } }); + +// The target verdict is placed with the dashboard tile's basis. Proven on +// the database, not assumed: the seven-day means the targets read equal the +// `avg7` the dashboard slice serves, type for type, on both accounts. +describe("target verdict basis equals the dashboard's", () => { + for (const userId of [SPARSE, DENSE]) { + it(`reads the slice's seven-day mean (${userId})`, async () => { + const [means, slice] = await Promise.all([ + readSevenDayMeans(userId), + computeSummariesSlice(userId), + ]); + const withData = Object.keys(means); + expect(withData.length).toBeGreaterThan(0); + for (const type of withData) { + expect(means[type], type).toBe(slice.summaries[type]?.avg7 ?? null); + } + }); + } +});