mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
fix(targets): read the seven-day basis as one aggregate instead of the whole summary slice
This commit is contained in:
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", () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user