From 5d46200941ea7cd0a83c9de4d057b7f9ae075099 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Tue, 23 Jun 2026 18:17:13 +0800 Subject: [PATCH] fix: finish all patient related upgrades --- .../GapAnalysisFixTests.cs | 68 +++++++++ .../Services/EncounterService.cs | 75 ++++++++- .../src/__tests__/EncounterTimeline.test.js | 60 ++++++++ .../src/__tests__/PatientBanner.test.js | 56 +++++++ .../src/__tests__/chartMedications.test.js | 57 +++++++ .../src/__tests__/patientFormat.test.js | 38 +++++ .../src/__tests__/timelineFormat.test.js | 41 +++++ .../src/__tests__/useChartData.test.js | 4 + vigilcare-dashboard/src/api/encounters.js | 4 + .../src/components/charts/TrendsGrid.vue | 6 +- .../src/components/charts/VitalChart.vue | 40 ++++- .../src/components/charts/VitalTrendChart.vue | 4 + .../components/patient/EncounterTimeline.vue | 142 ++++++++++++++++++ .../src/components/patient/PatientBanner.vue | 109 ++++++++++++++ .../src/composables/chartMedications.js | 53 +++++++ .../src/composables/patientFormat.js | 90 +++++++++++ .../src/composables/timelineFormat.js | 61 ++++++++ .../src/composables/useChartData.js | 1 + .../src/plugins/medicationMarkerPlugin.js | 68 +++++++++ .../src/views/PatientDetail.vue | 27 +++- 20 files changed, 985 insertions(+), 19 deletions(-) create mode 100644 vigilcare-dashboard/src/__tests__/EncounterTimeline.test.js create mode 100644 vigilcare-dashboard/src/__tests__/PatientBanner.test.js create mode 100644 vigilcare-dashboard/src/__tests__/chartMedications.test.js create mode 100644 vigilcare-dashboard/src/__tests__/patientFormat.test.js create mode 100644 vigilcare-dashboard/src/__tests__/timelineFormat.test.js create mode 100644 vigilcare-dashboard/src/components/patient/EncounterTimeline.vue create mode 100644 vigilcare-dashboard/src/components/patient/PatientBanner.vue create mode 100644 vigilcare-dashboard/src/composables/chartMedications.js create mode 100644 vigilcare-dashboard/src/composables/patientFormat.js create mode 100644 vigilcare-dashboard/src/composables/timelineFormat.js create mode 100644 vigilcare-dashboard/src/plugins/medicationMarkerPlugin.js diff --git a/VigilCareClinicalAPI.Tests/GapAnalysisFixTests.cs b/VigilCareClinicalAPI.Tests/GapAnalysisFixTests.cs index a001ef6..fce93dd 100644 --- a/VigilCareClinicalAPI.Tests/GapAnalysisFixTests.cs +++ b/VigilCareClinicalAPI.Tests/GapAnalysisFixTests.cs @@ -278,6 +278,74 @@ public class GapAnalysisFixTests : IAsyncLifetime 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(); + + 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( + $"/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 // ------------------------------------------------------------------------- diff --git a/VigilCareClinicalAPI/Services/EncounterService.cs b/VigilCareClinicalAPI/Services/EncounterService.cs index 6ffbd1d..3f4dda9 100644 --- a/VigilCareClinicalAPI/Services/EncounterService.cs +++ b/VigilCareClinicalAPI/Services/EncounterService.cs @@ -191,21 +191,82 @@ public class EncounterService : IEncounterService if (encounter is null) throw new NotFoundException("Encounter not found.", "ENCOUNTER_NOT_FOUND"); + var events = new List(); + + 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 .Where(o => o.EncounterId == encounterId) - .OrderByDescending(o => o.RecordedAt) - .Select(o => new { type = "observation", timestamp = o.RecordedAt, o.ObservationCode, o.Value, o.Unit }) + .Select(o => new + { + type = "observation", + timestamp = o.RecordedAt, + o.ObservationCode, + o.Value, + o.Unit + }) .ToListAsync(); + events.AddRange(observations); var alerts = await _db.ClinicalAlerts .Where(a => a.EncounterId == encounterId) - .OrderByDescending(a => a.TriggeredAt) - .Select(a => new { type = "alert", timestamp = a.TriggeredAt, a.AlertType, a.Severity, a.Status }) + .Select(a => new + { + type = "alert", + timestamp = a.TriggeredAt, + alertType = a.AlertType.ToString(), + severity = a.Severity.ToString(), + status = a.Status.ToString() + }) .ToListAsync(); + events.AddRange(alerts); - var timeline = observations.Cast() - .Concat(alerts.Cast()) - .OrderByDescending(x => (DateTimeOffset)((dynamic)x).timestamp) + var orders = await _db.Orders + .Where(o => o.EncounterId == encounterId) + .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(); return new { encounterId, events = timeline }; diff --git a/vigilcare-dashboard/src/__tests__/EncounterTimeline.test.js b/vigilcare-dashboard/src/__tests__/EncounterTimeline.test.js new file mode 100644 index 0000000..3104c30 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/EncounterTimeline.test.js @@ -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') + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/PatientBanner.test.js b/vigilcare-dashboard/src/__tests__/PatientBanner.test.js new file mode 100644 index 0000000..f95969a --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/PatientBanner.test.js @@ -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/) + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/chartMedications.test.js b/vigilcare-dashboard/src/__tests__/chartMedications.test.js new file mode 100644 index 0000000..5a52054 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/chartMedications.test.js @@ -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) + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/patientFormat.test.js b/vigilcare-dashboard/src/__tests__/patientFormat.test.js new file mode 100644 index 0000000..9009d5a --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/patientFormat.test.js @@ -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\)/) + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/timelineFormat.test.js b/vigilcare-dashboard/src/__tests__/timelineFormat.test.js new file mode 100644 index 0000000..42ace50 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/timelineFormat.test.js @@ -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') + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/useChartData.test.js b/vigilcare-dashboard/src/__tests__/useChartData.test.js index 777f3af..6951392 100644 --- a/vigilcare-dashboard/src/__tests__/useChartData.test.js +++ b/vigilcare-dashboard/src/__tests__/useChartData.test.js @@ -26,6 +26,10 @@ describe('useChartData', () => { const { chartData } = mountChartData(observations, 'HEART_RATE') expect(chartData.value.datasets[0].data).toEqual([72, 80]) expect(chartData.value.labels).toHaveLength(2) + expect(chartData.value.timestamps).toEqual([ + '2026-06-19T11:00:00Z', + '2026-06-19T12:00:00Z', + ]) }) it('sortsChronologically', () => { diff --git a/vigilcare-dashboard/src/api/encounters.js b/vigilcare-dashboard/src/api/encounters.js index c74b834..e1e6733 100644 --- a/vigilcare-dashboard/src/api/encounters.js +++ b/vigilcare-dashboard/src/api/encounters.js @@ -21,4 +21,8 @@ export async function fetchObservations(encounterId, { limit = 50 } = {}) { cursor = page.hasMore ? page.nextCursor : null } while (cursor) return all +} + +export function fetchTimeline(encounterId) { + return api.get(`/api/v1/encounters/${encounterId}/timeline`) } \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/charts/TrendsGrid.vue b/vigilcare-dashboard/src/components/charts/TrendsGrid.vue index 4b90d28..ab93184 100644 --- a/vigilcare-dashboard/src/components/charts/TrendsGrid.vue +++ b/vigilcare-dashboard/src/components/charts/TrendsGrid.vue @@ -1,7 +1,10 @@ @@ -40,4 +70,4 @@ const chartOptions = shallowRef(markRaw({ - \ No newline at end of file + diff --git a/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue b/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue index d0f5178..a147376 100644 --- a/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue +++ b/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue @@ -4,6 +4,7 @@ import VitalChart from './VitalChart.vue' const props = defineProps({ observations: { type: Array, required: true }, + medications: { type: Array, default: () => [] }, code: { type: String, required: true }, title: { type: String, required: true }, yMin: { type: Number, default: undefined }, @@ -19,5 +20,8 @@ const { chartData } = useChartData(() => props.observations, props.code) :title="title" :y-min="yMin" :y-max="yMax" + :observations="observations" + :observation-code="code" + :medications="medications" /> diff --git a/vigilcare-dashboard/src/components/patient/EncounterTimeline.vue b/vigilcare-dashboard/src/components/patient/EncounterTimeline.vue new file mode 100644 index 0000000..979776b --- /dev/null +++ b/vigilcare-dashboard/src/components/patient/EncounterTimeline.vue @@ -0,0 +1,142 @@ + + + diff --git a/vigilcare-dashboard/src/components/patient/PatientBanner.vue b/vigilcare-dashboard/src/components/patient/PatientBanner.vue new file mode 100644 index 0000000..e3d4ece --- /dev/null +++ b/vigilcare-dashboard/src/components/patient/PatientBanner.vue @@ -0,0 +1,109 @@ + + + diff --git a/vigilcare-dashboard/src/composables/chartMedications.js b/vigilcare-dashboard/src/composables/chartMedications.js new file mode 100644 index 0000000..76569b9 --- /dev/null +++ b/vigilcare-dashboard/src/composables/chartMedications.js @@ -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 +} diff --git a/vigilcare-dashboard/src/composables/patientFormat.js b/vigilcare-dashboard/src/composables/patientFormat.js new file mode 100644 index 0000000..31b9f1c --- /dev/null +++ b/vigilcare-dashboard/src/composables/patientFormat.js @@ -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' +} diff --git a/vigilcare-dashboard/src/composables/timelineFormat.js b/vigilcare-dashboard/src/composables/timelineFormat.js new file mode 100644 index 0000000..c963fe5 --- /dev/null +++ b/vigilcare-dashboard/src/composables/timelineFormat.js @@ -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 +} diff --git a/vigilcare-dashboard/src/composables/useChartData.js b/vigilcare-dashboard/src/composables/useChartData.js index f40d82b..867cc2e 100644 --- a/vigilcare-dashboard/src/composables/useChartData.js +++ b/vigilcare-dashboard/src/composables/useChartData.js @@ -12,6 +12,7 @@ export function useChartData(observations, code) { return { labels: filtered.map(o => formatTime(o.recordedAt)), + timestamps: filtered.map(o => o.recordedAt), datasets: [{ label: code, data: filtered.map(o => o.value), diff --git a/vigilcare-dashboard/src/plugins/medicationMarkerPlugin.js b/vigilcare-dashboard/src/plugins/medicationMarkerPlugin.js new file mode 100644 index 0000000..c28d688 --- /dev/null +++ b/vigilcare-dashboard/src/plugins/medicationMarkerPlugin.js @@ -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' + } + }, +} diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index bc77f9a..10d509e 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -8,6 +8,7 @@ import { useAlertStore } from '@/stores/alerts' import { useScoringStore } from '@/stores/scoring' import * as encountersApi from '@/api/encounters' import * as clinicalApi from '@/api/clinical' +import PatientBanner from '@/components/patient/PatientBanner.vue' import VitalsPanel from '@/components/patient/VitalsPanel.vue' import ScoresPanel from '@/components/patient/ScoresPanel.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 QsofaHistory from '@/components/charts/QsofaHistory.vue' import SofaHistory from '@/components/charts/SofaHistory.vue' +import EncounterTimeline from '@/components/patient/EncounterTimeline.vue' import ReplayControls from '@/components/replay/ReplayControls.vue' import AlertReasoning from '@/components/alerts/AlertReasoning.vue' import Skeleton from '@/components/ui/Skeleton.vue' @@ -56,6 +58,7 @@ const sofaHistory = ref([]) const medications = ref([]) const sepsisBundle = ref(null) const orders = ref([]) +const timelineEvents = ref([]) const selectedAlert = ref(null) let nextAlertIndex = 0 @@ -67,6 +70,10 @@ const replayObservations = computed(() => observations.value.filter(o => isAtOrBefore(o.recordedAt)), ) +const replayMedications = computed(() => + medications.value.filter(m => isAtOrBefore(m.administeredAt)), +) + const replayNews2History = computed(() => news2History.value.filter(h => isAtOrBefore(h.calculatedAt)), ) @@ -83,6 +90,10 @@ const replaySofaHistory = computed(() => sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)), ) +const replayTimelineEvents = computed(() => + timelineEvents.value.filter(e => isAtOrBefore(e.timestamp)), +) + function collectScenarioTimes() { return [ ...observations.value.map(o => new Date(o.recordedAt).getTime()), @@ -90,6 +101,7 @@ function collectScenarioTimes() { ...gcsHistory.value.map(h => new Date(h.calculatedAt).getTime()), ...qsofaHistory.value.map(h => new Date(h.evaluatedAt).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()), ].filter(Number.isFinite) } @@ -109,7 +121,7 @@ async function loadAll() { const id = route.params.encounterId loading.value = true 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.fetchObservations(id), clinicalApi.fetchNews2History(id).catch(() => []), @@ -119,6 +131,7 @@ async function loadAll() { clinicalApi.fetchMedications(id).catch(() => []), clinicalApi.fetchSepsisBundle(id), clinicalApi.fetchOrders(id).catch(() => ({ items: [] })), + encountersApi.fetchTimeline(id).catch(() => ({ events: [] })), ]) await alertStore.loadAlerts(id) encounter.value = enc @@ -130,6 +143,7 @@ async function loadAll() { medications.value = meds sepsisBundle.value = bundle orders.value = ord.items ?? ord + timelineEvents.value = timeline.events ?? [] scoringStore.startPolling(id) syncReplayBounds() } 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(() => { stopPlayback() @@ -189,11 +203,10 @@ onBeforeUnmount(() => { ← Ward -

- {{ encounter.patient?.firstName }} {{ encounter.patient?.lastName }} -

+ +
@@ -227,8 +240,10 @@ onBeforeUnmount(() => { :sofa="sofa" /> + +
- +