fix: finish all patient related upgrades
This commit is contained in:
@@ -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">
|
||||||
← Ward
|
← 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
|
||||||
|
|||||||
Reference in New Issue
Block a user