mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(labs): show a value without a unit as the number alone
Every place a lab unit is printed beside a value now goes through one helper, so a reading stored in "1" reads "3.6" and not "3.6 1": the list, history and detail, the chart and its tooltips, the reference bar and range notes, the trend card, the timeline, the day view, the clinician page, the doctor-report PDF and the changes card. Where the unit stands alone (a marker's subtitle, a picker row, the merge preview) it reads "No unit". The Coach's lab block, the get_labs history and the visit-prep summary hand the model an empty unit for such a value, so it never quotes the "1" either. Refs #1313
This commit is contained in:
1 parent
5405ce053d
commit
f9431da64f
23 files changed
+139
-71
No files matched your search
@@ -26,6 +26,7 @@ import {
|
||||
type LeafScope,
|
||||
type Translate,
|
||||
} from "./report-sections";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Join the parts of a composed value, dropping the ones with nothing in. */
|
||||
function compose(parts: Array<string | null | undefined>): string {
|
||||
@@ -73,7 +74,7 @@ export function LabResultsSection({
|
||||
const qualitative = lab.value === null;
|
||||
const reading = qualitative
|
||||
? lab.valueText
|
||||
: `${fmtLab(lab.value as number, lab.sourceValue)} ${lab.unit}`.trim();
|
||||
: withLabUnit(fmtLab(lab.value as number, lab.sourceValue), lab.unit);
|
||||
const range = (
|
||||
low: number | null,
|
||||
high: number | null,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
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";
|
||||
|
||||
/**
|
||||
* v1.25 — "what changed since your last panel" card.
|
||||
@@ -81,7 +82,7 @@ export function LabsChangesCard({
|
||||
const signedDelta =
|
||||
c.direction === "flat"
|
||||
? t("insights.labsChanges.directionFlat")
|
||||
: `${c.delta > 0 ? "+" : ""}${c.delta} ${c.unit}`.trim();
|
||||
: withLabUnit(`${c.delta > 0 ? "+" : ""}${c.delta}`, c.unit);
|
||||
return (
|
||||
<div
|
||||
key={c.analyte}
|
||||
@@ -93,7 +94,7 @@ export function LabsChangesCard({
|
||||
{c.analyte}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{`${c.latest} ${c.unit}`.trim()}
|
||||
{withLabUnit(String(c.latest), c.unit)}
|
||||
{labelKey ? ` · ${t(labelKey)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,8 @@ import { BiomarkerForm } from "./biomarker-form";
|
||||
import { BiomarkerMergeSheet } from "./biomarker-merge-sheet";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
import { LabUnitName } from "./lab-unit-input";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the Biomarker catalog manager. v1.22 — compact editable rows.
|
||||
@@ -124,11 +126,11 @@ export function BiomarkerManager() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="truncate text-sm font-medium">{marker.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{marker.unit}</span>
|
||||
<LabUnitName unit={marker.unit} />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{range
|
||||
? `${t("labs.referenceLabel")} ${range} ${marker.unit}`
|
||||
? `${t("labs.referenceLabel")} ${withLabUnit(range, marker.unit)}`
|
||||
: t("labs.biomarker.noRange")}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
import type { BiomarkerDto, BiomarkerMergeResultDto } from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** The most sources one merge takes, matching the server. */
|
||||
const MAX_SOURCES = 10;
|
||||
@@ -166,7 +167,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm">
|
||||
{tCount("labs.merge.previewSummary", preview.movedReadings, {
|
||||
name: preview.target.name,
|
||||
unit: preview.target.unit,
|
||||
unit: labUnitLabel(preview.target.unit, t),
|
||||
})}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
@@ -178,7 +179,7 @@ export function BiomarkerMergeSheet({
|
||||
<p className="text-sm font-medium break-words">
|
||||
{source.name}{" "}
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
{source.unit}
|
||||
{labUnitLabel(source.unit, t)}
|
||||
</span>
|
||||
</p>
|
||||
<p
|
||||
@@ -190,8 +191,8 @@ export function BiomarkerMergeSheet({
|
||||
>
|
||||
{source.conversion === "refused"
|
||||
? t("labs.merge.refused", {
|
||||
unit: source.unit,
|
||||
targetUnit: preview.target.unit,
|
||||
unit: labUnitLabel(source.unit, t),
|
||||
targetUnit: labUnitLabel(preview.target.unit, t),
|
||||
})
|
||||
: source.conversion === "converted"
|
||||
? tCount("labs.merge.converted", source.readingCount, {
|
||||
@@ -247,7 +248,7 @@ export function BiomarkerMergeSheet({
|
||||
{marker.name}
|
||||
</span>
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
|
||||
@@ -61,6 +61,7 @@ import type {
|
||||
LabResultDto,
|
||||
LabResultListResponse,
|
||||
} from "./types";
|
||||
import { labUnitLabel, labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
// v1.18.11 — defer the recharts biomarker chart through
|
||||
// `next/dynamic` so recharts is off `/labs/[biomarkerId]`'s first-load JS.
|
||||
@@ -426,7 +427,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
one description line every page header carries. */}
|
||||
{marker?.unit ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{marker.unit}
|
||||
{labUnitLabel(marker.unit, t)}
|
||||
{marker.panel ? ` · ${marker.panel}` : ""}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -492,7 +493,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
numeric readings, mirroring the metric sub-pages. */}
|
||||
<MetricStatStrip
|
||||
summary={summary}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
seriesLabel={marker?.name}
|
||||
icon={FlaskConical}
|
||||
/>
|
||||
@@ -500,7 +501,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
<CardContent>
|
||||
<LabBiomarkerChart
|
||||
readings={readings}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
lowerBound={marker?.lowerBound ?? null}
|
||||
upperBound={marker?.upperBound ?? null}
|
||||
/>
|
||||
@@ -508,7 +509,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
|
||||
</Card>
|
||||
<LabTrendCard
|
||||
trend={marker?.trend}
|
||||
unit={marker?.unit ?? latest?.unit ?? ""}
|
||||
unit={labUnitText(marker?.unit ?? latest?.unit)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -39,6 +39,11 @@ import type {
|
||||
BiomarkerListResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import {
|
||||
labUnitLabel,
|
||||
labUnitText,
|
||||
withLabUnit,
|
||||
} from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
const DEFINE_NEW = "__define_new__";
|
||||
@@ -330,9 +335,8 @@ export function LabForm({
|
||||
? formatReferenceRange(selected.lowerBound, selected.upperBound, labNumber)
|
||||
: "";
|
||||
|
||||
// What the typed string resolved to, shown back so the user can see whether
|
||||
// a window was read out of it before the reading is saved. When none was,
|
||||
// the hint says the text is kept as written rather than staying silent.
|
||||
// What the typed string resolved to, shown back before the save. When no
|
||||
// window was read out of it, the hint says the text stays as written.
|
||||
const parsedSourceRange = parseReferenceRange(sourceRange, selected?.unit);
|
||||
const sourceRangeHint = parsedSourceRange
|
||||
? isUnreadableRange(parsedSourceRange)
|
||||
@@ -341,7 +345,7 @@ export function LabForm({
|
||||
parsedSourceRange.low,
|
||||
parsedSourceRange.high,
|
||||
labNumber,
|
||||
)} ${selected?.unit ?? ""}`.trim()
|
||||
)} ${labUnitText(selected?.unit)}`.trim()
|
||||
: "";
|
||||
|
||||
const footerNode = (
|
||||
@@ -406,7 +410,7 @@ export function LabForm({
|
||||
<SelectContent>
|
||||
{markers.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.name} · {m.unit}
|
||||
{m.name} · {labUnitLabel(m.unit, t)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!lockedBiomarkerId ? (
|
||||
@@ -457,7 +461,7 @@ export function LabForm({
|
||||
{selected ? (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
{" "}
|
||||
({selected.unit})
|
||||
({labUnitLabel(selected.unit, t)})
|
||||
</span>
|
||||
) : null}
|
||||
</Label>
|
||||
@@ -515,7 +519,8 @@ export function LabForm({
|
||||
|
||||
{resultType === "numeric" && selected && referenceText ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.referenceLabel")} {referenceText} {selected.unit}
|
||||
{t("labs.referenceLabel")}{" "}
|
||||
{withLabUnit(referenceText, selected.unit)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./lab-result-related-links";
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabDate, useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
@@ -103,7 +104,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
|
||||
reading.value === null
|
||||
? (reading.valueText ?? "")
|
||||
: labNumber(reading.value, reading.sourceValue),
|
||||
unit: reading.value === null ? undefined : reading.unit,
|
||||
unit: reading.value === null ? undefined : labUnitText(reading.unit),
|
||||
compare: before
|
||||
? {
|
||||
label: t("day.previousReading", { date: labDate(before.takenAt) }),
|
||||
|
||||
@@ -31,6 +31,7 @@ import type {
|
||||
LabOverviewResponse,
|
||||
LabResultDto,
|
||||
} from "./types";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
interface MarkerGroup {
|
||||
/** Stable group key: `bm:<biomarkerId>`, or `analyte:<lower>` for legacy rows. */
|
||||
@@ -39,6 +40,7 @@ interface MarkerGroup {
|
||||
biomarkerId: string | null;
|
||||
analyte: string;
|
||||
panel: string | null;
|
||||
/** The marker's unit as the card names it ("No unit" for a ratio). */
|
||||
unit: string;
|
||||
/** Every live reading of the marker, counted by the server. */
|
||||
count: number;
|
||||
@@ -53,13 +55,16 @@ interface MarkerGroup {
|
||||
* inert until the backfill links them. The server already orders by the
|
||||
* newest reading.
|
||||
*/
|
||||
function toGroups(markers: LabOverviewMarkerDto[]): MarkerGroup[] {
|
||||
function toGroups(
|
||||
markers: LabOverviewMarkerDto[],
|
||||
t: (key: string) => string,
|
||||
): MarkerGroup[] {
|
||||
return markers.map((marker) => ({
|
||||
key: marker.key,
|
||||
biomarkerId: marker.latest.biomarkerId,
|
||||
analyte: marker.latest.analyte,
|
||||
panel: marker.latest.panel,
|
||||
unit: marker.latest.unit,
|
||||
unit: labUnitLabel(marker.latest.unit, t),
|
||||
count: marker.count,
|
||||
trend: marker.trend,
|
||||
latest: marker.latest,
|
||||
@@ -152,7 +157,7 @@ export function LabList({
|
||||
});
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const base = toGroups(data?.markers ?? []);
|
||||
const base = toGroups(data?.markers ?? [], t);
|
||||
// v1.18.6 — honour the user's Labs sort choice. The server
|
||||
// order is most-recent-first; `recentAsc` reverses, the alpha
|
||||
// options sort by analyte name (#43), and `manual` applies the persisted
|
||||
@@ -173,7 +178,7 @@ export function LabList({
|
||||
return applyOrder(base, prefs.order, (g) => g.biomarkerId ?? g.key);
|
||||
}
|
||||
return base;
|
||||
}, [data?.markers, prefs.sortDir, prefs.order]);
|
||||
}, [data?.markers, prefs.sortDir, prefs.order, t]);
|
||||
|
||||
const listEmpty = !isLoading && !isError && groups.length === 0;
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RangeBar } from "@/components/targets/range-bar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { labUnitText, withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.37.34 — compact reference-range visualization for saved lab readings.
|
||||
@@ -43,11 +44,14 @@ export function LabReferenceRangeBar({
|
||||
const scale = Math.max(Math.abs(referenceLow ?? referenceHigh!), 1);
|
||||
const min = referenceLow ?? referenceHigh! - scale;
|
||||
const max = referenceHigh ?? referenceLow! + scale;
|
||||
const rangeLabel = !hasLow
|
||||
? `≤${labNumber(referenceHigh!)} ${unit}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)} ${unit}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)} ${unit}`;
|
||||
const rangeLabel = withLabUnit(
|
||||
!hasLow
|
||||
? `≤${labNumber(referenceHigh!)}`
|
||||
: !hasHigh
|
||||
? `≥${labNumber(referenceLow!)}`
|
||||
: `${labNumber(referenceLow!)}–${labNumber(referenceHigh!)}`,
|
||||
unit,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -59,7 +63,7 @@ export function LabReferenceRangeBar({
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
unit={unit}
|
||||
unit={labUnitText(unit)}
|
||||
compact
|
||||
tone="lab"
|
||||
formatValue={labNumber}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { TileHeader } from "@/components/insights/tile-header";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { BiomarkerTrendDto } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* v1.44 — the marker's slope over all its readings, and where that straight
|
||||
@@ -35,12 +36,19 @@ export function LabTrendCard({
|
||||
: trend.slopePerYear < 0
|
||||
? "falling"
|
||||
: "flat";
|
||||
const slopeLine = t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
});
|
||||
const shownUnit = labUnitText(unit);
|
||||
// A value without a unit leaves the {unit} slot empty; close the gap it
|
||||
// leaves rather than print a space before the full stop.
|
||||
const tidy = (text: string) =>
|
||||
shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " ");
|
||||
const slopeLine = tidy(
|
||||
t(`labs.trend.slope.${direction}`, {
|
||||
amount: per(trend.slopePerYear),
|
||||
unit: shownUnit,
|
||||
n: trend.n,
|
||||
since: fmt.dateShortSmartCalendar(trend.firstAt),
|
||||
}),
|
||||
);
|
||||
const projectionLine =
|
||||
trend.projection === "exits" && trend.exitDate
|
||||
? t(`labs.trend.projection.exits.${trend.exitBound ?? "high"}`, {
|
||||
@@ -85,11 +93,13 @@ export function LabTrendCard({
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit,
|
||||
})}
|
||||
{tidy(
|
||||
t("labs.trend.interval", {
|
||||
lo: fmt.number(trend.slopeCi95[0], 2),
|
||||
hi: fmt.number(trend.slopeCi95[1], 2),
|
||||
unit: shownUnit,
|
||||
}),
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
import { BiomarkerManager } from "./biomarker-manager";
|
||||
import type { BiomarkerDto, BiomarkerListResponse } from "./types";
|
||||
import { useLabsLocalOcr, useUpdateLabsLocalOcr } from "./use-ocr-extract";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function LabsSettings() {
|
||||
const { t } = useTranslations();
|
||||
@@ -61,7 +62,7 @@ export function LabsSettings() {
|
||||
const reorderItems: ReorderItem[] = ordered.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
secondary: m.panel ? `${m.unit} · ${m.panel}` : m.unit,
|
||||
secondary: [labUnitLabel(m.unit, t), m.panel].filter(Boolean).join(" · "),
|
||||
}));
|
||||
|
||||
const manual = prefs.sortDir === "manual";
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { NativeSelect } from "@/components/ui/native-select";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { labUnitLabel } from "@/lib/labs/dimensionless";
|
||||
|
||||
export interface MarkerSuggestionDto {
|
||||
biomarkerId: string;
|
||||
@@ -110,7 +111,7 @@ export function MarkerMatchField({
|
||||
<option key={suggestion.biomarkerId} value={suggestion.biomarkerId}>
|
||||
{t("labs.match.suggestionOption", {
|
||||
name: suggestion.name,
|
||||
unit: suggestion.unit,
|
||||
unit: labUnitLabel(suggestion.unit, t),
|
||||
})}
|
||||
</option>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
import type { LabResultDto } from "./types";
|
||||
import { useLabNumber } from "./use-lab-format";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* The reference window a reading's own report printed, shown wherever that
|
||||
@@ -54,11 +55,14 @@ export function SourceRangeNote({
|
||||
// then stated from the converted bounds, in the marker's unit.
|
||||
const inForce =
|
||||
reading.sourceReferenceText ??
|
||||
`${formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
)} ${reading.unit}`.trim();
|
||||
withLabUnit(
|
||||
formatReferenceRange(
|
||||
reading.referenceLow,
|
||||
reading.referenceHigh,
|
||||
labNumber,
|
||||
),
|
||||
reading.unit,
|
||||
);
|
||||
|
||||
const catalog = formatReferenceRange(
|
||||
reading.catalogReferenceLow,
|
||||
@@ -77,7 +81,7 @@ export function SourceRangeNote({
|
||||
: null}
|
||||
{reading.referenceDivergesFromCatalog && catalog
|
||||
? ` · ${t("labs.sourceRange.catalogDiffers", {
|
||||
range: `${catalog} ${reading.unit}`.trim(),
|
||||
range: withLabUnit(catalog, reading.unit),
|
||||
})}`
|
||||
: null}
|
||||
</span>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
formatDayMonthYear,
|
||||
} from "./timeline-dates";
|
||||
import { seriesNameLines, type TimelineLayout } from "./timeline-geometry";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** "18 ng/mL", every digit the reading holds, in the reader's locale. */
|
||||
export type LabValueText = (
|
||||
@@ -50,7 +51,7 @@ export function useLabValueText(): LabValueText {
|
||||
const labNumber = useLabNumber();
|
||||
return useCallback(
|
||||
(value, sourceValue, unit) =>
|
||||
`${labNumber(value, sourceValue)} ${unit}`.trim(),
|
||||
withLabUnit(labNumber(value, sourceValue), unit),
|
||||
[labNumber],
|
||||
);
|
||||
}
|
||||
@@ -84,7 +85,7 @@ export function useLabReference(): (
|
||||
(v) => labNumber(v),
|
||||
);
|
||||
return range
|
||||
? t("timeline.labs.reference", { range: `${range} ${unit}`.trim() })
|
||||
? t("timeline.labs.reference", { range: withLabUnit(range, unit) })
|
||||
: null;
|
||||
},
|
||||
[t, labNumber],
|
||||
|
||||
@@ -67,6 +67,7 @@ import { resolveLabFields } from "@/lib/labs/serialise";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Cap on distinct biomarkers in the block (newest reading first). */
|
||||
const MAX_BIOMARKERS = 24;
|
||||
@@ -120,7 +121,7 @@ export interface CoachLabReading {
|
||||
value: number | null;
|
||||
/** Qualitative result text ("negativ" / …); null for a numeric row. */
|
||||
valueText: string | null;
|
||||
/** Resolved unit; empty for a qualitative marker. */
|
||||
/** Resolved unit; empty for a qualitative marker and for a ratio. */
|
||||
unit: string;
|
||||
referenceLow: number | null;
|
||||
referenceHigh: number | null;
|
||||
@@ -359,7 +360,9 @@ export async function buildLabsSnapshotBlock(
|
||||
: null,
|
||||
value: row.value,
|
||||
valueText: row.valueText ? sanitizeValueText(row.valueText) : null,
|
||||
unit: sanitizeForPrompt(resolved.unit, MAX_UNIT_CHARS),
|
||||
// A value without a unit is stored as "1"; the model is shown none,
|
||||
// so it never writes "3.6 1".
|
||||
unit: sanitizeForPrompt(labUnitText(resolved.unit), MAX_UNIT_CHARS),
|
||||
referenceLow: resolved.referenceLow,
|
||||
referenceHigh: resolved.referenceHigh,
|
||||
// A qualitative row has nothing to compare against the bounds →
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
readWorkoutsAroundRange,
|
||||
WORKOUT_PICK_SELECT,
|
||||
} from "@/lib/workouts/canonical-read";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Everything a section reader needs about the day. */
|
||||
export interface DayFrame {
|
||||
@@ -717,10 +718,9 @@ async function labsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
title: row.analyte,
|
||||
meta:
|
||||
row.value !== null
|
||||
? // Every recorded digit, without the float noise a converted
|
||||
// reading carries (the labs page prints the same figure).
|
||||
? // Every recorded digit, minus a converted reading's float noise.
|
||||
// eslint-disable-next-line healthlog/no-unlocalised-decimal -- the day contract's meta is wire text; the lab figure keeps the notation it was stored in
|
||||
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${row.unit}`.trim()
|
||||
`${formatRecordedNumber(row.value, (v, digits) => v.toFixed(digits), row.sourceValue)} ${labUnitText(row.unit)}`.trim()
|
||||
: (row.valueText ?? null),
|
||||
note: openText(row.noteEncrypted, "lab note"),
|
||||
href: row.biomarkerId ? `/labs/${row.biomarkerId}` : "/labs",
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type DoctorReportPdfCursorState,
|
||||
type DoctorReportPdfRenderContext,
|
||||
} from "./render-context";
|
||||
import { withLabUnit } from "@/lib/labs/dimensionless";
|
||||
|
||||
export function buildClinicalRecordsNotesSection(
|
||||
context: DoctorReportPdfRenderContext,
|
||||
@@ -109,7 +110,7 @@ export function buildClinicalRecordsNotesSection(
|
||||
lr.panel ? `${lr.analyte} (${lr.panel})` : lr.analyte,
|
||||
isQualitative
|
||||
? (lr.valueText ?? "")
|
||||
: `${labNum(lr.value as number, lr.sourceValue)} ${lr.unit}`.trim(),
|
||||
: withLabUnit(labNum(lr.value as number, lr.sourceValue), lr.unit),
|
||||
isQualitative ? "—" : referenceCell(lr),
|
||||
isQualitative
|
||||
? ""
|
||||
@@ -166,10 +167,10 @@ export function buildClinicalRecordsNotesSection(
|
||||
y += 4;
|
||||
for (const lr of diverging) {
|
||||
doc.text(
|
||||
`* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${rangeText(
|
||||
lr.catalogReferenceLow,
|
||||
lr.catalogReferenceHigh,
|
||||
)} ${lr.unit}`.trim(),
|
||||
`* ${lr.analyte}: ${t("doctorReport.labsCatalogRangeLabel")} ${withLabUnit(
|
||||
rangeText(lr.catalogReferenceLow, lr.catalogReferenceHigh),
|
||||
lr.unit,
|
||||
)}`.trim(),
|
||||
margin,
|
||||
y,
|
||||
);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { userDayKey } from "@/lib/tz/resolver";
|
||||
import { sanitizeForPrompt } from "@/lib/insights/sanitize";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
export type BiomarkerStatusResult = {
|
||||
hasProvider: boolean;
|
||||
@@ -321,7 +322,7 @@ function biomarkerSnapshot(args: {
|
||||
// position below, so both are scrubbed exactly like the coach
|
||||
// labs-snapshot scrubs the same fields.
|
||||
const safeMarkerName = sanitizeForPrompt(marker.name, 120);
|
||||
const safeMarkerUnit = sanitizeForPrompt(marker.unit, 40);
|
||||
const safeMarkerUnit = sanitizeForPrompt(labUnitText(marker.unit), 40);
|
||||
|
||||
const snapshot = {
|
||||
locale,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isModuleEnabled } from "@/lib/modules/gate";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Up to eight results of the panel, fenced; null when there is nothing to say. */
|
||||
export async function labsPanelEvidence(
|
||||
@@ -33,7 +34,8 @@ export async function labsPanelEvidence(
|
||||
lab.value === null
|
||||
? fenceUserText(lab.valueText ?? "not reported")
|
||||
: String(lab.value);
|
||||
const unit = lab.unit ? ` ${fenceUserText(lab.unit)}` : "";
|
||||
const shown = labUnitText(lab.unit);
|
||||
const unit = shown ? ` ${fenceUserText(shown)}` : "";
|
||||
return `- Newly arrived lab result: ${fenceUserText(lab.analyte)} ${value}${unit}.`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { DoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { getMetricStatusMeta } from "@/lib/insights/metric-status-registry";
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/**
|
||||
* Fallback units for the headline specialised metric types the generic
|
||||
@@ -91,7 +92,7 @@ export function summariseForVisit(
|
||||
panel: l.panel,
|
||||
value: l.value,
|
||||
valueText: l.valueText,
|
||||
unit: l.unit,
|
||||
unit: labUnitText(l.unit),
|
||||
referenceLow: l.referenceLow,
|
||||
referenceHigh: l.referenceHigh,
|
||||
takenAt: l.takenAt,
|
||||
|
||||
@@ -34,6 +34,7 @@ import { detectDerivedBriefingSignals } from "@/lib/insights/derived-briefing";
|
||||
import { buildBriefingIllnessCycleContext } from "@/lib/insights/illness-cycle-briefing";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import type { McpAuthContext } from "./auth";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -254,7 +255,7 @@ async function collectLabTrajectories(
|
||||
const history = readings.slice(0, 12).map((r) => ({
|
||||
value: r.value,
|
||||
valueText: r.valueText,
|
||||
unit: r.unit,
|
||||
unit: labUnitText(r.unit),
|
||||
referenceLow: r.referenceLow,
|
||||
referenceHigh: r.referenceHigh,
|
||||
takenAt: r.takenAt.toISOString(),
|
||||
@@ -263,7 +264,7 @@ async function collectLabTrajectories(
|
||||
return {
|
||||
analyte: latest.analyte,
|
||||
panel: latest.panel,
|
||||
unit: latest.unit,
|
||||
unit: labUnitText(latest.unit),
|
||||
readingsOnFile: readings.length,
|
||||
latest: history[0],
|
||||
history,
|
||||
|
||||
@@ -2,12 +2,17 @@ import { z } from "zod/v4";
|
||||
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import { resolveMarkerForRead } from "@/lib/labs/biomarker-resolve";
|
||||
import { getLabHistory, LAB_HISTORY_MAX_LIMIT } from "@/lib/mcp/rich-reads";
|
||||
import {
|
||||
getLabHistory,
|
||||
LAB_HISTORY_MAX_LIMIT,
|
||||
type LabHistoryResult,
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import { decodeOffsetCursor } from "@/lib/mcp/pagination";
|
||||
import type { McpAuthContext } from "../auth";
|
||||
import { coachReadOutput } from "./output-shapes";
|
||||
import { runCoachTool, whenModuleOn } from "./shared";
|
||||
import { READ_ONLY_ANNOTATIONS, type McpToolDefinition } from "./types";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Output schema for `get_labs` — latest-per-biomarker OR a paginated history. */
|
||||
const getLabsOutput: z.ZodRawShape = {
|
||||
@@ -59,6 +64,19 @@ function fenceLabText(value: unknown): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A value without a unit (a ratio, INR) is stored as the UCUM unity "1". The
|
||||
* model is shown no unit for it, as the Coach's lab block does, so it never
|
||||
* writes "3.6 1".
|
||||
*/
|
||||
function withShownUnits(result: LabHistoryResult): LabHistoryResult {
|
||||
if (!result.readings) return result;
|
||||
return {
|
||||
...result,
|
||||
readings: result.readings.map((r) => ({ ...r, unit: labUnitText(r.unit) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** `get_labs` behind its module gate: latest-per-biomarker or a history page. */
|
||||
function readLabs(
|
||||
ctx: McpAuthContext,
|
||||
@@ -78,7 +96,7 @@ function readLabs(
|
||||
analyte,
|
||||
offset: decodeOffsetCursor(args.cursor),
|
||||
limit: LAB_HISTORY_MAX_LIMIT,
|
||||
});
|
||||
}).then(withShownUnits);
|
||||
}
|
||||
// Default: latest reading per biomarker via the Coach labs read. A name that
|
||||
// resolves to one of the user's markers (an alias or a catalog abbreviation
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { fenceUserText } from "@/lib/ai/coach/data-fence";
|
||||
import type { NutrientsDailyResult } from "@/lib/mcp/nutrients-read";
|
||||
import type { NutrientUpperLimit } from "@/lib/nutrients/catalog";
|
||||
import { labUnitText } from "@/lib/labs/dimensionless";
|
||||
|
||||
/** Finite-number guard for the citation summarisers. */
|
||||
function num(v: unknown): number | null {
|
||||
@@ -66,7 +67,9 @@ export function plainLabText(rid: string, result: unknown): string {
|
||||
: typeof reading.valueText === "string"
|
||||
? reading.valueText
|
||||
: "—";
|
||||
const unit = typeof reading.unit === "string" ? reading.unit : "";
|
||||
// A value without a unit (a ratio, INR) is stored as "1"; it reads as none.
|
||||
const unit =
|
||||
typeof reading.unit === "string" ? labUnitText(reading.unit) : "";
|
||||
const status =
|
||||
typeof reading.rangeStatus === "string" ? reading.rangeStatus : "";
|
||||
const taken =
|
||||
|
||||
Reference in new issue
Block a user