fix: finish all patient related upgrades

This commit is contained in:
voltsrage
2026-06-23 18:17:13 +08:00
parent 7751d6df06
commit 5d46200941
20 changed files with 985 additions and 19 deletions
@@ -278,6 +278,74 @@ public class GapAnalysisFixTests : IAsyncLifetime
b.GetProperty("complianceStatus").GetString() == "IN_PROGRESS"); b.GetProperty("complianceStatus").GetString() == "IN_PROGRESS");
} }
// -------------------------------------------------------------------------
// P1 — encounter timeline endpoint
// -------------------------------------------------------------------------
[Fact]
public async Task EncounterTimeline_ReturnsMergedEventTypes()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var patient = new Patient
{
Id = Guid.NewGuid(), Mrn = "MRN-TL-001", FirstName = "TL", LastName = "Test",
DateOfBirth = new DateOnly(1980, 1, 1), Gender = "M",
CreatedAt = DateTimeOffset.UtcNow
};
var admittedAt = DateTimeOffset.UtcNow.AddHours(-6);
var encounter = new Encounter
{
Id = Guid.NewGuid(), PatientId = patient.Id, EncounterType = EncounterType.Inpatient,
Status = EncounterStatus.Active, Department = Department.Icu,
AttendingPhysician = "Dr. TL", AdmittedAt = admittedAt,
CreatedAt = DateTimeOffset.UtcNow
};
db.Patients.Add(patient);
db.Encounters.Add(encounter);
db.Observations.Add(new Observation
{
Id = Guid.NewGuid(), EncounterId = encounter.Id,
ObservationCode = "TEMP_C", Value = 38.8m, Unit = "C",
Source = ObservationSource.Manual, RecordedAt = admittedAt.AddHours(1),
CreatedAt = DateTimeOffset.UtcNow
});
db.ClinicalAlerts.Add(new ClinicalAlert
{
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning,
Details = "qSOFA screen", Status = AlertStatus.Open,
TriggeredAt = admittedAt.AddHours(2)
});
db.Orders.Add(new Order
{
Id = Guid.NewGuid(), EncounterId = encounter.Id,
OrderType = OrderType.Lab, Description = "Blood cultures",
OrderedBy = "Dr. TL", Status = OrderStatus.Pending,
OrderedAt = admittedAt.AddHours(3)
});
db.MedicationAdministrations.Add(new MedicationAdministration
{
Id = Guid.NewGuid(), EncounterId = encounter.Id,
DrugName = "Ceftriaxone", Dose = 1m, DoseUnit = "g", Route = "IV",
AdministeredAt = admittedAt.AddHours(4), AdministeredBy = "RN TL"
});
await db.SaveChangesAsync();
var resp = await _client.GetFromJsonAsync<JsonElement>(
$"/api/v1/encounters/{encounter.Id}/timeline");
var items = resp.GetProperty("data").GetProperty("events");
var types = items.EnumerateArray().Select(e => e.GetProperty("type").GetString()).ToList();
types.Should().Contain("status");
types.Should().Contain("observation");
types.Should().Contain("alert");
types.Should().Contain("order");
types.Should().Contain("medication");
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// P4 — qSOFA history endpoint // P4 — qSOFA history endpoint
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -191,21 +191,82 @@ public class EncounterService : IEncounterService
if (encounter is null) if (encounter is null)
throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND");
var events = new List<object>();
events.Add(new
{
type = "status",
timestamp = encounter.AdmittedAt,
status = EncounterStatus.Active.ToDbString(),
label = "Patient admitted"
});
if (encounter.DischargedAt is not null)
{
events.Add(new
{
type = "status",
timestamp = encounter.DischargedAt.Value,
status = EncounterStatus.Discharged.ToDbString(),
label = "Patient discharged"
});
}
var observations = await _db.Observations var observations = await _db.Observations
.Where(o => o.EncounterId == encounterId) .Where(o => o.EncounterId == encounterId)
.OrderByDescending(o => o.RecordedAt) .Select(o => new
.Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit }) {
type = "observation",
timestamp = o.RecordedAt,
o.ObservationCode,
o.Value,
o.Unit
})
.ToListAsync(); .ToListAsync();
events.AddRange(observations);
var alerts = await _db.ClinicalAlerts var alerts = await _db.ClinicalAlerts
.Where(a => a.EncounterId == encounterId) .Where(a => a.EncounterId == encounterId)
.OrderByDescending(a => a.TriggeredAt) .Select(a => new
.Select(a => new { type = "alert", timestamp = a.TriggeredAt, a.AlertType, a.Severity, a.Status }) {
type = "alert",
timestamp = a.TriggeredAt,
alertType = a.AlertType.ToString(),
severity = a.Severity.ToString(),
status = a.Status.ToString()
})
.ToListAsync(); .ToListAsync();
events.AddRange(alerts);
var timeline = observations.Cast<object>() var orders = await _db.Orders
.Concat(alerts.Cast<object>()) .Where(o => o.EncounterId == encounterId)
.OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp) .Select(o => new
{
type = "order",
timestamp = o.OrderedAt,
o.Description,
orderType = o.OrderType.ToString(),
status = o.Status.ToString()
})
.ToListAsync();
events.AddRange(orders);
var medications = await _db.MedicationAdministrations
.Where(m => m.EncounterId == encounterId)
.Select(m => new
{
type = "medication",
timestamp = m.AdministeredAt,
m.DrugName,
m.Dose,
m.DoseUnit,
m.Route
})
.ToListAsync();
events.AddRange(medications);
var timeline = events
.OrderByDescending(e => (DateTimeOffset)((dynamic)e).timestamp)
.ToList(); .ToList();
return new { encounterId, events = timeline }; return new { encounterId, events = timeline };
@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
const events = [
{
type: 'observation',
timestamp: '2026-06-23T10:00:00Z',
observationCode: 'TEMP_C',
value: 38.8,
unit: 'C',
},
{
type: 'alert',
timestamp: '2026-06-23T11:00:00Z',
alertType: 'QsofaScreen',
severity: 'Warning',
status: 'Open',
},
{
type: 'medication',
timestamp: '2026-06-23T12:00:00Z',
drugName: 'Ceftriaxone',
dose: 1,
doseUnit: 'g',
route: 'IV',
},
]
describe('EncounterTimeline', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-23T14:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('rendersEvents', () => {
const wrapper = mount(EncounterTimeline, { props: { events } })
expect(wrapper.text()).toContain('Encounter Timeline')
expect(wrapper.text()).toContain('Ceftriaxone')
expect(wrapper.text()).toContain('qSOFA Screen')
})
it('filtersByEventType', async () => {
const wrapper = mount(EncounterTimeline, { props: { events } })
const medToggle = wrapper.findAll('button').find(b => b.text() === 'Medications')
await medToggle.trigger('click')
expect(wrapper.text()).not.toContain('Ceftriaxone')
expect(wrapper.text()).toContain('Temperature')
})
it('collapsesWhenHeaderClicked', async () => {
const wrapper = mount(EncounterTimeline, { props: { events } })
await wrapper.find('button').trigger('click')
expect(wrapper.text()).not.toContain('Ceftriaxone')
})
})
@@ -0,0 +1,56 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import PatientBanner from '@/components/patient/PatientBanner.vue'
const encounter = {
roomBed: 'ICU-3C',
department: 'Icu',
attendingPhysician: 'Dr. Smith',
admissionReason: 'Sepsis workup',
patient: {
mrn: 'MRN-000123',
firstName: 'Jane',
lastName: 'Doe',
dateOfBirth: '1985-03-20',
gender: 'F',
bloodType: 'AbNegative',
allergies: 'Penicillin, Latex',
emergencyContactName: 'John Doe',
emergencyContactPhone: '555-0100',
},
}
describe('PatientBanner', () => {
it('showsDemographicsAndEncounterFields', () => {
const wrapper = mount(PatientBanner, { props: { encounter } })
expect(wrapper.text()).toContain('Jane Doe')
expect(wrapper.text()).toContain('MRN-000123')
expect(wrapper.text()).toContain('AB-')
expect(wrapper.text()).toContain('ICU')
expect(wrapper.text()).toContain('ICU-3C')
expect(wrapper.text()).toContain('Dr. Smith')
expect(wrapper.text()).toContain('Sepsis workup')
expect(wrapper.text()).toContain('John Doe')
})
it('highlightsKnownAllergies', () => {
const wrapper = mount(PatientBanner, { props: { encounter } })
expect(wrapper.text()).toContain('Penicillin, Latex')
const strip = wrapper.find('[role="status"]')
expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
})
it('showsNkdaWhenNoAllergies', () => {
const wrapper = mount(PatientBanner, {
props: {
encounter: {
...encounter,
patient: { ...encounter.patient, allergies: null },
},
},
})
expect(wrapper.text()).toContain('NKDA')
const strip = wrapper.find('[role="status"]')
expect(strip.classes().join(' ')).not.toMatch(/bg-red-600/)
})
})
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest'
import {
filterMedicationsForWindow,
formatMedicationTooltip,
getMedicationsNearTimestamp,
xPixelForMedicationTime,
} from '@/composables/chartMedications'
const observations = [
{ observationCode: 'HEART_RATE', value: 72, recordedAt: '2026-06-19T11:00:00Z' },
{ observationCode: 'HEART_RATE', value: 80, recordedAt: '2026-06-19T13:00:00Z' },
{ observationCode: 'RESP_RATE', value: 18, recordedAt: '2026-06-19T12:00:00Z' },
]
const medications = [
{
id: '1',
drugName: 'Metoprolol',
dose: 5,
doseUnit: 'mg',
route: 'IV',
administeredAt: '2026-06-19T12:30:00Z',
},
{
id: '2',
drugName: 'Saline',
dose: 500,
doseUnit: 'mL',
route: 'IV',
administeredAt: '2026-06-19T10:00:00Z',
},
]
describe('chartMedications', () => {
it('filtersMedicationsToObservationWindow', () => {
const result = filterMedicationsForWindow(observations, 'HEART_RATE', medications)
expect(result).toHaveLength(1)
expect(result[0].drugName).toBe('Metoprolol')
})
it('formatsMedicationTooltip', () => {
expect(formatMedicationTooltip(medications[0])).toContain('Metoprolol 5mg (IV)')
})
it('findsMedicationsNearTimestamp', () => {
const target = new Date('2026-06-19T12:35:00Z').getTime()
const near = getMedicationsNearTimestamp(medications, target, 10 * 60 * 1000)
expect(near).toHaveLength(1)
})
it('mapsMedicationTimeToChartPixel', () => {
const chartArea = { left: 10, right: 110, top: 0, bottom: 100, width: 100, height: 100 }
const timestamps = ['2026-06-19T11:00:00Z', '2026-06-19T13:00:00Z']
const x = xPixelForMedicationTime('2026-06-19T12:00:00Z', timestamps, chartArea)
expect(x).toBe(60)
})
})
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import {
computeAge,
formatAllergiesDisplay,
formatBloodType,
formatDepartment,
formatDobWithAge,
hasKnownAllergies,
} from '@/composables/patientFormat'
describe('patientFormat', () => {
it('computesAgeFromDob', () => {
expect(computeAge('1990-06-15', new Date('2026-06-23'))).toBe(36)
})
it('formatsBloodType', () => {
expect(formatBloodType('AbNegative')).toBe('AB-')
expect(formatBloodType('AB-')).toBe('AB-')
})
it('formatsDepartment', () => {
expect(formatDepartment('Icu')).toBe('ICU')
expect(formatDepartment('GENERAL_MEDICINE')).toBe('General Medicine')
})
it('showsNkdaWhenNoAllergies', () => {
expect(hasKnownAllergies('')).toBe(false)
expect(hasKnownAllergies(null)).toBe(false)
expect(formatAllergiesDisplay('')).toBe('NKDA')
expect(formatAllergiesDisplay(' Penicillin ')).toBe('Penicillin')
})
it('formatsDobWithAge', () => {
const text = formatDobWithAge('1990-06-15')
expect(text).toContain('1990')
expect(text).toMatch(/\(\d+y\)/)
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest'
import {
formatTimelineEvent,
timelineTypeLabel,
TIMELINE_EVENT_TYPES,
} from '@/composables/timelineFormat'
describe('timelineFormat', () => {
it('formatsObservationEvent', () => {
const text = formatTimelineEvent({
type: 'observation',
observationCode: 'HEART_RATE',
value: 110,
unit: 'bpm',
})
expect(text).toContain('Heart Rate')
expect(text).toContain('110')
})
it('formatsMedicationEvent', () => {
const text = formatTimelineEvent({
type: 'medication',
drugName: 'Metoprolol',
dose: 5,
doseUnit: 'mg',
route: 'IV',
})
expect(text).toBe('Metoprolol 5mg (IV)')
})
it('listsAllEventTypes', () => {
expect(TIMELINE_EVENT_TYPES.map(t => t.id)).toEqual([
'observation',
'alert',
'order',
'medication',
'status',
])
expect(timelineTypeLabel('order')).toBe('Orders')
})
})
@@ -26,6 +26,10 @@ describe('useChartData', () => {
const { chartData } = mountChartData(observations, 'HEART_RATE') const { chartData } = mountChartData(observations, 'HEART_RATE')
expect(chartData.value.datasets[0].data).toEqual([72, 80]) expect(chartData.value.datasets[0].data).toEqual([72, 80])
expect(chartData.value.labels).toHaveLength(2) expect(chartData.value.labels).toHaveLength(2)
expect(chartData.value.timestamps).toEqual([
'2026-06-19T11:00:00Z',
'2026-06-19T12:00:00Z',
])
}) })
it('sortsChronologically', () => { it('sortsChronologically', () => {
@@ -22,3 +22,7 @@ export async function fetchObservations(encounterId, { limit = 50 } = {}) {
} while (cursor) } while (cursor)
return all return all
} }
export function fetchTimeline(encounterId) {
return api.get(`/api/v1/encounters/${encounterId}/timeline`)
}
@@ -1,7 +1,10 @@
<script setup> <script setup>
import VitalTrendChart from './VitalTrendChart.vue' import VitalTrendChart from './VitalTrendChart.vue'
defineProps({ observations: { type: Array, required: true } }) defineProps({
observations: { type: Array, required: true },
medications: { type: Array, default: () => [] },
})
const charts = [ const charts = [
{ code: 'HEART_RATE', title: 'Heart Rate (bpm)', yMin: 30, yMax: 180 }, { code: 'HEART_RATE', title: 'Heart Rate (bpm)', yMin: 30, yMax: 180 },
@@ -18,6 +21,7 @@ const charts = [
v-for="chart in charts" v-for="chart in charts"
:key="chart.code" :key="chart.code"
:observations="observations" :observations="observations"
:medications="medications"
:code="chart.code" :code="chart.code"
:title="chart.title" :title="chart.title"
:y-min="chart.yMin" :y-min="chart.yMin"
@@ -1,23 +1,36 @@
<script setup> <script setup>
import { computed, toValue, shallowRef, markRaw } from 'vue' import { computed, toValue } from 'vue'
import { Line } from 'vue-chartjs' import { Line } from 'vue-chartjs'
import { Chart, registerables } from 'chart.js' import { Chart, registerables } from 'chart.js'
import {
filterMedicationsForWindow,
formatMedicationTooltip,
getMedicationsNearTimestamp,
} from '@/composables/chartMedications'
import { medicationMarkerPlugin } from '@/plugins/medicationMarkerPlugin'
Chart.register(...registerables) Chart.register(...registerables, medicationMarkerPlugin)
const props = defineProps({ const props = defineProps({
chartData: { type: Object, required: true }, chartData: { type: Object, required: true },
title: { type: String, required: true }, title: { type: String, required: true },
yMin: { type: Number, default: undefined }, yMin: { type: Number, default: undefined },
yMax: { type: Number, default: undefined }, yMax: { type: Number, default: undefined },
observations: { type: Array, default: () => [] },
observationCode: { type: String, default: '' },
medications: { type: Array, default: () => [] },
}) })
const lineData = computed(() => { const lineData = computed(() => {
const data = toValue(props.chartData) const data = toValue(props.chartData)
return data?.datasets ? data : { labels: [], datasets: [] } return data?.datasets ? data : { labels: [], timestamps: [], datasets: [] }
}) })
const chartOptions = shallowRef(markRaw({ const chartMedications = computed(() =>
filterMedicationsForWindow(props.observations, props.observationCode, props.medications),
)
const chartOptions = computed(() => ({
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
animation: { animation: {
@@ -29,6 +42,23 @@ const chartOptions = shallowRef(markRaw({
}, },
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
medicationMarkers: {
medications: chartMedications.value,
timestamps: lineData.value.timestamps ?? [],
},
tooltip: {
callbacks: {
afterBody(items) {
if (!items.length) return []
const timestamps = lineData.value.timestamps ?? []
const idx = items[0].dataIndex
const targetMs = timestamps[idx] ? new Date(timestamps[idx]).getTime() : null
if (targetMs == null) return []
return getMedicationsNearTimestamp(chartMedications.value, targetMs)
.map(formatMedicationTooltip)
},
},
},
}, },
})) }))
</script> </script>
@@ -4,6 +4,7 @@ import VitalChart from './VitalChart.vue'
const props = defineProps({ const props = defineProps({
observations: { type: Array, required: true }, observations: { type: Array, required: true },
medications: { type: Array, default: () => [] },
code: { type: String, required: true }, code: { type: String, required: true },
title: { type: String, required: true }, title: { type: String, required: true },
yMin: { type: Number, default: undefined }, yMin: { type: Number, default: undefined },
@@ -19,5 +20,8 @@ const { chartData } = useChartData(() => props.observations, props.code)
:title="title" :title="title"
:y-min="yMin" :y-min="yMin"
:y-max="yMax" :y-max="yMax"
:observations="observations"
:observation-code="code"
:medications="medications"
/> />
</template> </template>
@@ -0,0 +1,142 @@
<script setup>
import { ref, computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import {
TIMELINE_EVENT_TYPES,
formatTimelineEvent,
timelineEventStyles,
timelineTypeLabel,
} from '@/composables/timelineFormat'
const props = defineProps({
events: { type: Array, default: () => [] },
})
const expanded = ref(true)
const sinceHours = ref('')
const enabledTypes = ref(new Set(TIMELINE_EVENT_TYPES.map(t => t.id)))
const hourOptions = [
{ value: '', label: 'All time' },
{ value: '4', label: 'Last 4 hours' },
{ value: '8', label: 'Last 8 hours' },
{ value: '24', label: 'Last 24 hours' },
]
const filteredEvents = computed(() => {
let list = props.events.filter(e => enabledTypes.value.has(e.type))
const hours = Number(sinceHours.value)
if (hours > 0) {
const cutoff = Date.now() - hours * 60 * 60 * 1000
list = list.filter(e => new Date(e.timestamp).getTime() >= cutoff)
}
return [...list].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))
})
function toggleType(typeId) {
const next = new Set(enabledTypes.value)
if (next.has(typeId)) next.delete(typeId)
else next.add(typeId)
enabledTypes.value = next
}
function formatTime(iso) {
return new Date(iso).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function alertBadgeVariant(event) {
if (event.type !== 'alert') return timelineEventStyles(event.type).badge
return event.severity === 'Critical' ? 'critical' : 'warning'
}
</script>
<template>
<Card>
<template #header>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<button
type="button"
class="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
@click="expanded = !expanded"
>
<span class="text-xs" aria-hidden="true">{{ expanded ? '▼' : '▶' }}</span>
Encounter Timeline
</button>
<span v-if="events.length" class="text-xs text-gray-500 dark:text-gray-400">
{{ filteredEvents.length }} of {{ events.length }} events
</span>
</div>
</template>
<div v-if="expanded" class="space-y-4">
<div class="flex flex-wrap items-center gap-3">
<label class="text-xs text-gray-500 dark:text-gray-400">
Range
<select
v-model="sinceHours"
class="ml-2 rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-800 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
>
<option v-for="opt in hourOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</label>
<div class="flex flex-wrap gap-2">
<button
v-for="t in TIMELINE_EVENT_TYPES"
:key="t.id"
type="button"
class="rounded-full border px-2 py-0.5 text-xs transition-colors"
:class="enabledTypes.has(t.id)
? 'border-gray-800 bg-gray-800 text-white dark:border-gray-200 dark:bg-gray-200 dark:text-gray-900'
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'"
@click="toggleType(t.id)"
>
{{ t.label }}
</button>
</div>
</div>
<EmptyState v-if="!filteredEvents.length" message="No timeline events match the current filters" />
<ol v-else class="relative space-y-0 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
<li
v-for="(event, index) in filteredEvents"
:key="`${event.type}-${event.timestamp}-${index}`"
class="relative pb-6 last:pb-0"
>
<span
class="absolute -left-[1.3rem] top-1 h-3 w-3 rounded-full ring-2 ring-white dark:ring-gray-900"
:class="timelineEventStyles(event.type).dot"
/>
<div
class="rounded-lg border-l-4 bg-gray-50 p-3 dark:bg-gray-800/50"
:class="timelineEventStyles(event.type).border"
>
<div class="mb-1 flex flex-wrap items-center gap-2">
<Badge :variant="alertBadgeVariant(event)" size="xs">
{{ timelineTypeLabel(event.type) }}
</Badge>
<time class="text-xs text-gray-500 dark:text-gray-400">
{{ formatTime(event.timestamp) }}
</time>
</div>
<p class="text-sm text-gray-900 dark:text-gray-100">
{{ formatTimelineEvent(event) }}
</p>
</div>
</li>
</ol>
</div>
</Card>
</template>
@@ -0,0 +1,109 @@
<script setup>
import { computed } from 'vue'
import {
formatAllergiesDisplay,
formatBloodType,
formatDepartment,
formatDobWithAge,
formatGender,
hasKnownAllergies,
} from '@/composables/patientFormat'
const props = defineProps({
encounter: { type: Object, required: true },
})
const patient = computed(() => props.encounter.patient ?? {})
const fullName = computed(() =>
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
)
const allergyText = computed(() => formatAllergiesDisplay(patient.value.allergies))
const allergiesKnown = computed(() => hasKnownAllergies(patient.value.allergies))
const emergencyContact = computed(() => {
const name = patient.value.emergencyContactName
const phone = patient.value.emergencyContactPhone
if (name && phone) return `${name} · ${phone}`
return name || phone || null
})
</script>
<template>
<header class="w-full min-w-0 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div
class="px-4 py-2 text-sm font-medium"
:class="allergiesKnown
? 'bg-red-600 text-white'
: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'"
role="status"
:aria-label="allergiesKnown ? `Known allergies: ${allergyText}` : 'No known drug allergies'"
>
<span class="font-semibold">Allergies:</span>
{{ allergyText }}
</div>
<div class="space-y-3 bg-white p-4 dark:bg-gray-900">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
{{ fullName }}
</h1>
<span class="text-sm text-gray-600 dark:text-gray-400">
MRN {{ patient.mrn ?? '—' }}
</span>
</div>
<dl class="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">DOB / Age</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatDobWithAge(patient.dateOfBirth) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Gender</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatGender(patient.gender) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Blood type</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatBloodType(patient.bloodType) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Room / Bed</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.roomBed ?? '—' }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Department</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ formatDepartment(encounter.department) }}
</dd>
</div>
<div>
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Attending</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.attendingPhysician ?? '—' }}
</dd>
</div>
<div class="sm:col-span-2 lg:col-span-3">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Admission reason</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ encounter.admissionReason ?? '—' }}
</dd>
</div>
<div v-if="emergencyContact" class="sm:col-span-2 lg:col-span-3">
<dt class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Emergency contact</dt>
<dd class="mt-0.5 text-gray-900 dark:text-gray-100">
{{ emergencyContact }}
</dd>
</div>
</dl>
</div>
</header>
</template>
@@ -0,0 +1,53 @@
export function formatMedicationLabel(med) {
const name = med.drugName?.length > 12
? `${med.drugName.slice(0, 11)}`
: med.drugName
return name ?? 'Med'
}
export function formatMedicationTooltip(med) {
return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route}) — ${new Date(med.administeredAt).toLocaleString()}`
}
export function filterMedicationsForWindow(observations, code, medications) {
const series = (observations ?? [])
.filter(o => o.observationCode === code)
.sort((a, b) => new Date(a.recordedAt) - new Date(b.recordedAt))
if (!series.length) return []
const min = new Date(series[0].recordedAt).getTime()
const max = new Date(series[series.length - 1].recordedAt).getTime()
return (medications ?? []).filter(m => {
const t = new Date(m.administeredAt).getTime()
if (min === max) return Math.abs(t - min) <= 60_000
return t >= min && t <= max
})
}
export function getMedicationsNearTimestamp(medications, targetMs, windowMs = 15 * 60 * 1000) {
return (medications ?? []).filter(m => {
const t = new Date(m.administeredAt).getTime()
return Math.abs(t - targetMs) <= windowMs
})
}
export function xPixelForMedicationTime(administeredAt, timestamps, chartArea) {
if (!timestamps?.length || !chartArea) return null
const medTime = new Date(administeredAt).getTime()
const times = timestamps.map(t => new Date(t).getTime())
if (times.length === 1) {
if (Math.abs(medTime - times[0]) > 60_000) return null
return (chartArea.left + chartArea.right) / 2
}
const min = times[0]
const max = times[times.length - 1]
if (medTime < min || medTime > max) return null
const ratio = (medTime - min) / (max - min)
return chartArea.left + ratio * chartArea.width
}
@@ -0,0 +1,90 @@
const BLOOD_TYPE_LABELS = {
APositive: 'A+',
ANegative: 'A-',
BPositive: 'B+',
BNegative: 'B-',
AbPositive: 'AB+',
AbNegative: 'AB-',
OPositive: 'O+',
ONegative: 'O-',
'A+': 'A+',
'A-': 'A-',
'B+': 'B+',
'B-': 'B-',
'AB+': 'AB+',
'AB-': 'AB-',
'O+': 'O+',
'O-': 'O-',
}
const DEPARTMENT_LABELS = {
Icu: 'ICU',
GeneralMedicine: 'General Medicine',
Emergency: 'Emergency',
Cardiology: 'Cardiology',
Surgery: 'Surgery',
Pediatrics: 'Pediatrics',
ICU: 'ICU',
GENERAL_MEDICINE: 'General Medicine',
EMERGENCY: 'Emergency',
CARDIOLOGY: 'Cardiology',
SURGERY: 'Surgery',
PEDIATRICS: 'Pediatrics',
}
const GENDER_LABELS = {
M: 'Male',
F: 'Female',
O: 'Other',
U: 'Unknown',
}
export function formatBloodType(value) {
if (value == null || value === '') return '—'
return BLOOD_TYPE_LABELS[value] ?? String(value)
}
export function formatDepartment(value) {
if (value == null || value === '') return '—'
return DEPARTMENT_LABELS[value] ?? String(value).replace(/_/g, ' ')
}
export function formatGender(value) {
if (value == null || value === '') return '—'
return GENDER_LABELS[value] ?? value
}
export function computeAge(dateOfBirth, asOf = new Date()) {
if (!dateOfBirth) return null
const dob = new Date(dateOfBirth)
if (Number.isNaN(dob.getTime())) return null
let age = asOf.getFullYear() - dob.getFullYear()
const monthDelta = asOf.getMonth() - dob.getMonth()
if (monthDelta < 0 || (monthDelta === 0 && asOf.getDate() < dob.getDate())) {
age -= 1
}
return age
}
export function formatDateOfBirth(dateOfBirth) {
if (!dateOfBirth) return '—'
const d = new Date(dateOfBirth)
if (Number.isNaN(d.getTime())) return dateOfBirth
return d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' })
}
export function formatDobWithAge(dateOfBirth) {
const formatted = formatDateOfBirth(dateOfBirth)
const age = computeAge(dateOfBirth)
if (age == null) return formatted
return `${formatted} (${age}y)`
}
export function hasKnownAllergies(allergies) {
return Boolean(allergies?.trim())
}
export function formatAllergiesDisplay(allergies) {
return hasKnownAllergies(allergies) ? allergies.trim() : 'NKDA'
}
@@ -0,0 +1,61 @@
import { alertTypeLabel, observationCodeLabel } from '@/api/normalize'
export const TIMELINE_EVENT_TYPES = [
{ id: 'observation', label: 'Observations' },
{ id: 'alert', label: 'Alerts' },
{ id: 'order', label: 'Orders' },
{ id: 'medication', label: 'Medications' },
{ id: 'status', label: 'Status' },
]
export function timelineEventStyles(type) {
const styles = {
observation: {
dot: 'bg-blue-500',
border: 'border-blue-500',
badge: 'info',
},
alert: {
dot: 'bg-red-500',
border: 'border-red-500',
badge: 'critical',
},
order: {
dot: 'bg-purple-500',
border: 'border-purple-500',
badge: 'info',
},
medication: {
dot: 'bg-green-500',
border: 'border-green-500',
badge: 'success',
},
status: {
dot: 'bg-gray-500',
border: 'border-gray-400',
badge: 'info',
},
}
return styles[type] ?? styles.status
}
export function formatTimelineEvent(event) {
switch (event.type) {
case 'observation':
return `${observationCodeLabel(event.observationCode)} ${event.value} ${event.unit ?? ''}`.trim()
case 'alert':
return `${alertTypeLabel(event.alertType)}${event.severity} (${event.status})`
case 'order':
return `${event.description}${event.status}`
case 'medication':
return `${event.drugName} ${event.dose}${event.doseUnit} (${event.route})`
case 'status':
return event.label ?? `Status: ${event.status}`
default:
return event.type
}
}
export function timelineTypeLabel(type) {
return TIMELINE_EVENT_TYPES.find(t => t.id === type)?.label ?? type
}
@@ -12,6 +12,7 @@ export function useChartData(observations, code) {
return { return {
labels: filtered.map(o => formatTime(o.recordedAt)), labels: filtered.map(o => formatTime(o.recordedAt)),
timestamps: filtered.map(o => o.recordedAt),
datasets: [{ datasets: [{
label: code, label: code,
data: filtered.map(o => o.value), data: filtered.map(o => o.value),
@@ -0,0 +1,68 @@
import {
formatMedicationLabel,
formatMedicationTooltip,
xPixelForMedicationTime,
} from '@/composables/chartMedications'
const HIT_TOLERANCE_PX = 8
const MARKER_COLOR = '#7c3aed'
export const medicationMarkerPlugin = {
id: 'medicationMarkers',
afterDraw(chart, _args, options) {
const medications = options?.medications ?? []
const timestamps = options?.timestamps ?? []
if (!medications.length || !timestamps.length) return
const { ctx, chartArea } = chart
if (!chartArea) return
const hits = []
ctx.save()
ctx.strokeStyle = MARKER_COLOR
ctx.fillStyle = MARKER_COLOR
ctx.lineWidth = 1.5
ctx.setLineDash([5, 4])
ctx.font = '10px system-ui, sans-serif'
for (const med of medications) {
const x = xPixelForMedicationTime(med.administeredAt, timestamps, chartArea)
if (x == null) continue
ctx.beginPath()
ctx.moveTo(x, chartArea.top)
ctx.lineTo(x, chartArea.bottom)
ctx.stroke()
const label = formatMedicationLabel(med)
ctx.setLineDash([])
ctx.fillText(label, Math.min(x + 3, chartArea.right - 40), chartArea.top + 11)
hits.push({ med, x })
}
ctx.restore()
chart.$medicationHits = hits
},
afterEvent(chart, args) {
const event = args.event
if (!event || (event.type !== 'mousemove' && event.type !== 'mouseout')) return
if (event.type === 'mouseout') {
chart.canvas.title = ''
chart.canvas.style.cursor = 'default'
return
}
const hits = chart.$medicationHits ?? []
const near = hits.find(h => Math.abs(h.x - event.x) <= HIT_TOLERANCE_PX)
if (near) {
chart.canvas.title = formatMedicationTooltip(near.med)
chart.canvas.style.cursor = 'help'
} else {
chart.canvas.title = ''
chart.canvas.style.cursor = 'default'
}
},
}
@@ -8,6 +8,7 @@ import { useAlertStore } from '@/stores/alerts'
import { useScoringStore } from '@/stores/scoring' import { useScoringStore } from '@/stores/scoring'
import * as encountersApi from '@/api/encounters' import * as encountersApi from '@/api/encounters'
import * as clinicalApi from '@/api/clinical' import * as clinicalApi from '@/api/clinical'
import PatientBanner from '@/components/patient/PatientBanner.vue'
import VitalsPanel from '@/components/patient/VitalsPanel.vue' import VitalsPanel from '@/components/patient/VitalsPanel.vue'
import ScoresPanel from '@/components/patient/ScoresPanel.vue' import ScoresPanel from '@/components/patient/ScoresPanel.vue'
import SofaScorePanel from '@/components/patient/SofaScorePanel.vue' import SofaScorePanel from '@/components/patient/SofaScorePanel.vue'
@@ -19,6 +20,7 @@ import News2History from '@/components/charts/News2History.vue'
import GcsHistory from '@/components/charts/GcsHistory.vue' import GcsHistory from '@/components/charts/GcsHistory.vue'
import QsofaHistory from '@/components/charts/QsofaHistory.vue' import QsofaHistory from '@/components/charts/QsofaHistory.vue'
import SofaHistory from '@/components/charts/SofaHistory.vue' import SofaHistory from '@/components/charts/SofaHistory.vue'
import EncounterTimeline from '@/components/patient/EncounterTimeline.vue'
import ReplayControls from '@/components/replay/ReplayControls.vue' import ReplayControls from '@/components/replay/ReplayControls.vue'
import AlertReasoning from '@/components/alerts/AlertReasoning.vue' import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
import Skeleton from '@/components/ui/Skeleton.vue' import Skeleton from '@/components/ui/Skeleton.vue'
@@ -56,6 +58,7 @@ const sofaHistory = ref([])
const medications = ref([]) const medications = ref([])
const sepsisBundle = ref(null) const sepsisBundle = ref(null)
const orders = ref([]) const orders = ref([])
const timelineEvents = ref([])
const selectedAlert = ref(null) const selectedAlert = ref(null)
let nextAlertIndex = 0 let nextAlertIndex = 0
@@ -67,6 +70,10 @@ const replayObservations = computed(() =>
observations.value.filter(o => isAtOrBefore(o.recordedAt)), observations.value.filter(o => isAtOrBefore(o.recordedAt)),
) )
const replayMedications = computed(() =>
medications.value.filter(m => isAtOrBefore(m.administeredAt)),
)
const replayNews2History = computed(() => const replayNews2History = computed(() =>
news2History.value.filter(h => isAtOrBefore(h.calculatedAt)), news2History.value.filter(h => isAtOrBefore(h.calculatedAt)),
) )
@@ -83,6 +90,10 @@ const replaySofaHistory = computed(() =>
sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)), sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
) )
const replayTimelineEvents = computed(() =>
timelineEvents.value.filter(e => isAtOrBefore(e.timestamp)),
)
function collectScenarioTimes() { function collectScenarioTimes() {
return [ return [
...observations.value.map(o => new Date(o.recordedAt).getTime()), ...observations.value.map(o => new Date(o.recordedAt).getTime()),
@@ -90,6 +101,7 @@ function collectScenarioTimes() {
...gcsHistory.value.map(h => new Date(h.calculatedAt).getTime()), ...gcsHistory.value.map(h => new Date(h.calculatedAt).getTime()),
...qsofaHistory.value.map(h => new Date(h.evaluatedAt).getTime()), ...qsofaHistory.value.map(h => new Date(h.evaluatedAt).getTime()),
...sofaHistory.value.map(h => new Date(h.calculatedAt).getTime()), ...sofaHistory.value.map(h => new Date(h.calculatedAt).getTime()),
...timelineEvents.value.map(e => new Date(e.timestamp).getTime()),
...alerts.value.map(a => new Date(a.triggeredAt).getTime()), ...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
].filter(Number.isFinite) ].filter(Number.isFinite)
} }
@@ -109,7 +121,7 @@ async function loadAll() {
const id = route.params.encounterId const id = route.params.encounterId
loading.value = true loading.value = true
try { try {
const [enc, obs, history, gcsHist, qsofaHist, sofaHist, meds, bundle, ord] = await Promise.all([ const [enc, obs, history, gcsHist, qsofaHist, sofaHist, meds, bundle, ord, timeline] = await Promise.all([
encountersApi.fetchEncounter(id), encountersApi.fetchEncounter(id),
encountersApi.fetchObservations(id), encountersApi.fetchObservations(id),
clinicalApi.fetchNews2History(id).catch(() => []), clinicalApi.fetchNews2History(id).catch(() => []),
@@ -119,6 +131,7 @@ async function loadAll() {
clinicalApi.fetchMedications(id).catch(() => []), clinicalApi.fetchMedications(id).catch(() => []),
clinicalApi.fetchSepsisBundle(id), clinicalApi.fetchSepsisBundle(id),
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })), clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
encountersApi.fetchTimeline(id).catch(() => ({ events: [] })),
]) ])
await alertStore.loadAlerts(id) await alertStore.loadAlerts(id)
encounter.value = enc encounter.value = enc
@@ -130,6 +143,7 @@ async function loadAll() {
medications.value = meds medications.value = meds
sepsisBundle.value = bundle sepsisBundle.value = bundle
orders.value = ord.items ?? ord orders.value = ord.items ?? ord
timelineEvents.value = timeline.events ?? []
scoringStore.startPolling(id) scoringStore.startPolling(id)
syncReplayBounds() syncReplayBounds()
} finally { } finally {
@@ -174,7 +188,7 @@ watch(openAlerts, (list) => {
} }
}) })
watch([observations, news2History, gcsHistory, qsofaHistory, sofaHistory, alerts], syncReplayBounds, { deep: true }) watch([observations, news2History, gcsHistory, qsofaHistory, sofaHistory, timelineEvents, alerts], syncReplayBounds, { deep: true })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopPlayback() stopPlayback()
@@ -189,11 +203,10 @@ onBeforeUnmount(() => {
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
&larr; Ward &larr; Ward
</RouterLink> </RouterLink>
<h1 class="text-xl font-bold dark:text-white">
{{ encounter.patient?.firstName }} {{ encounter.patient?.lastName }}
</h1>
</div> </div>
<PatientBanner :encounter="encounter" />
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-4"> <div class="space-y-4">
<ScoresPanel /> <ScoresPanel />
@@ -227,8 +240,10 @@ onBeforeUnmount(() => {
:sofa="sofa" :sofa="sofa"
/> />
<EncounterTimeline :events="replayTimelineEvents" />
<div id="clinical-review" class="w-full min-w-0 space-y-8"> <div id="clinical-review" class="w-full min-w-0 space-y-8">
<TrendsGrid :observations="replayObservations" /> <TrendsGrid :observations="replayObservations" :medications="replayMedications" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" /> <News2History v-if="replayNews2History.length" :history="replayNews2History" />
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" /> <QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
<ReplayControls <ReplayControls