下拉重新整理

呈現臨床資料(二)

4,295 字 11 分鐘閱讀 9 次閱讀

火線超人有一個 LIFF 頁面,列出伺服器上的病人讓使用者挑。LIFF 就是在 LINE 裡開起來的網頁。

那個頁面每一列顯示四個欄位:identifier、姓名、性別、生日。其中姓名那一欄有一個 placeholder,因為撈到的病人可能沒有名字。

day15 講過同一件事。今天的病況跟用藥更麻煩。一筆病況的名稱,FHIR 裡有三個不同的地方可以放。三個都可能是空的,你必須一層一層往下找。

先看兩筆資料

一筆 Condition,這裡只留跟畫面有關的欄位:

{
  "resourceType": "Condition",
  "clinicalStatus": {
    "coding": [
      {
        "system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
        "code": "resolved"
      }
    ]
  },
  "code": {
    "coding": [
      {
        "system": "http://snomed.info/sct",
        "code": "444470001",
        "display": "Injury of anterior cruciate ligament"
      }
    ],
    "text": "Injury of anterior cruciate ligament"
  },
  "onsetDateTime": "2017-03-21T23:37:54+00:00",
  "abatementDateTime": "2017-05-27T23:37:54+00:00"
}

注意 codeclinicalStatus 的結構是一樣的,都是 coding 陣列加一個可選的 text。這個結構叫 CodeableConcept。FHIR 裡幾乎所有「這是什麼」的欄位都長這樣。

但兩者的內容差很多。code 那個有 display 也有 textclinicalStatus 那個只有 systemcode,另外兩層都沒有。

再看一筆 MedicationRequest:

{
  "resourceType": "MedicationRequest",
  "status": "stopped",
  "intent": "order",
  "medicationCodeableConcept": {
    "coding": [
      {
        "system": "http://www.nlm.nih.gov/research/umls/rxnorm",
        "code": "310965",
        "display": "Ibuprofen 200 MG Oral Tablet"
      }
    ],
    "text": "Ibuprofen 200 MG Oral Tablet"
  },
  "authoredOn": "2017-03-21T23:37:54+00:00",
  "dosageInstruction": [{ "sequence": 1, "asNeededBoolean": true }]
}

status 這裡不是 CodeableConcept,是一個單純的字串。同一份規範裡兩種寫法並存,取值的程式就得分開寫。

三層取值的優先順序

CodeableConcept 取顯示文字要自己寫一個函式,規則寫死成三層。這跟 day15 取姓名那四層 fallback 是同一招:

export function displayOf(concept, fallback = '(未提供名稱)') {
  if (!concept) return fallback
  if (concept.text) return concept.text
  const coding = concept.coding?.[0]
  return coding?.display ?? coding?.code ?? fallback
}

text 優先,因為那是來源系統原本就寫給人看的那一句。它可能是醫師自己打的字,比標準名稱貼近臨床實際。

text 沒有就往下找,最後只剩 code 本身。一串數字不好看,但至少讓人知道有這筆資料,工程師也查得出是什麼。

重點是這是一條規則,不是每個欄位各寫一套。每個 CodeableConcept 都走同一條。不要在 Condition 寫一種、MedicationRequest 寫另一種。

三筆病況我逐筆看過,病名那個欄位都有 text,所以三筆都停在第一層。第二層的 display 與第三層的 code 在這批資料上輪不到。但 text 在規範裡是選填的,換一台伺服器就可能只給你 display

CodeableConcept 三層取值的拆解圖,cream 底色,標題「同一個結構,取到的是哪一層」,副標「同一筆 Condition 裡的兩個 CodeableConcept,走同一條規則」。畫面主體是一個三列兩欄的矩陣。最上方兩個深藍色欄標題並排:左欄是等寬字的 code,底下一行灰色小字寫 http://snomed.info/sct;右欄是 clinicalStatus,底下一行灰色小字寫 …/CodeSystem/condition-clinical。左側由上而下三個列標籤,各佔兩行,依序是第一層配 .text、第二層配 coding 陣列第一個元素的 display、第三層配同一個元素的 code。第一列左格是深藍底白字的 Injury of anterior cruciate ligament,右上角掛一個珊瑚色小標籤寫「取這個」;右格是灰色虛線空框,裡面寫「沒有這一層」。第二列左格是白底灰字的同一句 Injury of anterior cruciate ligament,右上角掛一個淺灰色標籤寫「輪不到」;右格同樣是虛線空框寫著「沒有這一層」。第三列左格是白底灰字的 444470001,一樣掛著「輪不到」標籤;右格是整格珊瑚色底白字的 resolved,右上角掛一個深藍色標籤寫「只剩這個」。兩個被取到的格子落在左上與右下,形成一條對角。最下方兩塊深藍色區塊並排:左塊標題「病況名稱停在第一層」,內文是來源系統寫給人看的那一句,直接顯示;右塊標題「病況狀態掉到第三層」,內文以珊瑚色標出 resolved 是英文代碼,要自己對照成中文。圖片最下方一行灰字:三層都空的時候才輪到 fallback,這筆資料沒有走到那一步

狀態欄位要另外一個函式:

export function codeOf(concept) {
  return concept?.coding?.[0]?.code ?? 'unknown'
}

差別在目的。displayOf 要的是給人看的字,codeOf 要的是給程式判斷的值。clinicalStatusdisplayOf 會拿到 resolved 這個英文字,直接顯示在中文畫面上很怪。拿它去比對狀態也靠不住,那兩層的文字不保證存在,code 才是值集定死的那個。

拿到 code 之後自己對照成中文:

const CONDITION_STATUS = {
  active: '仍在追蹤',
  recurrence: '復發',
  relapse: '復發',
  inactive: '未在追蹤',
  remission: '緩解',
  resolved: '已解決',
  unknown: '狀態不明',
}

這張表要涵蓋規範列出的所有值,不是只寫你在測試資料裡看到的那兩三個。查不到就回原字串,不要讓 undefined 上畫面。

Condition 有兩個狀態欄位

上面那筆 Condition 除了 clinicalStatus,還有一個 verificationStatus

兩個欄位講的是不同的事。clinicalStatus 是這個病況現在怎麼樣,值有 activeresolvedremission 這些。verificationStatus 是這個診斷本身有沒有被確認,值包含 unconfirmedprovisionaldifferentialrefutedentered-in-error

refuted 的意思是「查過了,不是這個病」。把它當成確診顯示在病況清單上,是臨床安全問題不是顯示問題。

這批 sandbox 資料我取樣兩百筆,那道檢查你在這裡看不出效果。但欄位在規範裡,真實系統會用到,清單至少要把非 confirmed 的標出來或濾掉。

Condition 兩個狀態欄位的對照圖,cream 底色,標題「Condition 的兩個狀態欄位」,副標「取樣 200 筆,一個欄位有兩種值,另一個只有一種」。上半部左右兩張白色卡片。左卡標題是等寬字的 clinicalStatus,底下灰色問句「這個病況現在怎麼樣」,再往下一行粗體「實際出現 2 種值」,接一條分成兩段的橫條:左段深藍色佔 54%,大字 108 加標籤 active;右段灰藍色佔 46%,大字 92 加標籤 resolved;橫條下方灰字寫「兩種值加起來就是取樣的 200 筆」。右卡標題是 verificationStatus,問句「這個診斷確認了沒」,粗體「實際出現 1 種值」,接一條整條深藍色、佔滿卡片寬度的橫條,大字 200 加標籤 confirmed,下方灰字寫「200 筆全部同一個值,一筆例外都沒有」。兩張卡片下方是一條白底加灰色虛線框的橫帶,左側寫著 verificationStatus 值集裡的其他五個,這批資料都是 0 筆;右側並排五個膠囊標記,依序為 unconfirmed、provisional、differential、refuted、entered-in-error,其中 refuted 是白底珊瑚色字加珊瑚色外框,其餘四個是淺灰底灰字。再下方一整條深藍色橫幅,第一行白色粗體寫「這批資料看不到,但欄位在規範裡」,第二行寫把一筆 refuted 的診斷當成確診顯示,是臨床安全問題不是顯示問題,其中 refuted 以珊瑚色標示。圖片最下方一行灰字:兩個欄位各自獨立,clinicalStatus 是 active 不代表這個診斷被確認過

排序交給伺服器也可以

範例是把資料撈回來之後在前端排:

.sort((a, b) => b.onset.localeCompare(a.onset))

日期用字串比大小就好。FHIR 的日期是 ISO 8601 格式,年月日由大到小。截到日這一層字典序跟時間序一致,不必轉成 Date 物件。

伺服器端排序這條路也走得通,Condition?patient=<id>&_sort=-onset-date 實測回 200,順序跟前端排出來的一樣。

這裡選前端排有兩個理由。一是這位病人的資料量很小,三筆病況兩筆用藥,多發一個參數換不到什麼。

二是排序參數的名稱每種資源都不一樣。Condition 叫 onset-date,MedicationRequest 叫 authoredon,不是統一的 date。要用伺服器排就得一種一種查清楚,搜尋參數怎麼查怎麼組,day20 會專門講。

藥名有兩種放法

medicationCodeableConcept 是把藥名內嵌在這筆處方裡。另一種寫法是 medicationReference,指向一個獨立的 Medication 資源。

兩種都合法,伺服器選哪一種你不能假設:

name: one.medicationCodeableConcept
  ? displayOf(one.medicationCodeableConcept)
  : `(需另外讀取 ${one.medicationReference?.reference})`

碰到 reference 那種要多發一次請求才拿得到藥名。這個範例先不看,只把它標出來讓你知道有這條路。

dosageInstruction 也有同樣的問題。它的 text 是給人看的整句用法,真實 EHR 多半會給。這台 sandbox 只給 asNeededBoolean,連 text 都沒有:

function dosageText(dosage) {
  if (!dosage) return ''
  if (dosage.text) return dosage.text
  return dosage.asNeededBoolean ? '需要時服用' : ''
}

實際跑出來,兩筆用藥有一筆的用法欄位是空的。那筆 dosageInstructionasNeededBoolean 都沒有,只有一個 sequence。空白就空白,不要編一句「依醫囑服用」填進去。

這位病人沒有在吃的藥

實跑結果是兩筆用藥,狀態都是 stopped

這件事值得多說一句。你很容易把這一段寫成「列出病人正在吃的藥」,然後用 status === 'active' 過濾。在這位病人身上,那個清單會是空的。

所以清單不過濾,全部列出來並把狀態寫在旁邊。使用者看到「已停用」跟看到空清單,得到的資訊完全不同。前者知道這個人有用藥史但停了,後者只會以為系統壞了。

病況那邊三筆,兩筆 resolved 一筆 active,剛好夠示範狀態不是裝飾。

版面:三塊資料都 render 到畫面之後

到現在為止畫面上有三個區塊:基本資料、趨勢圖、兩張表。原生 DOM 生出來的表格沒有任何樣式,欄位擠在一起,看不出哪裡是一列的邊界。

要處理版面,但這個專案沒有裝 npm,也沒有建置步驟。Tailwind 的正常流程是兩樣都要。

另外,還有一條路:@tailwindcss/browser。它是一個跑在瀏覽器裡的 JIT 編譯器,掃 DOM 上的 class 名稱即時產生 CSS。一個 script 標籤就結束,沒有設定檔。

curl -o vendor/tailwind-browser.js \
  https://cdn.jsdelivr.net/npm/@tailwindcss/[email protected]/dist/index.global.js

不過有件事要先講:這個版本官方說只適合用在開發,不要用在正式環境。 理由是每個使用者的瀏覽器都要跑一次編譯。正式做法是裝 npm 加一個建置步驟,讓 Tailwind 事先產出一份靜態 CSS。裡面只有你真的用到的那些 class。day28 的上線檢查清單會再回來處理這件事。

你載了一個編譯器進瀏覽器,只為了 9 KB 的輸出。開發環境是可以,但是正式環境就不合適了。

vendor 目錄現在三個檔案。這是免建置的代價,展開來讓你自己判斷。

vendor 目錄檔案大小的組成圖,cream 底色,標題「三個檔案 534 KB,一半是編譯器」,副標「vendor 目錄實測,@tailwindcss/browser 版本 4.3.3」。上半部一行小字寫「vendor 目錄合計 546472 bytes,約 534 KB」,底下是一條橫跨整個畫面的堆疊長條,依檔案大小分成三段:最左邊灰藍色段佔一成,標著 54 KB;中間中藍色段佔近四成,標著 204 KB 與等寬字 chart.umd.js;最右邊深藍色段佔一半以上,標著 276 KB 與等寬字 tailwind-browser.js。長條下方一行灰字說明最左邊那段是 fhir-client.pure.min.js,55665 bytes。中段一條細的分隔線。下半部先是一行小字「載進瀏覽器的編譯器,對上它產出的樣式」,底下兩條左端對齊的橫條:上面一條是佔滿寬度的深藍色長條,內部白色粗體寫 282289 bytes 編譯器本身;下面一條是一個極窄的珊瑚色方塊,寬度只有上面那條的三十分之一,右側珊瑚色字寫 9000 字元,再接灰字「JIT 對測試頁產出的 CSS」。圖片最下方一行灰字:全檔 grep fetch 與 XMLHttpRequest 都是 0 筆,執行期不對外連線

跟著做:把兩張表做出來並套上版面

起點是 day16 結束時的專案,四個檔案加兩個 vendor。分別是 index.htmlapp.jspatient.jsvitals.js,以及 vendor/ 底下的 fhir-client.pure.min.jschart.umd.js

第一步,把 scope 收成萬用字元

現在要讀四種資源了,逐一列出太長:

const SCOPE = 'launch/patient patient/*.rs openid fhirUser offline_access'

同意畫面會從 day16 的五行變回四行,Read * records 取代了逐項列舉。

這裡有個反直覺的地方。scope 從逐項改成萬用字元,你要的權限變多了,同意畫面上的資訊卻變少了。畫面上寫得越模糊,使用者能做的判斷就越少。

第二步,抓 Tailwind 進 vendor

curl -o vendor/tailwind-browser.js \
  https://cdn.jsdelivr.net/npm/@tailwindcss/[email protected]/dist/index.global.js

index.html 的 script 標籤加在最前面:

<script src="vendor/tailwind-browser.js"></script>
<script src="vendor/fhir-client.pure.min.js"></script>
<script src="vendor/chart.umd.js"></script>

第三步,新增 clinical.js

取值的部分:

export function displayOf(concept, fallback = '(未提供名稱)') {
  if (!concept) return fallback
  if (concept.text) return concept.text
  const coding = concept.coding?.[0]
  return coding?.display ?? coding?.code ?? fallback
}

export function codeOf(concept) {
  return concept?.coding?.[0]?.code ?? 'unknown'
}

export async function loadConditions(client) {
  const list = await client.request(
    `Condition?patient=${client.patient.id}&_count=100`,
    { pageLimit: 0, flat: true }
  )
  return list
    .map((one) => ({
      name: displayOf(one.code),
      status: codeOf(one.clinicalStatus),
      onset: one.onsetDateTime?.slice(0, 10) ?? '',
    }))
    .sort((a, b) => b.onset.localeCompare(a.onset))
}

export async function loadMedications(client) {
  const list = await client.request(
    `MedicationRequest?patient=${client.patient.id}&_count=100`,
    { pageLimit: 0, flat: true }
  )
  return list
    .map((one) => ({
      name: one.medicationCodeableConcept
        ? displayOf(one.medicationCodeableConcept)
        : `(需另外讀取 ${one.medicationReference?.reference})`,
      status: one.status ?? 'unknown',
      authoredOn: one.authoredOn?.slice(0, 10) ?? '',
      dosage: dosageText(one.dosageInstruction?.[0]),
    }))
    .sort((a, b) => b.authoredOn.localeCompare(a.authoredOn))
}

function dosageText(dosage) {
  if (!dosage) return ''
  if (dosage.text) return dosage.text
  return dosage.asNeededBoolean ? '需要時服用' : ''
}

第四步,表格與狀態 badge

const TABLE = 'w-full text-sm border-collapse'
const TH = 'px-3 py-2 text-left font-medium text-slate-600 border-b border-slate-300'
const TD = 'px-3 py-2 border-b border-slate-200'

function statusBadge(label, active) {
  const tone = active
    ? 'bg-emerald-100 text-emerald-800'
    : 'bg-slate-100 text-slate-600'
  return `<span class="inline-block rounded px-2 py-0.5 text-xs ${tone}">${label}</span>`
}

function table(headers, rows) {
  const head = headers.map((one) => `<th class="${TH}">${one}</th>`).join('')
  const body = rows
    .map((cells) => `<tr>${cells.map((one) => `<td class="${TD}">${one}</td>`).join('')}</tr>`)
    .join('')
  return `<table class="${TABLE}"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`
}

export function conditionsTable(rows) {
  if (!rows.length) return '<p class="text-slate-500">這位病人沒有病況記錄。</p>'
  return table(
    ['病況', '狀態', '起始'],
    rows.map((one) => [
      one.name,
      statusBadge(CONDITION_STATUS[one.status] ?? one.status, one.status === 'active'),
      one.onset,
    ])
  )
}

export function medicationsTable(rows) {
  if (!rows.length) return '<p class="text-slate-500">這位病人沒有用藥記錄。</p>'
  return table(
    ['藥品', '狀態', '用法', '開立日'],
    rows.map((one) => [
      one.name,
      statusBadge(MEDICATION_STATUS[one.status] ?? one.status, one.status === 'active'),
      one.dosage,
      one.authoredOn,
    ])
  )
}

CONDITION_STATUSMEDICATION_STATUS 就是前面那兩張中文對照表,一起放進 clinical.js

class 字串抽成常數,是為了兩張表共用同一套樣式。直接寫在字串模板裡也能動,但改一次要改兩個地方。

顏色只是幫你看快一點。狀態的文字照樣寫出來,不能只靠顏色傳達,那樣色覺不同的人讀不到這個資訊。

第五步,預留畫面的兩個區塊

<section class="rounded bg-white p-4 shadow-sm">
  <h2 class="mb-2 font-semibold">病況</h2>
  <div id="conditions" class="text-slate-500">尚未載入</div>
</section>

用藥那個區塊照抄改個 id。bodyclass="bg-slate-50 text-slate-900 p-6 md:p-10",外層包一個 mx-auto max-w-3xl space-y-8 把內容置中並拉開間距。

第六步,三塊資料一起載

const conditionsBox = document.querySelector('#conditions')
const medicationsBox = document.querySelector('#medications')

async function showConditions(client) {
  const rows = await loadConditions(client)
  conditionsBox.className = ''
  conditionsBox.innerHTML = conditionsTable(rows)
  console.log('病況:', rows.length, '')
}

async function showMedications(client) {
  const rows = await loadMedications(client)
  medicationsBox.className = ''
  medicationsBox.innerHTML = medicationsTable(rows)
  console.log('用藥:', rows.length, '')
}

await Promise.all([
  showVitals(client),
  showConditions(client),
  showMedications(client),
])

那兩行 className = '' 是把「尚未載入」那個灰字的 class 清掉,不然表格會整個變灰。

三塊資料互不相干,沒有理由一個等一個。

跑起來畫面上會有:四欄基本資料、三條線的趨勢圖、三列病況、兩列用藥。console 會多兩行。

打開 Network 面板確認一下,所有 script 都來自 localhost,沒有任何 CDN 請求。

執行結果圖,淺灰綠底色,標題「病況三列、用藥兩列跑出來的樣子」,副標「scope 收成 patient/*.rs,同一頁上兩張表都由 innerHTML 生成」。畫面上有三個綠色圓形編號,與圖片下方三條註記一一對應。中央一個白色圓角面板,最上一列是三個灰色小圓點加一條網址列,網址列顯示 http://localhost:5175/。面板內容區上半是「病況」表,欄標題依序為病況、狀態、起始,三列由上而下:Viral sinusitis (disorder) 配一個淺灰藍底徽章「已解決」與日期 2017-07-27;Injury of anterior cruciate ligament 配「已解決」與 2017-03-21;Body mass index 30+ - obesity (finding) 配一個淺綠底徽章「仍在追蹤」與 1991-06-07,這一列左緣掛著綠色編號 1。內容區下半是「用藥」表,欄標題依序為藥品、狀態、用法、開立日,兩列:Meperidine Hydrochloride 50 MG Oral Tablet 配「已停用」,用法那一格是一條珊瑚色短橫線代表空白,開立日 2017-03-28,這一列左緣掛著綠色編號 2;Ibuprofen 200 MG Oral Tablet 配「已停用」、用法「需要時服用」、開立日 2017-03-21。再往下是一條淺灰底的 Console 標籤,底下兩行等寬字輸出,依序是「病況: 3 筆」與「用藥: 2 筆」。面板最下一列左緣掛著綠色編號 3,寫著資源請求 localhost:5175 七筆、launch.smarthealthit.org 六筆,最右邊一句灰字「沒有第三個 host」。面板下方三條註記:編號 1 寫徽章底色是 Tailwind 產的 oklch(0.95 0.052 163.051),其餘四個是 oklch(0.968 0.007 247.896);編號 2 以珊瑚色標出「用法欄空白」,說明這筆的 dosageInstruction 只有 sequence,連 asNeededBoolean 都沒有;編號 3 以綠色標出「七筆加六筆」,說明裡沒有一筆指向 CDN,三個 vendor 檔都從 localhost 載

小結

CodeableConcept 取值三層優先序,textdisplaycode。寫成一條規則套用到所有欄位。狀態欄位要另外處理,因為程式要的是 code 不是給人看的字。

到今天為止這個 app 只會讀。明天讓這個 app 能寫。把病人在家量的血壓存回伺服器,那筆資料會出現在趨勢圖的最後一個點上。