fix(targets): read the seven-day basis as one aggregate instead of the whole summary slice

This commit is contained in:
Marc-André Bombeck committed 2026-10-16 05:49:51 +02:00
1 parent 13c74c4e92
commit abbb424d21
4 files changed
+111 -10

No files matched your search

@@ -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", () => ({
+41
View File
@@ -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<Record<string, number | null>> {
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<string, number | null> = {};
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
+44 -7
View File
@@ -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<string, { avg7: number | null; latest: number | null } | undefined>
>;
/**
* 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<Record<string, number | null>>,
latestByType: TargetValueByType,
folded: Readonly<
Record<string, { avg7: number | null; latest: number | null }>
>,
): VerdictSummaries {
const out: Record<string, { avg7: number | null; latest: number | null }> =
{};
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
@@ -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);
}
});
}
});