mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
fix(insights): show small lab changes and print them like the labs page
"What changed since your last panel" rounded every delta to two decimals, so TSH 0.004 to 0.009 read "no change", and it printed values and deltas through `String()`: a full stop in every language and every digit of a unit conversion (41.999198717948715 ng/mL). The summary now reads each reading at the precision it was recorded at (`recordedPrecision`, with the report's printed figure for a converted value, which the route now selects) and rounds the delta to the finer of the two, so its direction agrees with the printed figures. The card prints both through `useLabNumber()`. 5.9 - 5.6 still comes out as 0.3, so the existing marker-pairing test stands as it was.
This commit is contained in:
1 parent
9d7c99e60c
commit
2c7a0dc3c2
5 files changed
+148
-8
No files matched your search
@@ -48,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
biomarkerId: true,
|
||||
unit: true,
|
||||
value: true,
|
||||
sourceValue: true,
|
||||
referenceLow: true,
|
||||
referenceHigh: true,
|
||||
sourceReferenceLow: true,
|
||||
@@ -65,6 +66,7 @@ export const GET = apiHandler(async () => {
|
||||
biomarkerId: r.biomarkerId,
|
||||
unit: r.unit,
|
||||
value: r.value as number,
|
||||
sourceValue: r.sourceValue,
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
sourceReferenceLow: r.sourceReferenceLow,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The card prints a lab figure the way the labs page does: in the reader's
|
||||
* notation and with the digits the reading holds. It used to print
|
||||
* `String(value)`, so a German reader saw "4.6" beside a labs page that says
|
||||
* "4,6", and a converted reading showed every digit of the conversion.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({
|
||||
data: {
|
||||
present: true,
|
||||
latestDate: "2026-06-01",
|
||||
previousDate: "2026-03-01",
|
||||
generatedAt: "2026-06-02T00:00:00.000Z",
|
||||
changes: [
|
||||
{
|
||||
analyte: "TSH",
|
||||
unit: "mIU/L",
|
||||
latest: 0.009,
|
||||
previous: 0.004,
|
||||
delta: 0.005,
|
||||
direction: "up",
|
||||
status: "unknown",
|
||||
},
|
||||
{
|
||||
analyte: "Potassium",
|
||||
unit: "mmol/L",
|
||||
latest: 4.6,
|
||||
previous: 4.9,
|
||||
delta: -0.3,
|
||||
direction: "down",
|
||||
status: "unknown",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({ isAuthenticated: true }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { LabsChangesCard } from "../labs-changes-card";
|
||||
|
||||
describe("<LabsChangesCard>", () => {
|
||||
it("prints values and deltas in the reader's notation", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="de">
|
||||
<LabsChangesCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("0,009 mIU/L");
|
||||
expect(html).toContain("+0,005 mIU/L");
|
||||
expect(html).toContain("4,6 mmol/L");
|
||||
expect(html).toContain("-0,3 mmol/L");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { SectionHeading } from "@/components/ui/section-heading";
|
||||
import type { LabChange } from "@/lib/insights/labs-changes";
|
||||
import { InsightSectionCard } from "./insight-section-card";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { useLabNumber } from "@/components/labs/use-lab-format";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -55,6 +56,7 @@ export function LabsChangesCard({
|
||||
}: LabsChangesCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const labNumber = useLabNumber();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: queryKeys.insightsLabsChanges(),
|
||||
@@ -82,7 +84,10 @@ export function LabsChangesCard({
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
|
||||
: withLabUnit(
|
||||
`${c.delta > 0 ? "+" : ""}${labNumber(c.delta)}`,
|
||||
c.unit,
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
@@ -94,7 +99,7 @@ export function LabsChangesCard({
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{withLabUnit(String(c.latest), c.unit)}
|
||||
{withLabUnit(labNumber(c.latest), c.unit)}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,7 @@ function row(
|
||||
sourceHigh?: number | null;
|
||||
sourceText?: string | null;
|
||||
biomarkerId?: string | null;
|
||||
sourceValue?: number | null;
|
||||
} = {},
|
||||
): LabChangeRow {
|
||||
return {
|
||||
@@ -34,6 +35,7 @@ function row(
|
||||
biomarkerId: opts.biomarkerId ?? null,
|
||||
unit: opts.unit ?? "mg/dL",
|
||||
value,
|
||||
sourceValue: opts.sourceValue ?? null,
|
||||
referenceLow: opts.low ?? null,
|
||||
referenceHigh: opts.high ?? null,
|
||||
sourceReferenceLow: opts.sourceLow ?? null,
|
||||
@@ -179,6 +181,53 @@ describe("summariseLabChanges", () => {
|
||||
expect(out.present).toBe(false);
|
||||
});
|
||||
|
||||
it("calls a small change in a small value a change", () => {
|
||||
// Two decimals for every analyte rounded this delta to 0: "no change"
|
||||
// for a TSH that more than doubled.
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
row("TSH", 0.009, "2026-06-01", { unit: "mIU/L" }),
|
||||
row("TSH", 0.004, "2026-03-01", { unit: "mIU/L" }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({ delta: 0.005, direction: "up" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads a converted value at the precision its report printed", () => {
|
||||
// 104.83 nmol/L converted to ng/mL is 41.999198717948715.
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
row("Vitamin D", 41.999198717948715, "2026-06-01", {
|
||||
unit: "ng/mL",
|
||||
sourceValue: 104.83,
|
||||
}),
|
||||
row("Vitamin D", 30, "2026-03-01", { unit: "ng/mL" }),
|
||||
],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
latest: 41.999,
|
||||
previous: 30,
|
||||
delta: 11.999,
|
||||
direction: "up",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("still calls an unchanged reading flat", () => {
|
||||
const out = summariseLabChanges(
|
||||
[row("LDL", 130, "2026-06-01"), row("LDL", 130, "2026-03-01")],
|
||||
NOW,
|
||||
);
|
||||
expect(out.changes).toEqual([
|
||||
expect.objectContaining({ delta: 0, direction: "flat" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports no delta across two units", () => {
|
||||
const out = summariseLabChanges(
|
||||
[
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* the newest panel is older than `LAB_CHANGE_RECENCY_DAYS`. Neutral framing
|
||||
* only — a delta is not a diagnosis.
|
||||
*/
|
||||
import { recordedPrecision } from "@/lib/format/recorded-number";
|
||||
import {
|
||||
classifyAgainstEffectiveRange,
|
||||
resolveEffectiveReferenceRange,
|
||||
@@ -33,6 +34,11 @@ export interface LabChangeRow extends SourceReferenceRange {
|
||||
biomarkerId?: string | null;
|
||||
unit: string;
|
||||
value: number;
|
||||
/**
|
||||
* The figure the source report printed when `value` was converted from
|
||||
* another unit; it sets the precision the converted value is read at.
|
||||
*/
|
||||
sourceValue?: number | null;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
takenAt: Date;
|
||||
@@ -41,9 +47,13 @@ export interface LabChangeRow extends SourceReferenceRange {
|
||||
export interface LabChange {
|
||||
analyte: string;
|
||||
unit: string;
|
||||
/** The readings at the precision they were recorded at (`recordedPrecision`). */
|
||||
latest: number;
|
||||
previous: number;
|
||||
/** Signed latest − previous, rounded to 2dp. */
|
||||
/**
|
||||
* Signed latest − previous, at the finer of the two readings' precisions,
|
||||
* so TSH 0.004 → 0.009 is +0.005 and not a rounded-away 0.
|
||||
*/
|
||||
delta: number;
|
||||
direction: "up" | "down" | "flat";
|
||||
status: ReferenceRangeStatus;
|
||||
@@ -108,8 +118,6 @@ function sameUnit(a: LabChangeRow, b: LabChangeRow): boolean {
|
||||
return a.unit.trim().toLowerCase() === b.unit.trim().toLowerCase();
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100;
|
||||
|
||||
/**
|
||||
* Summarise the change between the two most-recent numeric lab panels. `rows`
|
||||
* should already be the user's live (non-deleted) numeric results; this helper
|
||||
@@ -161,14 +169,30 @@ export function summariseLabChanges(
|
||||
const previousRow = previous.get(key);
|
||||
// A delta across two units is no change, it is a conversion; skip it.
|
||||
if (!previousRow || !sameUnit(latestRow, previousRow)) continue;
|
||||
const delta = round2(latestRow.value - previousRow.value);
|
||||
// Each reading at the digits it was recorded with; the delta at the
|
||||
// finer of the two, so it says what the printed figures say.
|
||||
const latestReading = recordedPrecision(
|
||||
latestRow.value,
|
||||
latestRow.sourceValue,
|
||||
);
|
||||
const previousReading = recordedPrecision(
|
||||
previousRow.value,
|
||||
previousRow.sourceValue,
|
||||
);
|
||||
const digits = Math.max(
|
||||
latestReading.fractionDigits,
|
||||
previousReading.fractionDigits,
|
||||
);
|
||||
const delta = Number(
|
||||
(latestReading.value - previousReading.value).toFixed(digits),
|
||||
);
|
||||
const direction: LabChange["direction"] =
|
||||
delta > 0 ? "up" : delta < 0 ? "down" : "flat";
|
||||
changes.push({
|
||||
analyte: latestRow.analyte,
|
||||
unit: latestRow.unit,
|
||||
latest: latestRow.value,
|
||||
previous: previousRow.value,
|
||||
latest: latestReading.value,
|
||||
previous: previousReading.value,
|
||||
delta,
|
||||
direction,
|
||||
status: classifyAgainstEffectiveRange(
|
||||
|
||||
Reference in new issue
Block a user