mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
fix(targets): place the target verdict on the dashboard's seven-day basis
This commit is contained in:
1 parent
6776decaa0
commit
2fc74e76df
2 files changed
+163
-20
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
|
||||
import { targetVerdictReference, withTargetVerdicts } from "../build-response";
|
||||
import { resolveSummaryVerdicts } from "../trend-verdict";
|
||||
import type { TargetItem } from "../types";
|
||||
|
||||
const OWN_BP = { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } };
|
||||
const CTX = {
|
||||
profile: { dateOfBirth: null, gender: null, heightCm: null },
|
||||
thresholdsJson: OWN_BP,
|
||||
};
|
||||
|
||||
function item(
|
||||
type: string,
|
||||
current: number | null,
|
||||
average30: number | null = null,
|
||||
): TargetItem {
|
||||
return {
|
||||
type,
|
||||
label: type,
|
||||
current,
|
||||
average30,
|
||||
trend: "up",
|
||||
unit: "",
|
||||
range: null,
|
||||
classification: null,
|
||||
source: "",
|
||||
} as TargetItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* The target tile and the dashboard tile judge the same change, so they are
|
||||
* placed with the same value: the seven-day mean, else the latest reading,
|
||||
* from the dashboard's summary slice.
|
||||
*/
|
||||
describe("target verdict basis", () => {
|
||||
it("places a target with the dashboard's seven-day mean, not its last reading", () => {
|
||||
// Last reading 118 (inside the own band), seven-day mean 132 (above it).
|
||||
const summaries = { BLOOD_PRESSURE_SYS: { avg7: 132, latest: 118 } };
|
||||
const [bp] = withTargetVerdicts(
|
||||
[item("BLOOD_PRESSURE", 118, 121)],
|
||||
CTX,
|
||||
summaries,
|
||||
);
|
||||
const dashboard = resolveSummaryVerdicts(summaries, CTX);
|
||||
expect(bp!.direction).toEqual(dashboard.BLOOD_PRESSURE_SYS);
|
||||
expect(bp!.direction?.targetPosition).toBe("above");
|
||||
});
|
||||
|
||||
it("follows the resting heart rate for the pulse target when it exists", () => {
|
||||
expect(
|
||||
targetVerdictReference(
|
||||
item("PULSE", 70),
|
||||
{ RESTING_HEART_RATE: { avg7: 52, latest: 50 } },
|
||||
undefined,
|
||||
),
|
||||
).toBe(52);
|
||||
expect(
|
||||
targetVerdictReference(
|
||||
item("PULSE", 70),
|
||||
{ PULSE: { avg7: null, latest: 64 } },
|
||||
undefined,
|
||||
),
|
||||
).toBe(64);
|
||||
});
|
||||
|
||||
it("judges BMI by the weight summary", () => {
|
||||
expect(
|
||||
targetVerdictReference(
|
||||
item("BMI", 24),
|
||||
{ WEIGHT: { avg7: 80, latest: 81 } },
|
||||
undefined,
|
||||
),
|
||||
).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to the item's own value without a summary", () => {
|
||||
expect(
|
||||
targetVerdictReference(item("SLEEP_DURATION", 7), {}, undefined),
|
||||
).toBe(420);
|
||||
expect(
|
||||
targetVerdictReference(item("BMI", 24), {}, item("WEIGHT", null, 79)),
|
||||
).toBe(79);
|
||||
expect(
|
||||
targetVerdictReference(item("MOOD_SCORE", null), {}, undefined),
|
||||
).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
readMoodDayRollups,
|
||||
} from "@/lib/rollups/mood-rollups";
|
||||
import { readRestingPulseProxy } from "@/lib/analytics/resting-pulse-read";
|
||||
import { computeSummariesSlice } from "@/lib/analytics/summaries-slice";
|
||||
import { buildGlucoseTargets } from "./glucose-builder";
|
||||
import { readGlucoseTargetSummaries } from "./glucose-read";
|
||||
import { buildMedicationTarget } from "./medication-builder";
|
||||
@@ -112,6 +113,7 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
latestMoodEntry,
|
||||
glucoseSummaries,
|
||||
restingPulseProxy,
|
||||
dashboardSummaries,
|
||||
] = await Promise.all([
|
||||
limit(() =>
|
||||
prisma.user.findUnique({
|
||||
@@ -247,6 +249,11 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
timeZone: timezone,
|
||||
}),
|
||||
),
|
||||
// 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)),
|
||||
]);
|
||||
|
||||
// Rollup coverage fallback stays an explicit repository read here rather
|
||||
@@ -369,14 +376,18 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
|
||||
// v1.43 (ios #116 item 3) — every item carries which way its change
|
||||
// counts, judged with the same rule the dashboard tiles use.
|
||||
const verdictTargets = withTargetVerdicts(targets, {
|
||||
profile: {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
const verdictTargets = withTargetVerdicts(
|
||||
targets,
|
||||
{
|
||||
profile: {
|
||||
heightCm,
|
||||
dateOfBirth: dbUser?.dateOfBirth ?? null,
|
||||
gender: dbUser?.gender ?? null,
|
||||
},
|
||||
thresholdsJson: dbUser?.thresholdsJson ?? null,
|
||||
},
|
||||
thresholdsJson: dbUser?.thresholdsJson ?? null,
|
||||
});
|
||||
dashboardSummaries.summaries,
|
||||
);
|
||||
targets.splice(0, targets.length, ...verdictTargets);
|
||||
|
||||
const pageSummary = buildTargetPageSummary(targets);
|
||||
@@ -412,28 +423,70 @@ export async function buildTargetsResponse(user: AuthedUser) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the verdict over each target item. The reference is the item's latest
|
||||
* value, else its thirty-day average, in the unit the item is served in:
|
||||
* sleep is served in hours and judged in minutes, and BMI is judged by the
|
||||
* weight it is derived from.
|
||||
* The dashboard summary each target item's verdict is placed with: the key
|
||||
* its dashboard tile reads its direction under. Blood pressure is judged by
|
||||
* its systolic side, BMI by the weight it is derived from, and the resting
|
||||
* pulse by the resting heart rate where the account records one (the tile
|
||||
* shows that series then).
|
||||
*/
|
||||
function summaryKeysFor(type: string): string[] {
|
||||
switch (type) {
|
||||
case "BLOOD_PRESSURE":
|
||||
return ["BLOOD_PRESSURE_SYS"];
|
||||
case "BMI":
|
||||
return ["WEIGHT"];
|
||||
case "PULSE":
|
||||
return ["RESTING_HEART_RATE", "PULSE"];
|
||||
default:
|
||||
return [type];
|
||||
}
|
||||
}
|
||||
|
||||
type VerdictSummaries = Readonly<
|
||||
Record<string, { avg7: number | null; latest: number | null } | undefined>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* else the latest reading. Only when the account has no summary for the type
|
||||
* does the item's own value stand in (latest, else thirty-day average; sleep
|
||||
* is served in hours and stored in minutes).
|
||||
*/
|
||||
export function targetVerdictReference(
|
||||
item: Pick<TargetItem, "type" | "current" | "average30">,
|
||||
summaries: VerdictSummaries,
|
||||
weightItem: Pick<TargetItem, "current" | "average30"> | undefined,
|
||||
): number | null {
|
||||
for (const key of summaryKeysFor(item.type)) {
|
||||
const summary = summaries[key];
|
||||
const value = summary ? (summary.avg7 ?? summary.latest) : null;
|
||||
if (value !== null && value !== undefined) return value;
|
||||
}
|
||||
const own =
|
||||
item.type === "BMI"
|
||||
? weightItem
|
||||
? (weightItem.current ?? weightItem.average30)
|
||||
: null
|
||||
: (item.current ?? item.average30);
|
||||
if (own === null) return null;
|
||||
return item.type === "SLEEP_DURATION" ? own * 60 : own;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lay the verdict over each target item, placed with the dashboard's basis
|
||||
* (`targetVerdictReference`).
|
||||
*/
|
||||
export function withTargetVerdicts(
|
||||
targets: readonly TargetItem[],
|
||||
ctx: Omit<TrendVerdictContext, "referenceValue">,
|
||||
summaries: VerdictSummaries,
|
||||
): TargetItem[] {
|
||||
const weight = targets.find((item) => item.type === "WEIGHT");
|
||||
const weightReference = weight ? (weight.current ?? weight.average30) : null;
|
||||
return targets.map((item) => {
|
||||
const own = item.current ?? item.average30;
|
||||
const reference =
|
||||
item.type === "BMI"
|
||||
? weightReference
|
||||
: own !== null && item.type === "SLEEP_DURATION"
|
||||
? own * 60
|
||||
: own;
|
||||
const direction = resolveTrendVerdict(item.type, {
|
||||
...ctx,
|
||||
referenceValue: reference,
|
||||
referenceValue: targetVerdictReference(item, summaries, weight),
|
||||
});
|
||||
return {
|
||||
...item,
|
||||
|
||||
Reference in new issue
Block a user