diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index a23121b84..a9e9c0346 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 345, - "total": 111930, + "total": 111929, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -93,7 +93,7 @@ "components/documents/document-card.tsx :: DocumentCard": 354, "components/documents/document-detail-sheet.tsx :: DocumentDetailSheet": 325, "components/documents/document-facts-review.tsx :: DocumentFactsSection": 242, - "components/documents/document-facts-review.tsx :: FactRow": 263, + "components/documents/document-facts-review.tsx :: FactRow": 262, "components/documents/document-filter-bar.tsx :: DocumentFilterBar": 348, "components/documents/document-record-links.tsx :: DocumentRecordLinks": 202, "components/documents/document-timeline.tsx :: DocumentTimeline": 314, diff --git a/src/app/api/labs/route.ts b/src/app/api/labs/route.ts index 65373f80c..32fb3924e 100644 --- a/src/app/api/labs/route.ts +++ b/src/app/api/labs/route.ts @@ -17,6 +17,7 @@ import { withIdempotency } from "@/lib/idempotency"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { emitDataArrival } from "@/lib/arrivals/emit-shared"; import { resolveOrMintBiomarker } from "@/lib/labs/biomarker-store"; +import { isDimensionlessUnit } from "@/lib/labs/dimensionless"; import { readingInMarkerUnit } from "@/lib/labs/reading-conversion"; import { type ResolvedBiomarker, @@ -270,7 +271,7 @@ async function postLabResult(request: NextRequest) { meta: { biomarkerId: biomarker.id, reason: reading.reason }, }); return apiError( - `The reading is in ${unit}, but ${biomarker.name} is tracked in ${biomarker.unit}.`, + unitMismatchMessage(unit, biomarker.name, biomarker.unit), 422, { errorCode: "labs.unit.mismatch", @@ -392,3 +393,19 @@ async function postLabResult(request: NextRequest) { return apiSuccess(dto, 201); } + +/** The refusal for a reading whose unit differs from its marker's. */ +function unitMismatchMessage( + readingUnit: string | undefined, + markerName: string, + markerUnit: string, +): string { + const readingSide = + !readingUnit || isDimensionlessUnit(readingUnit) + ? "The reading has no unit" + : `The reading is in ${readingUnit}`; + const markerSide = isDimensionlessUnit(markerUnit) + ? "tracked without a unit" + : `tracked in ${markerUnit}`; + return `${readingSide}, but ${markerName} is ${markerSide}.`; +} diff --git a/src/components/documents/document-facts-review.tsx b/src/components/documents/document-facts-review.tsx index 10de24b49..00745a89c 100644 --- a/src/components/documents/document-facts-review.tsx +++ b/src/components/documents/document-facts-review.tsx @@ -223,7 +223,7 @@ export function FactRow({ onCheckedChange: (checked: boolean) => void; /** A per-fact commit refusal from the last confirm attempt, or null. */ failure: string | null; - /** v1.43 — the suggestion the person picked for a lab value, or null. */ + /** The suggestion the person picked for a lab value, or null. */ chosenBiomarkerId: string | null; onChooseBiomarker: (biomarkerId: string | null) => void; /** The account's marker name by id, for the matched marker. */ @@ -252,8 +252,7 @@ export function FactRow({ { onSuccess: () => { setVerifying(false); - // The refetched fact arrives with needsReview cleared; include it — - // the person just asserted these values. + // Include it: the refetched fact clears needsReview, as asserted. onCheckedChange(true); }, }, diff --git a/src/components/labs/__tests__/lab-trend-card.test.tsx b/src/components/labs/__tests__/lab-trend-card.test.tsx index 0a30bd0e2..35c7d45ac 100644 --- a/src/components/labs/__tests__/lab-trend-card.test.tsx +++ b/src/components/labs/__tests__/lab-trend-card.test.tsx @@ -7,7 +7,7 @@ import { primeLocales } from "@/__tests__/helpers/prime-locales"; import { LabTrendCard } from "../lab-trend-card"; import type { BiomarkerTrendDto } from "../types"; -primeLocales("de"); +primeLocales("de", "ko"); const OK: BiomarkerTrendDto = { status: "ok", @@ -29,11 +29,12 @@ const OK: BiomarkerTrendDto = { function render( trend: BiomarkerTrendDto | undefined, - locale: "en" | "de" = "en", + locale: "en" | "de" | "ko" = "en", + unit = "mg/dL", ) { return renderToStaticMarkup( - + , ); } @@ -48,6 +49,16 @@ describe("", () => { expect(html).toContain("4.25 to 20.75 mg/dL"); }); + it("leaves no gap where a value without a unit has none, in any language", () => { + const en = render(OK, "en", "1"); + expect(en).toContain("Rising by about 13 a year across 4 readings"); + expect(en).toContain("4.25 to 20.75."); + const ko = render(OK, "ko", "1"); + expect(ko).toContain("약 13씩"); + expect(ko).not.toMatch(/ 씩/); + expect(ko).not.toContain("\u0000"); + }); + it("speaks German with du", () => { const html = render(OK, "de"); expect(html).toContain("deines Referenzbereichs"); diff --git a/src/components/labs/lab-trend-card.tsx b/src/components/labs/lab-trend-card.tsx index c44e01897..e88b129f6 100644 --- a/src/components/labs/lab-trend-card.tsx +++ b/src/components/labs/lab-trend-card.tsx @@ -37,14 +37,16 @@ export function LabTrendCard({ ? "falling" : "flat"; 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. + // A value without a unit fills the {unit} slot with a marker and drops it + // together with the space before it, so no language keeps the gap ("0.3 .", + // "0.3 씩"). + const SLOT = "\u0000"; const tidy = (text: string) => - shownUnit ? text : text.replace(/ +([.,])/g, "$1").replace(/ {2,}/g, " "); + shownUnit ? text : text.replace(new RegExp(` ?${SLOT}`, "g"), ""); const slopeLine = tidy( t(`labs.trend.slope.${direction}`, { amount: per(trend.slopePerYear), - unit: shownUnit, + unit: shownUnit || SLOT, n: trend.n, since: fmt.dateShortSmartCalendar(trend.firstAt), }), @@ -97,7 +99,7 @@ export function LabTrendCard({ t("labs.trend.interval", { lo: fmt.number(trend.slopeCi95[0], 2), hi: fmt.number(trend.slopeCi95[1], 2), - unit: shownUnit, + unit: shownUnit || SLOT, }), )}

diff --git a/src/lib/insights/feature-blocks.ts b/src/lib/insights/feature-blocks.ts index de67e5d73..52184829c 100644 --- a/src/lib/insights/feature-blocks.ts +++ b/src/lib/insights/feature-blocks.ts @@ -29,6 +29,7 @@ import { canonicalWorkoutsInRange, readWorkoutsAroundRange, } from "@/lib/workouts/canonical-read"; +import { labUnitText } from "@/lib/labs/dimensionless"; import { deriveBucketedTypes } from "@/lib/signals/adapters/correlation"; import type { AggregatedFeatures, @@ -434,7 +435,7 @@ export async function readLabsBriefingBlock( analyte: resolved.analyte, value: latest.value, valueText: latest.valueText ? sanitizeLabel(latest.valueText, 60) : null, - unit: resolved.unit, + unit: labUnitText(resolved.unit), rangeStatus, trend, takenAt: latest.takenAt.toISOString(), diff --git a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap index 0abc8fcce..0cc087401 100644 --- a/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap +++ b/src/lib/mcp/__tests__/__snapshots__/tools-list.write-session.snap @@ -378,7 +378,7 @@ { "name": "get_labs", "title": "Get lab results", - "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "description": "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", "inputSchema": { "type": "object", "properties": { diff --git a/src/lib/mcp/record-links.ts b/src/lib/mcp/record-links.ts index d06542b49..38a1a01ae 100644 --- a/src/lib/mcp/record-links.ts +++ b/src/lib/mcp/record-links.ts @@ -104,7 +104,7 @@ async function labLinks( const name = analyte.get(target.id); if (!name) continue; out.set(target.id, { - id: `lab:${name}`, + id: `lab:${scrubFenceMarkers(name)}`, kind: "lab", label: scrubFenceMarkers(target.label), date: linkDate(target.date), diff --git a/src/lib/mcp/tools/get-labs.ts b/src/lib/mcp/tools/get-labs.ts index a570e8da2..e20757a00 100644 --- a/src/lib/mcp/tools/get-labs.ts +++ b/src/lib/mcp/tools/get-labs.ts @@ -140,7 +140,7 @@ export const getLabsTool: McpToolDefinition = { name: "get_labs", title: "Get lab results", description: - "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", + "Fetch the user's lab results. By default returns the latest reading per biomarker over the whole record. Filtered to one named analyte, each matching marker also carries a summarised history (the newest earlier values in full, older ones as one mean / min / max / count per year). Pass history:true with an analyte to return that analyte's reading TRAJECTORY (newest first, paginated). Each reading carries its value, unit, reference range, and in-range/below/above status; a ratio or an index recorded without a unit has an empty unit. Each reading (the latest, an earlier value in full, a trajectory reading; not a per-year summary) carries links: the ids of the records it is linked to (the report it was read from, the visits it is filed against), [] when none, at most 25 with linksTruncated; every linked record is described once in linkedRecords (id for fetch, kind, label, date, and for a document imported from Paperless-ngx or Papra its sourceSystem and sourceId). A document is listed only while the Documents module is on. Returns { present: false } when no labs match, and { present: false, reason: \"module_disabled\" } when the labs module is switched off.", inputShape: { analyte: z.string().min(1).max(80).optional(), history: z