---
title: LIS Reference-Range Display (worklist / validation / print)
slug: lis-reference-range-display
status: active
owner: hazem
updated: 2026-06-20
refs:
  - moon-erp/src/app/features/lis/dept-worklist/dept-worklist.component.ts        # worklist
  - moon-erp/src/app/features/lis/validation-worklist/validation-worklist.component.ts  # validation (gold standard)
  - moon-erp/src/app/features/lis/result-preview/lis-result-preview.component.ts  # print preview
  - moon-erp-be/Modules/LIS/app/Services/WorklistContextService.php               # BE row builder
related:
  - middleware
---

## Context
Reported on **LR-2026-00369 / patient AYMAN SAAD / VITB12 (Vitamin B12)**: the reference range "wasn't showing / was unformatted" in the worklist. Asked to make the range render **consistently** across worklist + validation + print.

## Root cause — 3 surfaces formatted the range 3 different ways (1 was the reference, 2 were broken)
- **Worklist** (`/lab/worklist`, `dept-worklist`): `getRangeDisplay()` only read `normalMin/normalMax` → **ignored text ranges (`text_normal`) entirely**, no decimal trim, no fallback. So any test whose range is qualitative/text (or whose numeric snapshot was empty) showed **blank**.
- **Validation** (`/lab/validation`, `validation-worklist`): already had a full `formatRangeFromRow()` (text-first → numeric snapshot → investigation `normal_ranges` matched by gender/age → trim). **This is the gold standard — left unchanged.**
- **Print** (`result-preview` → PDF): `formatRangeFromInv()` read the **wrong field names** (`nr.min/nr.max/nr.label`) while the API emits `normal_min/normal_max/text_normal` → printed range was effectively **always blank**; also ignored text ranges. (`LabResultResource` does NOT emit a `reference_range` string, so the `r.reference_range || …` path always fell through to this broken fallback.)
- **BE gap:** `WorklistContextService` already emits a ready `reference_range_text`, but it only pulled `text_normal` (+ the result's numeric snapshot). For a **pending** numeric test (no snapshot yet) it returned empty → worklist blank even when the investigation has a numeric range.

## Data model (so future edits don't re-derive it)
- `lab_investigations.result_type` ∈ {numeric, text, selection, memo, formula, histopathology, file, culture}; `reference_range_type` ∈ {fixed, none, age_gender_based}.
- Ranges live in **`lab_investigation_normal_ranges`** (multiple rows / investigation): `normal_min, normal_max, critical_low, critical_high, unit, text_normal`, banded by `age_from/age_to/gender`. **Text ranges use `text_normal`** (e.g. "Negative"); numeric use min/max (B12 = 200–900 pg/mL).
- A `lab_results` row **snapshots** `normal_min, normal_max, critical_low, critical_high, unit` (but **not** a formatted `reference_range` string).
- Worklist rows come from `GET /lis/worklist/rows/{id}` → `WorklistController::rows` → `WorklistContextService` (investigations eager-load `normalRanges`, line ~504). The BE row carries `reference_range_text` (the canonical formatted string).

## Fix shipped (2026-06-20, branch hazemdev2)
1. **BE `WorklistContextService`** — new `matchedReferenceRange()` + `matchedRangeNumeric()`: `reference_range_text` now = text_normal (gender/age matched) → else result snapshot min/max → else **investigation's matched numeric range** → all via `formatReferenceRange()` (trims zeros). So pending numeric tests now carry a range too. (php -l ✓, Pint ✓, `local-deploy.sh` ✓.)
2. **FE worklist `dept-worklist`** — added `referenceRangeText` to `ResultRow` (mapped from `sr.reference_range_text` in all 3 literal sites); rewrote `getRangeDisplay()` to **prefer `referenceRangeText`**, else trimmed numeric fallback (+ added `trimTrailingZeros`).
3. **FE print `result-preview`** — rewrote `formatRangeFromInv()` to read the **correct** keys (`normal_min/normal_max/text_normal`), prefer text, trim zeros (+ `trimZeros`).
4. **Validation** — unchanged (already correct; it's the pattern the other two now match).
- `ng build` ✓ (no TS errors); deployed to moonui2 `/app`.

## Decisions & why
- **One canonical string from the BE** (`reference_range_text`) that all read surfaces consume → no more 3-way drift. Validation keeps its own richer client fallback (it also has the investigation `normal_ranges` + patient age/gender locally) and stays the reference behavior.
- **Text range wins over numeric** everywhere (qualitative tests like "Negative"/"Non-reactive").

## Gotchas
- `LabResultResource` emits `normal_min/normal_max` only — **no `reference_range` string** — so any FE that does `r.reference_range || fallback` always hits the fallback; the fallback MUST be correct.
- `result-preview` previously read `nr.min/nr.max` — **wrong keys**; the real shape is `normal_min/normal_max/text_normal` (see `LabInvestigationNormalRangeResource`).
- Worklist nav/range fields are mapped in **3** `ResultRow` literal sites in `dept-worklist` (interface + synth-panel placeholder + 2 mappers) — add new fields to all.

## Multi-line text-range formatting (2026-06-21) — ✅ shipped
The `text_normal` data is messy free text: literal `\n`/`\t` escapes, en/em/double dashes, comma-separated age bands **and** comma thousands separators (`25,700`), ragged spacing. Built ONE shared pure util **`src/app/core/utils/reference-range.util.ts` → `formatReferenceRangeText()`** that normalizes any of it into **one band/segment per line** as `label: lo - hi`:
- Decodes `\n`/`\t`; normalizes dashes; splits comma-age-bands (NOT thousands commas); trims trailing decimal zeros; tidy `:` spacing.
- **Keeps titer ratios tight** (`1:40`, `1:160` — digit:digit colon is NOT spaced — serology dilutions, not label/value) and passes plain text (`Negative`) through.
- Wired into **all 5 render points**: `dept-worklist.getRangeDisplay`, `validation-worklist.getRangeDisplay`, `result-preview.formatRangeFromInv` (both call sites), `lis-print-report.service` (HTML print), `lis-report-pdf.service.formatRange` (jsPDF — autotable renders the `\n`). Worklist+validation `.range-text` SCSS switched `nowrap → pre-line` so multi-band ranges show in full.
- Verified against 30 real `text_normal` samples. FE `ng build` ✓, deployed to moonui2 `/app`. **FE commit `f4369019b`.**

## Open / next
- Other surfaces still have local `trimTrailingZeros`/`trimZeros` — could route through the new util later (cosmetic; output already aligned).
- Range fix + formatter: **fix already merged to `main`; the formatter commit `f4369019b` is on hazemdev2 (push/merge pending)**.
