mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Merge branch 'fix/1261-own-targets' into release/v1.44.2
This commit is contained in:
commit
803d90c6c4
14 files changed
+650
-146
No files matched your search
+11
-7
@@ -63558,7 +63558,8 @@ components:
|
||||
- diaHigh
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Personalised blood-pressure target numbers. Null when the profile has no date of birth.
|
||||
description: "Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the
|
||||
age-banded default. Null when there is neither a target nor a date of birth."
|
||||
bpSysRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
@@ -63578,7 +63579,8 @@ components:
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Systolic traffic-light range. Null when the profile has no date of birth.
|
||||
description: Systolic traffic-light range, the user's own target winning over the age default. Null when there is
|
||||
neither a target nor a date of birth.
|
||||
bpDiaRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
@@ -63598,7 +63600,8 @@ components:
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
- type: "null"
|
||||
description: Diastolic traffic-light range. Null when the profile has no date of birth.
|
||||
description: Diastolic traffic-light range, the user's own target winning over the age default. Null when there is
|
||||
neither a target nor a date of birth.
|
||||
pulseDisplayRange:
|
||||
type: object
|
||||
properties:
|
||||
@@ -63616,7 +63619,8 @@ components:
|
||||
- orangeMin
|
||||
- orangeMax
|
||||
additionalProperties: false
|
||||
description: Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.
|
||||
description: "Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA
|
||||
reference range with no date of birth). Always present."
|
||||
pulseBands:
|
||||
type: array
|
||||
items:
|
||||
@@ -63637,7 +63641,7 @@ components:
|
||||
- max
|
||||
- color
|
||||
additionalProperties: false
|
||||
description: Resting-pulse chart bands. Always present — AHA fallback with no date of birth.
|
||||
description: Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.
|
||||
bodyFatRange:
|
||||
type: object
|
||||
properties:
|
||||
@@ -63649,7 +63653,7 @@ components:
|
||||
- min
|
||||
- max
|
||||
additionalProperties: false
|
||||
description: Gender-aware body-fat target range. Always present.
|
||||
description: "Body-fat target range: the user's own target where set, else the gender-aware default. Always present."
|
||||
bodyFatBands:
|
||||
type: array
|
||||
items:
|
||||
@@ -63670,7 +63674,7 @@ components:
|
||||
- max
|
||||
- color
|
||||
additionalProperties: false
|
||||
description: Body-fat chart bands. Always present.
|
||||
description: Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.
|
||||
weightRange:
|
||||
anyOf:
|
||||
- type: object
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The SpO₂ chart shades the person's own target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise. The page used to hand the
|
||||
* chart a fixed 80 / 90 / 95 / 100 band, so a target of 88 to 92 % (COPD,
|
||||
* altitude) left every reading of the person's normal range shaded as a
|
||||
* warning.
|
||||
*
|
||||
* The scaffold is replaced by a probe that prints the bands it receives; the
|
||||
* thresholds read is seeded straight into the shared query cell, which is the
|
||||
* cell the target editor invalidates on save.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { ValueBand } from "@/lib/analytics/value-bands";
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/insights/healthkit-metric-page", () => ({
|
||||
HealthKitMetricPage: (props: { valueBands?: ValueBand[] }) => (
|
||||
<pre data-testid="bands">{JSON.stringify(props.valueBands ?? null)}</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const { default: InsightsOxygenSaturationPage } = await import("../page");
|
||||
|
||||
function bandsRendered(thresholds: unknown): ValueBand[] {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity } },
|
||||
});
|
||||
if (thresholds !== undefined) {
|
||||
client.setQueryData(queryKeys.userThresholds(), thresholds);
|
||||
}
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<InsightsOxygenSaturationPage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const match = /<pre[^>]*>([^<]*)<\/pre>/.exec(html);
|
||||
const json = (match?.[1] ?? "null").replaceAll(""", '"');
|
||||
return JSON.parse(json) as ValueBand[];
|
||||
}
|
||||
|
||||
const green = (bands: ValueBand[]) =>
|
||||
bands.find((band) => band.color === "var(--success)");
|
||||
|
||||
describe("/insights/oxygen value bands", () => {
|
||||
it("keeps the clinical bands without a personal target", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: { greenMin: 95, greenMax: 100, orangeMin: 92, orangeMax: 100 },
|
||||
isOverride: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(bands).toEqual([
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the clinical bands while the thresholds read is still loading", () => {
|
||||
expect(green(bandsRendered(undefined))).toEqual(
|
||||
expect.objectContaining({ min: 95, max: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shades the person's own band when they set one", () => {
|
||||
const bands = bandsRendered({
|
||||
effective: {
|
||||
OXYGEN_SATURATION: {
|
||||
range: {
|
||||
greenMin: 88,
|
||||
greenMax: 92,
|
||||
orangeMin: 87.4,
|
||||
orangeMax: 92.6,
|
||||
},
|
||||
isOverride: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(green(bands)).toEqual(expect.objectContaining({ min: 88, max: 92 }));
|
||||
// Nothing is shaded above 100 %.
|
||||
expect(Math.max(...bands.map((band) => band.max))).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { Wind } from "lucide-react";
|
||||
|
||||
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
|
||||
import { buildOxygenSaturationBands } from "@/lib/analytics/value-bands";
|
||||
import { useOwnTargetRange } from "@/lib/queries/use-own-target-range";
|
||||
|
||||
/**
|
||||
* v1.4.32 — `/insights/oxygen`.
|
||||
@@ -13,8 +15,12 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
|
||||
* compressing the trend. Withings ScanWatch + Apple Watch both
|
||||
* report SpO₂; the v1.4.25 source-priority picker keeps the surface
|
||||
* deduped.
|
||||
*
|
||||
* The shaded zone follows the person's own SpO₂ target when they set one on
|
||||
* `/targets`, and the clinical bands otherwise.
|
||||
*/
|
||||
export default function InsightsOxygenSaturationPage() {
|
||||
const ownRange = useOwnTargetRange("OXYGEN_SATURATION");
|
||||
return (
|
||||
<HealthKitMetricPage
|
||||
measurementType="OXYGEN_SATURATION"
|
||||
@@ -26,14 +32,9 @@ export default function InsightsOxygenSaturationPage() {
|
||||
color="var(--chart-4)"
|
||||
unit="%"
|
||||
yAxisUnit="%"
|
||||
valueBands={[
|
||||
// < 90 % — clinical hypoxia floor, paint the danger band.
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
// 90 .. 95 % — watch zone.
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
// 95 .. 100 % — clinical normal.
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
]}
|
||||
// The clinical shading (hypoxia floor < 90 %, watch zone to 95 %,
|
||||
// normal above), or the person's own band when they set one.
|
||||
valueBands={buildOxygenSaturationBands(ownRange)}
|
||||
emptyStateIcon={<Wind className="size-6" />}
|
||||
emptyStateCtaType={null}
|
||||
captureType="OXYGEN_SATURATION"
|
||||
|
||||
+8
-10
@@ -378,10 +378,10 @@ export default function DashboardPageClient({
|
||||
analyticsThickQuery.data,
|
||||
]);
|
||||
|
||||
// v1.34 — the user's own weight target, for the legacy (snapshot-disabled)
|
||||
// band fallback only. In snapshot mode the server resolves the same override
|
||||
// into `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the height-derived band for someone who has set a target.
|
||||
// The user's own targets, for the legacy (snapshot-disabled) band fallback
|
||||
// only. In snapshot mode the server resolves the same overrides into
|
||||
// `targetBands` and this query never fires; the fallback would otherwise
|
||||
// keep shading the default band for someone who has set a target.
|
||||
const { data: thresholdsData } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: async () => {
|
||||
@@ -754,9 +754,9 @@ export default function DashboardPageClient({
|
||||
// in the snapshot steady state) before this. Keyed on the `user` object
|
||||
// identity so the React Compiler can preserve the memo (a narrower
|
||||
// property list trips `preserve-manual-memoization`).
|
||||
// v1.34 — the weight target rides the same memo. In snapshot mode
|
||||
// v1.34 — the user's own targets ride the same memo. In snapshot mode
|
||||
// `thresholdsData` is never fetched, so the client copy falls back to the
|
||||
// height-derived band for the frames before `serverBands` lands — exactly
|
||||
// default bands for the frames before `serverBands` lands — exactly
|
||||
// as it already did for every other profile-derived number.
|
||||
// v1.36.x — the profile this fallback reads is the CALLER's, and under a
|
||||
// switch the caller is the delegate. `viewerBandProfile` is where that is
|
||||
@@ -771,9 +771,7 @@ export default function DashboardPageClient({
|
||||
dateOfBirth: user?.dateOfBirth ? new Date(user.dateOfBirth) : null,
|
||||
gender: toProfileSex(user?.gender),
|
||||
heightCm: user?.heightCm ?? null,
|
||||
weightTargetOverride: resolveWeightTargetOverride(
|
||||
thresholdsData?.overrides ?? null,
|
||||
),
|
||||
thresholdsJson: thresholdsData?.overrides ?? null,
|
||||
},
|
||||
inSharedRecord,
|
||||
),
|
||||
@@ -791,7 +789,7 @@ export default function DashboardPageClient({
|
||||
const weightTrend =
|
||||
(snapshotEnabled ? snapshotQuery.data?.tiles.weightTrend : undefined) ??
|
||||
resolveWeightTrend(
|
||||
clientBandProfile.weightTargetOverride,
|
||||
resolveWeightTargetOverride(clientBandProfile.thresholdsJson),
|
||||
weightTrendReferenceKg(w),
|
||||
);
|
||||
// v1.43 — every other tile's direction comes resolved from the server
|
||||
|
||||
@@ -186,3 +186,61 @@ export function getBodyFatTargetRange(gender: string | null | undefined): {
|
||||
// Gender-neutral fallback: midpoint of male/female fitness+acceptable.
|
||||
return { min: 17, max: 27 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart bands for oxygen saturation (%).
|
||||
*
|
||||
* Without a personal target the chart keeps its clinical shading: below 90 %
|
||||
* the hypoxia floor, 90 to 95 % the watch zone, 95 to 100 % normal. With one
|
||||
* (a COPD or high-altitude user living at 88 to 92 %, say) the green band is
|
||||
* the person's own and the wings come from the resolved range, so the zone
|
||||
* agrees with the `/targets` card. Saturation cannot exceed 100 %, so nothing
|
||||
* is shaded above it; the floor stays at 80 % unless the person's own wing
|
||||
* reaches lower.
|
||||
*/
|
||||
export function buildOxygenSaturationBands(
|
||||
ownRange: TrafficRange | null,
|
||||
): ValueBand[] {
|
||||
if (!ownRange) {
|
||||
return [
|
||||
{ min: 80, max: 90, color: "var(--destructive)", opacity: 0.18 },
|
||||
{ min: 90, max: 95, color: "var(--warning)", opacity: 0.18 },
|
||||
{ min: 95, max: 100, color: "var(--success)", opacity: 0.18 },
|
||||
];
|
||||
}
|
||||
const floor = Math.min(80, ownRange.orangeMin);
|
||||
const ceiling = 100;
|
||||
const bands: ValueBand[] = [
|
||||
{
|
||||
min: floor,
|
||||
max: ownRange.orangeMin,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: ownRange.orangeMin,
|
||||
max: ownRange.greenMin,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: ownRange.greenMin,
|
||||
max: Math.min(ownRange.greenMax, ceiling),
|
||||
color: "var(--success)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: Math.min(ownRange.greenMax, ceiling),
|
||||
max: Math.min(ownRange.orangeMax, ceiling),
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: Math.min(ownRange.orangeMax, ceiling),
|
||||
max: ceiling,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
];
|
||||
return bands.filter((band) => band.max > band.min);
|
||||
}
|
||||
@@ -1269,6 +1269,30 @@ describe("buildDashboardSnapshot — module gating (v1.18.0)", () => {
|
||||
expect(snap.targetBands.weightRange?.greenMax).toBe(78);
|
||||
});
|
||||
|
||||
it("an own blood-pressure, pulse and body-fat target reach the dashboard bands", async () => {
|
||||
const snap = await buildDashboardSnapshot(
|
||||
fakePrisma,
|
||||
baseUser({
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
PULSE: { min: 48, max: 58 },
|
||||
BODY_FAT: { min: 12, max: 16 },
|
||||
},
|
||||
}),
|
||||
{ modules: () => Promise.resolve(moduleMap()) },
|
||||
);
|
||||
expect(snap.targetBands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
expect(snap.targetBands.pulseDisplayRange.greenMin).toBe(48);
|
||||
expect(snap.targetBands.pulseDisplayRange.greenMax).toBe(58);
|
||||
expect(snap.targetBands.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
});
|
||||
|
||||
it("sleep disabled: tile hidden + SLEEP_DURATION stripped from summaries", async () => {
|
||||
const snap = await buildDashboardSnapshot(fakePrisma, baseUser(), {
|
||||
modules: () => Promise.resolve(moduleMap({ sleep: false })),
|
||||
|
||||
@@ -105,28 +105,28 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: Date | null;
|
||||
gender: "MALE" | "FEMALE" | null;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}> = [
|
||||
{
|
||||
name: "male 180cm with DOB",
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
{
|
||||
name: "female 165cm with DOB",
|
||||
dateOfBirth: new Date("1970-01-01T00:00:00.000Z"),
|
||||
gender: "FEMALE",
|
||||
heightCm: 165,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
{
|
||||
name: "no-profile user (null DOB / gender / height)",
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -146,20 +146,20 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "OTHER",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
const unanswered = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: null,
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(other).toEqual(unanswered);
|
||||
const male = buildTargetBands({
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 180,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(other.bodyFatBands).not.toEqual(male.bodyFatBands);
|
||||
});
|
||||
@@ -169,7 +169,7 @@ describe("buildTargetBands — server/client parity", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(bands.bpTargets).toBeNull();
|
||||
expect(bands.bpSysRange).toBeNull();
|
||||
@@ -198,14 +198,14 @@ describe("buildTargetBands — user weight target", () => {
|
||||
it("replaces the height-derived band with the user's own", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
|
||||
});
|
||||
expect(withTarget.weightRange?.greenMin).toBe(74);
|
||||
expect(withTarget.weightRange?.greenMax).toBe(78);
|
||||
|
||||
const heightDerived = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(heightDerived.weightRange).not.toEqual(withTarget.weightRange);
|
||||
// The chart bands follow the same range, so tile and chart agree.
|
||||
@@ -221,7 +221,7 @@ describe("buildTargetBands — user weight target", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: { min: 60, max: 65 },
|
||||
thresholdsJson: { WEIGHT: { min: 60, max: 65 } },
|
||||
});
|
||||
expect(bands.weightRange?.greenMin).toBe(60);
|
||||
expect(bands.weightBands?.length).toBeGreaterThan(0);
|
||||
@@ -230,14 +230,132 @@ describe("buildTargetBands — user weight target", () => {
|
||||
it("leaves every non-weight band untouched", () => {
|
||||
const withTarget = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: { min: 74, max: 78 },
|
||||
thresholdsJson: { WEIGHT: { min: 74, max: 78 } },
|
||||
});
|
||||
const without = buildTargetBands({
|
||||
...profile,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
expect(withTarget.bpTargets).toEqual(without.bpTargets);
|
||||
expect(withTarget.pulseBands).toEqual(without.pulseBands);
|
||||
expect(withTarget.bodyFatBands).toEqual(without.bodyFatBands);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other metrics the dashboard shades take the person's own target the
|
||||
* same way weight does. A blood-pressure, resting-pulse or body-fat target set
|
||||
* on `/targets` used to leave the chart on the age / sex default, so the band
|
||||
* under the line contradicted the band the person had just entered.
|
||||
*/
|
||||
describe("buildTargetBands — the user's own targets on every shaded metric", () => {
|
||||
const profile = {
|
||||
dateOfBirth: new Date("1985-06-15T00:00:00.000Z"),
|
||||
gender: "MALE" as const,
|
||||
heightCm: 180,
|
||||
};
|
||||
const without = buildTargetBands({ ...profile, thresholdsJson: null });
|
||||
const greenOf = (bands: Array<{ min: number; max: number; color: string }>) =>
|
||||
bands.find((band) => band.color === "var(--success)");
|
||||
|
||||
it("draws the blood-pressure zones and tile ranges from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
},
|
||||
});
|
||||
expect(bands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
expect(bands.bpTargets).not.toEqual(without.bpTargets);
|
||||
expect(bands.bpSysRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 115, greenMax: 125 }),
|
||||
);
|
||||
expect(bands.bpDiaRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 70, greenMax: 78 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("fills a one-sided blood-pressure target with the age default", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 110, max: 128 } },
|
||||
});
|
||||
expect(bands.bpTargets?.sysLow).toBe(110);
|
||||
expect(bands.bpTargets?.sysHigh).toBe(128);
|
||||
expect(bands.bpTargets?.diaLow).toBe(without.bpTargets?.diaLow);
|
||||
expect(bands.bpTargets?.diaHigh).toBe(without.bpTargets?.diaHigh);
|
||||
expect(bands.bpDiaRange).toEqual(without.bpDiaRange);
|
||||
});
|
||||
|
||||
it("draws a blood-pressure band from a target even without a date of birth", () => {
|
||||
const bands = buildTargetBands({
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 115, max: 125 },
|
||||
BLOOD_PRESSURE_DIA: { min: 70, max: 78 },
|
||||
},
|
||||
});
|
||||
expect(bands.bpTargets).toEqual({
|
||||
sysLow: 115,
|
||||
sysHigh: 125,
|
||||
diaLow: 70,
|
||||
diaHigh: 78,
|
||||
});
|
||||
});
|
||||
|
||||
it("draws the resting-pulse bands and display range from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { PULSE: { min: 48, max: 58 } },
|
||||
});
|
||||
expect(bands.pulseDisplayRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 48, greenMax: 58 }),
|
||||
);
|
||||
expect(greenOf(bands.pulseBands)).toEqual(
|
||||
expect.objectContaining({ min: 48, max: 58 }),
|
||||
);
|
||||
expect(bands.pulseBands).not.toEqual(without.pulseBands);
|
||||
});
|
||||
|
||||
it("draws the body-fat bands and range from the own band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BODY_FAT: { min: 12, max: 16 } },
|
||||
});
|
||||
expect(bands.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
expect(greenOf(bands.bodyFatBands)).toEqual(
|
||||
expect.objectContaining({ min: 12, max: 16 }),
|
||||
);
|
||||
expect(bands.bodyFatBands).not.toEqual(without.bodyFatBands);
|
||||
});
|
||||
|
||||
it("ignores a malformed target and keeps the default band", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: {
|
||||
BLOOD_PRESSURE_SYS: { min: 130, max: 120 },
|
||||
PULSE: { min: "fast", max: 60 },
|
||||
BODY_FAT: null,
|
||||
},
|
||||
});
|
||||
expect(bands).toEqual(without);
|
||||
});
|
||||
|
||||
it("leaves the other metrics' bands alone", () => {
|
||||
const bands = buildTargetBands({
|
||||
...profile,
|
||||
thresholdsJson: { BLOOD_PRESSURE_SYS: { min: 115, max: 125 } },
|
||||
});
|
||||
expect(bands.pulseBands).toEqual(without.pulseBands);
|
||||
expect(bands.bodyFatBands).toEqual(without.bodyFatBands);
|
||||
expect(bands.weightBands).toEqual(without.weightBands);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const DELEGATE_PROFILE = {
|
||||
dateOfBirth: new Date("1996-04-02T00:00:00Z"),
|
||||
gender: "FEMALE" as const,
|
||||
heightCm: 165,
|
||||
weightTargetOverride: { min: 55, max: 62 },
|
||||
thresholdsJson: { WEIGHT: { min: 55, max: 62 } },
|
||||
};
|
||||
|
||||
describe("viewerBandProfile", () => {
|
||||
@@ -45,7 +45,7 @@ describe("viewerBandProfile", () => {
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+86
-93
@@ -15,7 +15,7 @@
|
||||
* so the band/target math lives in exactly one place. `buildTargetBands`
|
||||
* is kept as an alias for the snapshot-era import sites.
|
||||
*/
|
||||
import { getBpTargets, type BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import type { BpTargets } from "@/lib/analytics/bp-targets";
|
||||
import {
|
||||
buildBandsFromTrafficRange,
|
||||
buildTrafficLightBands,
|
||||
@@ -30,16 +30,27 @@ import {
|
||||
getPersonalizedPulseTarget,
|
||||
} from "@/lib/analytics/pulse-targets";
|
||||
import { binaryReferenceSex, type ProfileSex } from "@/lib/profile/sex";
|
||||
import { getEffectiveRange } from "@/lib/analytics/effective-range";
|
||||
import {
|
||||
getEffectiveRange,
|
||||
resolveEffectiveBpTargets,
|
||||
resolveThresholdOverride,
|
||||
type ThresholdMetric,
|
||||
} from "@/lib/analytics/effective-range";
|
||||
|
||||
export interface DashboardTargetBands {
|
||||
/** Personalised BP target numbers (null when no DOB). */
|
||||
/**
|
||||
* BP target numbers: the user's own band where they set one, else the age
|
||||
* default. Null when neither exists (no target and no DOB).
|
||||
*/
|
||||
bpTargets: BpTargets | null;
|
||||
/** Systolic traffic range (null when no DOB). */
|
||||
/** Systolic traffic range, same precedence (null without target or DOB). */
|
||||
bpSysRange: TrafficRange | null;
|
||||
/** Diastolic traffic range (null when no DOB). */
|
||||
/** Diastolic traffic range, same precedence (null without target or DOB). */
|
||||
bpDiaRange: TrafficRange | null;
|
||||
/** Resting-pulse display range (always present — AHA fallback). */
|
||||
/**
|
||||
* Resting-pulse display range: the user's own target, else the
|
||||
* age / sex percentile band (AHA fallback). Always present.
|
||||
*/
|
||||
pulseDisplayRange: {
|
||||
greenMin: number;
|
||||
greenMax: number;
|
||||
@@ -48,7 +59,7 @@ export interface DashboardTargetBands {
|
||||
};
|
||||
/** Resting-pulse chart bands (always present — AHA fallback). */
|
||||
pulseBands: ValueBand[];
|
||||
/** Body-fat target range (gender-aware; always present). */
|
||||
/** Body-fat target range: own target, else gender-aware. Always present. */
|
||||
bodyFatRange: { min: number; max: number };
|
||||
/** Body-fat chart bands (always present). */
|
||||
bodyFatBands: ValueBand[];
|
||||
@@ -66,110 +77,92 @@ export interface DashboardTargetBands {
|
||||
* DB read. The snapshot builder and the page fallback both call this so
|
||||
* they produce byte-identical numbers.
|
||||
*
|
||||
* v1.34 — `weightTargetOverride` is the user's OWN weight band as stored in
|
||||
* `User.thresholdsJson.WEIGHT`, in kg. When present it replaces the
|
||||
* height-derived WHO band on both the weight range and the weight chart bands:
|
||||
* a user who has answered "this is my target" must not keep reading a shaded
|
||||
* zone that ignores the answer. `null` keeps the height-derived band. Required
|
||||
* rather than optional so every call site is named by the compiler.
|
||||
* `thresholdsJson` is the user's own targets blob as stored in
|
||||
* `User.thresholdsJson` (`null` when there is none). Every metric the dashboard
|
||||
* shades and that accepts a personal target reads it: weight, blood pressure,
|
||||
* resting pulse and body fat. A target the person set replaces the computed
|
||||
* default on both the tile's range and the chart's bands, so a shaded zone
|
||||
* never ignores an answer given on `/targets`. Each override goes through
|
||||
* `getEffectiveRange`, so its orange wings match the `/targets` page and the
|
||||
* doctor report rather than a second convention. A metric without an override
|
||||
* keeps exactly the band it drew before. Required rather than optional so
|
||||
* every call site is named by the compiler.
|
||||
*
|
||||
* v1.34 introduced this for weight alone; the other three kept the age / sex
|
||||
* default regardless of the person's own target until v1.44.1.
|
||||
*/
|
||||
export function buildDashboardBands(profile: {
|
||||
dateOfBirth: Date | null;
|
||||
gender: ProfileSex;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}): DashboardTargetBands {
|
||||
const bpTargets = profile.dateOfBirth
|
||||
? getBpTargets(profile.dateOfBirth)
|
||||
: null;
|
||||
const rangeProfile = {
|
||||
heightCm: profile.heightCm,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.gender,
|
||||
};
|
||||
const ownTarget = (metric: ThresholdMetric) =>
|
||||
resolveThresholdOverride(profile.thresholdsJson, metric);
|
||||
const ownRange = (metric: ThresholdMetric): TrafficRange | null => {
|
||||
const override = ownTarget(metric);
|
||||
return override
|
||||
? getEffectiveRange(metric, rangeProfile, { [metric]: override }).range
|
||||
: null;
|
||||
};
|
||||
|
||||
const bpTargets = resolveEffectiveBpTargets(
|
||||
rangeProfile,
|
||||
profile.thresholdsJson,
|
||||
);
|
||||
const pulseAge = getAgeFromDateOfBirth(profile.dateOfBirth);
|
||||
// The percentile and body-fat tables carry two sexes and no third row;
|
||||
// both helpers answer a null with a neutral midpoint rather than a side.
|
||||
const referenceSex = binaryReferenceSex(profile.gender);
|
||||
const pulseTarget = getPersonalizedPulseTarget(pulseAge, referenceSex);
|
||||
const bodyFatRange = getBodyFatTargetRange(referenceSex);
|
||||
// v1.34 — an explicit target outranks the height-derived WHO band. Without
|
||||
// this the dashboard kept shading a band the user had already answered, and
|
||||
// the answer lived on `/targets` where nothing else could see it. The
|
||||
// override is routed through `getEffectiveRange` rather than widened here,
|
||||
// so the orange wings match the `/targets` page and the doctor report
|
||||
// exactly instead of inventing a second convention. No height and no target
|
||||
// → no band at all, unchanged.
|
||||
const weightRange = profile.weightTargetOverride
|
||||
? getEffectiveRange(
|
||||
"WEIGHT",
|
||||
{
|
||||
heightCm: profile.heightCm,
|
||||
dateOfBirth: profile.dateOfBirth,
|
||||
gender: profile.gender,
|
||||
},
|
||||
{ WEIGHT: profile.weightTargetOverride },
|
||||
).range
|
||||
: profile.heightCm
|
||||
? buildWeightRangeFromHeight(profile.heightCm)
|
||||
: null;
|
||||
const defaultPulse = getPersonalizedPulseTarget(pulseAge, referenceSex);
|
||||
const pulseRange: TrafficRange = ownRange("PULSE") ?? {
|
||||
greenMin: defaultPulse.greenMin,
|
||||
greenMax: defaultPulse.greenMax,
|
||||
orangeMin: defaultPulse.orangeMin,
|
||||
orangeMax: defaultPulse.orangeMax,
|
||||
};
|
||||
const ownBodyFat = ownRange("BODY_FAT");
|
||||
const bodyFatRange = ownBodyFat
|
||||
? { min: ownBodyFat.greenMin, max: ownBodyFat.greenMax }
|
||||
: getBodyFatTargetRange(referenceSex);
|
||||
// No height and no target → no weight band at all, unchanged.
|
||||
const weightRange =
|
||||
ownRange("WEIGHT") ??
|
||||
(profile.heightCm ? buildWeightRangeFromHeight(profile.heightCm) : null);
|
||||
const weightBands = weightRange
|
||||
? buildBandsFromTrafficRange(weightRange, {
|
||||
lowerBound: 30,
|
||||
upperBound: 250,
|
||||
})
|
||||
: null;
|
||||
const bpSysRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh)
|
||||
: null;
|
||||
const bpDiaRange = bpTargets
|
||||
? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh)
|
||||
: null;
|
||||
const pulseBands = [
|
||||
{
|
||||
min: 30,
|
||||
max: pulseTarget.orangeMin,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.16,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.orangeMin,
|
||||
max: pulseTarget.greenMin,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
color: "var(--success)",
|
||||
opacity: 0.2,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.greenMax,
|
||||
max: pulseTarget.orangeMax,
|
||||
color: "var(--warning)",
|
||||
opacity: 0.18,
|
||||
},
|
||||
{
|
||||
min: pulseTarget.orangeMax,
|
||||
max: 220,
|
||||
color: "var(--destructive)",
|
||||
opacity: 0.16,
|
||||
},
|
||||
].filter((band) => band.max > band.min);
|
||||
const bodyFatBands = buildTrafficLightBands(
|
||||
bodyFatRange.min,
|
||||
bodyFatRange.max,
|
||||
{
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
},
|
||||
);
|
||||
// The tile colours a reading against the same band the chart shades: the
|
||||
// person's own range with its effective wings, else the age default's.
|
||||
const bpSysRange =
|
||||
ownRange("BLOOD_PRESSURE_SYS") ??
|
||||
(bpTargets ? buildTrafficRange(bpTargets.sysLow, bpTargets.sysHigh) : null);
|
||||
const bpDiaRange =
|
||||
ownRange("BLOOD_PRESSURE_DIA") ??
|
||||
(bpTargets ? buildTrafficRange(bpTargets.diaLow, bpTargets.diaHigh) : null);
|
||||
const pulseBands = buildBandsFromTrafficRange(pulseRange, {
|
||||
lowerBound: 30,
|
||||
upperBound: 220,
|
||||
});
|
||||
const bodyFatBands = ownBodyFat
|
||||
? buildBandsFromTrafficRange(ownBodyFat, { lowerBound: 2, upperBound: 55 })
|
||||
: buildTrafficLightBands(bodyFatRange.min, bodyFatRange.max, {
|
||||
lowerBound: 2,
|
||||
upperBound: 55,
|
||||
});
|
||||
return {
|
||||
bpTargets,
|
||||
bpSysRange,
|
||||
bpDiaRange,
|
||||
pulseDisplayRange: {
|
||||
greenMin: pulseTarget.greenMin,
|
||||
greenMax: pulseTarget.greenMax,
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
},
|
||||
pulseDisplayRange: pulseRange,
|
||||
pulseBands,
|
||||
bodyFatRange,
|
||||
bodyFatBands,
|
||||
@@ -187,7 +180,7 @@ export interface BandProfile {
|
||||
dateOfBirth: Date | null;
|
||||
gender: ProfileSex;
|
||||
heightCm: number | null;
|
||||
weightTargetOverride: { min: number; max: number } | null;
|
||||
thresholdsJson: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -220,6 +213,6 @@ export function viewerBandProfile(
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
heightCm: null,
|
||||
weightTargetOverride: null,
|
||||
thresholdsJson: null,
|
||||
};
|
||||
}
|
||||
@@ -1489,10 +1489,11 @@ export async function buildDashboardSnapshot(
|
||||
dateOfBirth: user.dateOfBirth,
|
||||
gender,
|
||||
heightCm: user.heightCm ?? null,
|
||||
// v1.34 — the user's own weight target, when they set one, replaces the
|
||||
// height-derived band on the tile + the chart. Resolved off the same
|
||||
// `thresholdsJson` blob the health score reads.
|
||||
weightTargetOverride,
|
||||
// The user's own targets, where set, replace the computed default on
|
||||
// every tile and chart band (weight, blood pressure, resting pulse,
|
||||
// body fat). Resolved off the same `thresholdsJson` blob the health
|
||||
// score and the tile directions read.
|
||||
thresholdsJson: user.thresholdsJson,
|
||||
}),
|
||||
tiles: {
|
||||
summaries: slim.summaries,
|
||||
|
||||
@@ -1186,8 +1186,8 @@ export const dashboardSnapshotResponse = z
|
||||
"Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.",
|
||||
),
|
||||
// v1.18.6 — server-computed band / target math (audit finding #3),
|
||||
// resolved from the user's own profile facts (DOB / gender / height /
|
||||
// weight-target override). The dashboard reads these instead of
|
||||
// resolved from the user's own profile facts (DOB / gender / height) and
|
||||
// their own targets. The dashboard reads these instead of
|
||||
// recomputing client-side; always present (every sub-field falls back
|
||||
// to a neutral default rather than being omitted).
|
||||
targetBands: z
|
||||
@@ -1201,7 +1201,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Personalised blood-pressure target numbers. Null when the profile has no date of birth.",
|
||||
"Blood-pressure target numbers: the user's own target (`/api/user/thresholds`) where set, else the age-banded default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
bpSysRange: z
|
||||
.object({
|
||||
@@ -1212,7 +1212,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Systolic traffic-light range. Null when the profile has no date of birth.",
|
||||
"Systolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
bpDiaRange: z
|
||||
.object({
|
||||
@@ -1223,7 +1223,7 @@ export const dashboardSnapshotResponse = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Diastolic traffic-light range. Null when the profile has no date of birth.",
|
||||
"Diastolic traffic-light range, the user's own target winning over the age default. Null when there is neither a target nor a date of birth.",
|
||||
),
|
||||
pulseDisplayRange: z
|
||||
.object({
|
||||
@@ -1233,7 +1233,7 @@ export const dashboardSnapshotResponse = z
|
||||
orangeMax: z.number(),
|
||||
})
|
||||
.describe(
|
||||
"Resting-pulse display range. Always present — falls back to the AHA reference range with no date of birth.",
|
||||
"Resting-pulse display range: the user's own target where set, else the age / sex percentile band (the AHA reference range with no date of birth). Always present.",
|
||||
),
|
||||
pulseBands: z
|
||||
.array(
|
||||
@@ -1246,11 +1246,13 @@ export const dashboardSnapshotResponse = z
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Resting-pulse chart bands. Always present — AHA fallback with no date of birth.",
|
||||
"Resting-pulse chart bands, drawn from the same range as `pulseDisplayRange`. Always present.",
|
||||
),
|
||||
bodyFatRange: z
|
||||
.object({ min: z.number(), max: z.number() })
|
||||
.describe("Gender-aware body-fat target range. Always present."),
|
||||
.describe(
|
||||
"Body-fat target range: the user's own target where set, else the gender-aware default. Always present.",
|
||||
),
|
||||
bodyFatBands: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -1261,7 +1263,9 @@ export const dashboardSnapshotResponse = z
|
||||
strokeOpacity: z.number().optional(),
|
||||
}),
|
||||
)
|
||||
.describe("Body-fat chart bands. Always present."),
|
||||
.describe(
|
||||
"Body-fat chart bands, drawn from the same range as `bodyFatRange`. Always present.",
|
||||
),
|
||||
weightRange: z
|
||||
.object({
|
||||
greenMin: z.number(),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { selectOwnTargetRange } from "../use-own-target-range";
|
||||
|
||||
const range = { greenMin: 88, greenMax: 92, orangeMin: 87.4, orangeMax: 92.6 };
|
||||
|
||||
describe("selectOwnTargetRange", () => {
|
||||
it("returns the resolved range when the person set the target", () => {
|
||||
expect(
|
||||
selectOwnTargetRange(
|
||||
{ effective: { OXYGEN_SATURATION: { range, isOverride: true } } },
|
||||
"OXYGEN_SATURATION",
|
||||
),
|
||||
).toEqual(range);
|
||||
});
|
||||
|
||||
it("returns null for a default range, so the chart keeps its own band", () => {
|
||||
expect(
|
||||
selectOwnTargetRange(
|
||||
{ effective: { OXYGEN_SATURATION: { range, isOverride: false } } },
|
||||
"OXYGEN_SATURATION",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null before the read lands or for an absent metric", () => {
|
||||
expect(selectOwnTargetRange(null, "OXYGEN_SATURATION")).toBeNull();
|
||||
expect(selectOwnTargetRange({ effective: {} }, "PULSE")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { apiGet } from "@/lib/api/api-fetch";
|
||||
import type { ThresholdMetric } from "@/lib/analytics/effective-range";
|
||||
import type { TrafficRange } from "@/lib/analytics/value-bands";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
/** The slice of `GET /api/user/thresholds` a chart band needs. */
|
||||
interface ThresholdsSlice {
|
||||
effective: Partial<
|
||||
Record<string, { range: TrafficRange | null; isOverride: boolean }>
|
||||
>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's own target band for one metric, as the server resolved it, or
|
||||
* `null` when they have not set one (the chart then keeps its default band).
|
||||
*
|
||||
* `/api/user/thresholds` resolves every override through `getEffectiveRange`,
|
||||
* orange wings included, so the page draws the resolved range rather than
|
||||
* re-deriving one. Shares the `userThresholds()` cache entry with the target
|
||||
* editor, which invalidates it on save, so editing a target repaints the
|
||||
* chart without a reload.
|
||||
*/
|
||||
export function useOwnTargetRange(
|
||||
metric: ThresholdMetric,
|
||||
): TrafficRange | null {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.userThresholds(),
|
||||
queryFn: () => apiGet<ThresholdsSlice>("/api/user/thresholds"),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
return selectOwnTargetRange(data ?? null, metric);
|
||||
}
|
||||
|
||||
/** Pure projection, exported for the unit test. */
|
||||
export function selectOwnTargetRange(
|
||||
data: ThresholdsSlice | null,
|
||||
metric: ThresholdMetric,
|
||||
): TrafficRange | null {
|
||||
const entry = data?.effective?.[metric];
|
||||
if (!entry?.isOverride) return null;
|
||||
return entry.range ?? null;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* A target the person sets on `/targets` reaches the dashboard's chart bands.
|
||||
*
|
||||
* The snapshot resolved the blood-pressure, resting-pulse and body-fat bands
|
||||
* from the profile alone (age, sex) and took only the weight target into
|
||||
* account, so the zones under the blood-pressure chart stayed on the age
|
||||
* default after the person had entered their own band. The resolved bands
|
||||
* are what the web dashboard draws and what a native client reads, so the
|
||||
* proof runs through the real routes: write the target through
|
||||
* `PUT /api/user/thresholds`, read `GET /api/dashboard/snapshot` back from
|
||||
* the same Postgres, with the snapshot's cache cell in between.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
async function seedUser(): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "own-target-bands",
|
||||
email: "own-target-bands@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
dateOfBirth: new Date("1960-03-01T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
heightCm: 178,
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 60 * 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
interface Bands {
|
||||
bpTargets: {
|
||||
sysLow: number;
|
||||
sysHigh: number;
|
||||
diaLow: number;
|
||||
diaHigh: number;
|
||||
} | null;
|
||||
pulseDisplayRange: { greenMin: number; greenMax: number };
|
||||
bodyFatRange: { min: number; max: number };
|
||||
}
|
||||
|
||||
async function readBands(): Promise<Bands> {
|
||||
const { GET } = await import("@/app/api/dashboard/snapshot/route");
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { targetBands: Bands } };
|
||||
return body.data.targetBands;
|
||||
}
|
||||
|
||||
async function putTargets(payload: unknown): Promise<void> {
|
||||
const { PUT } = await import("@/app/api/user/thresholds/route");
|
||||
const res = await PUT(
|
||||
new NextRequest("http://localhost/api/user/thresholds", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
}
|
||||
|
||||
describe("dashboard bands follow the person's own targets (real Postgres)", () => {
|
||||
it("replaces the age default once a target is saved", async () => {
|
||||
await seedUser();
|
||||
|
||||
const before = await readBands();
|
||||
// The premise: an age default exists and differs from the target below.
|
||||
expect(before.bpTargets).not.toBeNull();
|
||||
expect(before.bpTargets?.sysHigh).not.toBe(150);
|
||||
|
||||
await putTargets({
|
||||
BLOOD_PRESSURE_SYS: { min: 120, max: 150 },
|
||||
BLOOD_PRESSURE_DIA: { min: 75, max: 95 },
|
||||
PULSE: { min: 48, max: 58 },
|
||||
BODY_FAT: { min: 12, max: 16 },
|
||||
});
|
||||
|
||||
// Read again: the save evicts the cached snapshot, so this is a rebuild
|
||||
// against the stored blob, not the cell from the first read.
|
||||
const after = await readBands();
|
||||
expect(after.bpTargets).toEqual({
|
||||
sysLow: 120,
|
||||
sysHigh: 150,
|
||||
diaLow: 75,
|
||||
diaHigh: 95,
|
||||
});
|
||||
expect(after.pulseDisplayRange).toEqual(
|
||||
expect.objectContaining({ greenMin: 48, greenMax: 58 }),
|
||||
);
|
||||
expect(after.bodyFatRange).toEqual({ min: 12, max: 16 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user