Fix issues with critical alerts breaking the simulation

This commit is contained in:
voltsrage
2026-06-25 02:06:35 +08:00
parent df6fbed401
commit 09a84f34ba
29 changed files with 196 additions and 155 deletions
@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class AlertSeverityJsonConverter : JsonConverter<AlertSeverity>
{
public override AlertSeverity Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> AlertSeverityExtensions.FromDbString(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, AlertSeverity value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToDbString());
}
@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class AlertStatusJsonConverter : JsonConverter<AlertStatus>
{
public override AlertStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> AlertStatusExtensions.FromDbString(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, AlertStatus value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToDbString());
}
@@ -0,0 +1,11 @@
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class AlertTypeJsonConverter : JsonConverter<AlertType>
{
public override AlertType Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> AlertTypeExtensions.FromDbString(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, AlertType value, JsonSerializerOptions options)
=> writer.WriteStringValue(value.ToDbString());
}
@@ -44,7 +44,10 @@ namespace VigilCareClinicalAPI.Migrations
table: "patients"); table: "patients");
migrationBuilder.Sql( migrationBuilder.Sql(
"ALTER TABLE patients ALTER COLUMN date_of_birth TYPE date USING date_of_birth::date;"); """
UPDATE patients SET date_of_birth = '1900-01-01' WHERE date_of_birth !~ '^\d{4}-\d{2}-\d{2}$';
ALTER TABLE patients ALTER COLUMN date_of_birth TYPE date USING date_of_birth::date;
""");
} }
} }
} }
@@ -56,6 +56,14 @@ namespace VigilCareClinicalAPI.Migrations
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.Sql(
"""
UPDATE patients SET last_name = LEFT(last_name, 100);
UPDATE patients SET first_name = LEFT(first_name, 100);
UPDATE patients SET emergency_contact_phone = LEFT(emergency_contact_phone, 20);
UPDATE patients SET emergency_contact_name = LEFT(emergency_contact_name, 200);
""");
migrationBuilder.AlterColumn<string>( migrationBuilder.AlterColumn<string>(
name: "last_name", name: "last_name",
table: "patients", table: "patients",
+4 -1
View File
@@ -258,9 +258,12 @@ try
opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new BloodTypeJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new AuditActionJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new AuditActionJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new SepsisBundleComplianceStatusJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new SepsisBundleComplianceStatusJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); opts.JsonSerializerOptions.Converters.Add(new AlertTypeJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new AlertSeverityJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new AlertStatusJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new ObservationSourceJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter()); opts.JsonSerializerOptions.Converters.Add(new DepartmentJsonConverter());
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
}); });
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options => builder.Services.AddSwaggerGen(options =>
@@ -6,9 +6,9 @@ import { useAuthStore } from '@/stores/auth'
const alert = { const alert = {
id: 'alert-1', id: 'alert-1',
alertType: 'SofaSepsis', alertType: 'SOFA_SEPSIS',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
details: 'SOFA delta >= 2', details: 'SOFA delta >= 2',
} }
@@ -5,9 +5,9 @@ import AlertCard from '@/components/alerts/AlertCard.vue'
const openAlert = { const openAlert = {
id: 'alert-1', id: 'alert-1',
alertType: 'SepsisWarning', alertType: 'SEPSIS_WARNING',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
details: 'SIRS criteria met', details: 'SIRS criteria met',
triggeredAt: '2026-06-19T12:00:00Z', triggeredAt: '2026-06-19T12:00:00Z',
} }
@@ -15,7 +15,7 @@ const openAlert = {
const resolvedAlert = { const resolvedAlert = {
...openAlert, ...openAlert,
id: 'alert-2', id: 'alert-2',
status: 'Resolved', status: 'RESOLVED',
} }
describe('AlertCard', () => { describe('AlertCard', () => {
@@ -25,7 +25,7 @@ describe('AlertCard', () => {
it('showsAlertTypeAndSeverity', () => { it('showsAlertTypeAndSeverity', () => {
const wrapper = mount(AlertCard, { props: { alert: openAlert } }) const wrapper = mount(AlertCard, { props: { alert: openAlert } })
expect(wrapper.text()).toContain('Critical') expect(wrapper.text()).toContain('CRITICAL')
expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)') expect(wrapper.text()).toContain('Sepsis Warning (SIRS — Legacy)')
}) })
@@ -55,7 +55,7 @@ describe('AlertCard', () => {
props: { props: {
alert: { alert: {
...openAlert, ...openAlert,
status: 'Acknowledged', status: 'ACKNOWLEDGED',
acknowledgedBy: 'Demo Nurse (NURSE)', acknowledgedBy: 'Demo Nurse (NURSE)',
acknowledgedAt: '2026-06-19T12:30:00Z', acknowledgedAt: '2026-06-19T12:30:00Z',
}, },
@@ -3,19 +3,19 @@ import { alertTypeLabel, bundleTriggerLabel } from '@/api/normalize'
describe('AlertLabels', () => { describe('AlertLabels', () => {
it('sofaSepsisLabel', () => { it('sofaSepsisLabel', () => {
expect(alertTypeLabel('SofaSepsis')).toBe('Sepsis Alert (SOFA)') expect(alertTypeLabel('SOFA_SEPSIS')).toBe('Sepsis Alert (SOFA)')
}) })
it('legacySepsisLabel', () => { it('legacySepsisLabel', () => {
expect(alertTypeLabel('SepsisWarning')).toBe('Sepsis Warning (SIRS — Legacy)') expect(alertTypeLabel('SEPSIS_WARNING')).toBe('Sepsis Warning (SIRS — Legacy)')
}) })
it('qsofaScreenLabel', () => { it('qsofaScreenLabel', () => {
expect(alertTypeLabel('QsofaScreen')).toBe('qSOFA Screen') expect(alertTypeLabel('QSOFA_SCREEN')).toBe('qSOFA Screen')
}) })
it('gcsCriticalLabel', () => { it('gcsCriticalLabel', () => {
expect(alertTypeLabel('GcsCritical')).toBe('GCS Critical (≤ 8)') expect(alertTypeLabel('GCS_CRITICAL')).toBe('GCS Critical (≤ 8)')
}) })
it('bundleTriggerSofa', () => { it('bundleTriggerSofa', () => {
@@ -5,16 +5,16 @@ import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
const sepsisAlert = { const sepsisAlert = {
id: 'alert-1', id: 'alert-1',
alertType: 'SepsisWarning', alertType: 'SEPSIS_WARNING',
severity: 'Critical', severity: 'CRITICAL',
details: 'SIRS criteria met', details: 'SIRS criteria met',
triggeredAt: '2026-06-19T12:00:00Z', triggeredAt: '2026-06-19T12:00:00Z',
} }
const qsofaScreenAlert = { const qsofaScreenAlert = {
id: 'alert-2', id: 'alert-2',
alertType: 'QsofaScreen', alertType: 'QSOFA_SCREEN',
severity: 'Warning', severity: 'WARNING',
details: 'qSOFA screen positive', details: 'qSOFA screen positive',
triggeredAt: '2026-06-19T12:30:00Z', triggeredAt: '2026-06-19T12:30:00Z',
} }
@@ -42,7 +42,7 @@ describe('AlertReasoning', () => {
const unknown = { const unknown = {
id: 'alert-3', id: 'alert-3',
alertType: 'CustomUnknown', alertType: 'CustomUnknown',
severity: 'Warning', severity: 'WARNING',
details: 'Something unusual happened', details: 'Something unusual happened',
triggeredAt: '2026-06-19T13:00:00Z', triggeredAt: '2026-06-19T13:00:00Z',
} }
@@ -54,8 +54,8 @@ describe('AlertReasoning', () => {
it('showsStructuredExplanationWhenPresent', () => { it('showsStructuredExplanationWhenPresent', () => {
const news2Alert = { const news2Alert = {
id: 'alert-4', id: 'alert-4',
alertType: 'News2Emergency', alertType: 'NEWS2_EMERGENCY',
severity: 'Critical', severity: 'CRITICAL',
details: 'NEWS2 score 8 (HIGH): RESP_RATE=3, SPO2=2', details: 'NEWS2 score 8 (HIGH): RESP_RATE=3, SPO2=2',
triggeredAt: '2026-06-19T14:00:00Z', triggeredAt: '2026-06-19T14:00:00Z',
explanation: { explanation: {
@@ -18,9 +18,9 @@ describe('CriticalAlertBanner', () => {
const alertStore = useAlertStore() const alertStore = useAlertStore()
alertStore.bannerAlerts = [{ alertStore.bannerAlerts = [{
id: 'alert-1', id: 'alert-1',
alertType: 'SofaSepsis', alertType: 'SOFA_SEPSIS',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
details: 'SOFA delta >= 2', details: 'SOFA delta >= 2',
explanation: { explanation: {
narrativeSummary: 'SOFA 6 — respiratory (+2), renal (+2).', narrativeSummary: 'SOFA 6 — respiratory (+2), renal (+2).',
@@ -13,9 +13,9 @@ const events = [
{ {
type: 'alert', type: 'alert',
timestamp: '2026-06-23T11:00:00Z', timestamp: '2026-06-23T11:00:00Z',
alertType: 'QsofaScreen', alertType: 'QSOFA_SCREEN',
severity: 'Warning', severity: 'WARNING',
status: 'Open', status: 'OPEN',
}, },
{ {
type: 'medication', type: 'medication',
@@ -23,8 +23,8 @@ vi.mock('@/stores/alertQuality', () => ({
const defaultProps = { const defaultProps = {
alertId: 'alert-1', alertId: 'alert-1',
alertType: 'SepsisWarning', alertType: 'SEPSIS_WARNING',
severity: 'Critical', severity: 'CRITICAL',
} }
function ratingButtons(wrapper) { function ratingButtons(wrapper) {
@@ -10,8 +10,8 @@ const { mockSummaryStats, mockByAlertType } = vi.hoisted(() => ({
falsePositiveRate: 33, falsePositiveRate: 33,
}, },
mockByAlertType: { mockByAlertType: {
SepsisWarning: [{ rating: 'useful' }, { rating: 'would-act' }], SEPSIS_WARNING: [{ rating: 'useful' }, { rating: 'would-act' }],
WarningHeartRate: [{ rating: 'false-positive' }], WARNING_HEART_RATE: [{ rating: 'false-positive' }],
}, },
})) }))
@@ -3,23 +3,23 @@ import { detectNewCriticalAlerts, isNotifiableCriticalAlert } from '@/composable
const criticalOpen = { const criticalOpen = {
id: 'a1', id: 'a1',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
alertType: 'SofaSepsis', alertType: 'SOFA_SEPSIS',
} }
const criticalAcknowledged = { const criticalAcknowledged = {
id: 'a2', id: 'a2',
severity: 'Critical', severity: 'CRITICAL',
status: 'Acknowledged', status: 'ACKNOWLEDGED',
alertType: 'News2Emergency', alertType: 'NEWS2_EMERGENCY',
} }
const warningOpen = { const warningOpen = {
id: 'a3', id: 'a3',
severity: 'Warning', severity: 'WARNING',
status: 'Open', status: 'OPEN',
alertType: 'News2Warning', alertType: 'NEWS2_WARNING',
} }
describe('criticalAlertDetect', () => { describe('criticalAlertDetect', () => {
@@ -39,7 +39,7 @@ describe('criticalAlertDetect', () => {
it('detectsNewCriticalAlertsAfterSeed', () => { it('detectsNewCriticalAlertsAfterSeed', () => {
const seeded = detectNewCriticalAlerts([criticalOpen], [], false) const seeded = detectNewCriticalAlerts([criticalOpen], [], false)
const next = detectNewCriticalAlerts( const next = detectNewCriticalAlerts(
[criticalOpen, { ...criticalOpen, id: 'a4', alertType: 'GcsCritical' }], [criticalOpen, { ...criticalOpen, id: 'a4', alertType: 'GCS_CRITICAL' }],
seeded.nextSeenIds, seeded.nextSeenIds,
seeded.seeded, seeded.seeded,
) )
@@ -29,7 +29,7 @@ const enrichment = {
admissionReason: 'Sepsis workup', admissionReason: 'Sepsis workup',
patient: { allergies: 'Penicillin' }, patient: { allergies: 'Penicillin' },
}, },
openAlerts: [{ alertType: 'News2Emergency' }], openAlerts: [{ alertType: 'NEWS2_EMERGENCY' }],
pendingOrders: [{ description: 'Blood cultures', status: 'Pending' }], pendingOrders: [{ description: 'Blood cultures', status: 'Pending' }],
vitals: extractLatestVitals([ vitals: extractLatestVitals([
{ observationCode: 'HEART_RATE', value: 110, unit: 'bpm', recordedAt: '2026-06-23T10:00:00Z' }, { observationCode: 'HEART_RATE', value: 110, unit: 'bpm', recordedAt: '2026-06-23T10:00:00Z' },
@@ -11,9 +11,9 @@ describe('alert store notifications', () => {
const store = useAlertStore() const store = useAlertStore()
const critical = { const critical = {
id: 'alert-1', id: 'alert-1',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
alertType: 'SofaSepsis', alertType: 'SOFA_SEPSIS',
} }
expect(store.applyPollResults([critical])).toEqual([]) expect(store.applyPollResults([critical])).toEqual([])
@@ -21,9 +21,9 @@ describe('alert store notifications', () => {
const next = { const next = {
id: 'alert-2', id: 'alert-2',
severity: 'Critical', severity: 'CRITICAL',
status: 'Open', status: 'OPEN',
alertType: 'GcsCritical', alertType: 'GCS_CRITICAL',
} }
const newAlerts = store.applyPollResults([critical, next]) const newAlerts = store.applyPollResults([critical, next])
expect(newAlerts.map(alert => alert.id)).toEqual(['alert-2']) expect(newAlerts.map(alert => alert.id)).toEqual(['alert-2'])
@@ -14,7 +14,7 @@ describe('useFeedbackStore', () => {
it('addFeedback_createsEntry', () => { it('addFeedback_createsEntry', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
expect(store.entries).toHaveLength(1) expect(store.entries).toHaveLength(1)
expect(store.entries[0].alertId).toBe('a1') expect(store.entries[0].alertId).toBe('a1')
@@ -23,8 +23,8 @@ describe('useFeedbackStore', () => {
it('addFeedback_updatesExisting', () => { it('addFeedback_updatesExisting', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'false-positive') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'false-positive')
expect(store.entries).toHaveLength(1) expect(store.entries).toHaveLength(1)
expect(store.entries[0].rating).toBe('false-positive') expect(store.entries[0].rating).toBe('false-positive')
@@ -32,10 +32,10 @@ describe('useFeedbackStore', () => {
it('stats_computesCorrectly', () => { it('stats_computesCorrectly', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
store.addFeedback('a2', 'SepsisWarning', 'Critical', 'would-act') store.addFeedback('a2', 'SEPSIS_WARNING', 'CRITICAL', 'would-act')
store.addFeedback('a3', 'WarningHeartRate', 'Warning', 'false-positive') store.addFeedback('a3', 'WARNING_HEART_RATE', 'WARNING', 'false-positive')
store.addFeedback('a4', 'WarningHeartRate', 'Warning', 'too-early') store.addFeedback('a4', 'WARNING_HEART_RATE', 'WARNING', 'too-early')
expect(store.stats.total).toBe(4) expect(store.stats.total).toBe(4)
expect(store.stats.useful).toBe(2) expect(store.stats.useful).toBe(2)
@@ -46,18 +46,18 @@ describe('useFeedbackStore', () => {
it('byAlertType_groupsCorrectly', () => { it('byAlertType_groupsCorrectly', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
store.addFeedback('a2', 'WarningHeartRate', 'Warning', 'false-positive') store.addFeedback('a2', 'WARNING_HEART_RATE', 'WARNING', 'false-positive')
store.addFeedback('a3', 'SepsisWarning', 'Critical', 'too-early') store.addFeedback('a3', 'SEPSIS_WARNING', 'CRITICAL', 'too-early')
expect(Object.keys(store.byAlertType)).toHaveLength(2) expect(Object.keys(store.byAlertType)).toHaveLength(2)
expect(store.byAlertType.SepsisWarning).toHaveLength(2) expect(store.byAlertType.SEPSIS_WARNING).toHaveLength(2)
expect(store.byAlertType.WarningHeartRate).toHaveLength(1) expect(store.byAlertType.WARNING_HEART_RATE).toHaveLength(1)
}) })
it('exportAsJson_generatesValidJson', () => { it('exportAsJson_generatesValidJson', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
let capturedBlob = null let capturedBlob = null
const click = vi.fn() const click = vi.fn()
@@ -81,7 +81,7 @@ describe('useFeedbackStore', () => {
it('clearAll_emptiesEntries', () => { it('clearAll_emptiesEntries', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
store.clearAll() store.clearAll()
expect(store.entries).toHaveLength(0) expect(store.entries).toHaveLength(0)
@@ -89,7 +89,7 @@ describe('useFeedbackStore', () => {
it('persistsToLocalStorage', () => { it('persistsToLocalStorage', () => {
const store = useFeedbackStore() const store = useFeedbackStore()
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful') store.addFeedback('a1', 'SEPSIS_WARNING', 'CRITICAL', 'useful')
const stored = JSON.parse(localStorage.getItem('vigilcare-feedback')) const stored = JSON.parse(localStorage.getItem('vigilcare-feedback'))
expect(stored).toHaveLength(1) expect(stored).toHaveLength(1)
+41 -47
View File
@@ -1,43 +1,43 @@
const ALERT_TYPE_LABELS = { const ALERT_TYPE_LABELS = {
SepsisWarning: 'Sepsis Warning (SIRS — Legacy)', SEPSIS_WARNING: 'Sepsis Warning (SIRS — Legacy)',
QsofaWarning: 'qSOFA Alert (Legacy)', QSOFA_WARNING: 'qSOFA Alert (Legacy)',
QsofaScreen: 'qSOFA Screen', QSOFA_SCREEN: 'qSOFA Screen',
SofaSepsis: 'Sepsis Alert (SOFA)', SOFA_SEPSIS: 'Sepsis Alert (SOFA)',
SofaWarning: 'SOFA Warning', SOFA_WARNING: 'SOFA Warning',
GcsCritical: 'GCS Critical (≤ 8)', GCS_CRITICAL: 'GCS Critical (≤ 8)',
GcsWarning: 'GCS Warning (912)', GCS_WARNING: 'GCS Warning (912)',
News2Warning: 'NEWS2 Warning', NEWS2_WARNING: 'NEWS2 Warning',
News2Emergency: 'NEWS2 Emergency', NEWS2_EMERGENCY: 'NEWS2 Emergency',
RapidDeterioration: 'Rapid Deterioration', RAPID_DETERIORATION: 'Rapid Deterioration',
CriticalHeartRate: 'Critical Heart Rate', CRITICAL_HEART_RATE: 'Critical Heart Rate',
CriticalTempC: 'Critical Temperature', CRITICAL_TEMP_C: 'Critical Temperature',
CriticalPotassiumMeqL: 'Critical Potassium', CRITICAL_POTASSIUM_MEQ_L: 'Critical Potassium',
CriticalSpo2: 'Critical SpO₂', CRITICAL_SPO2: 'Critical SpO₂',
CriticalRespRate: 'Critical Respiratory Rate', CRITICAL_RESP_RATE: 'Critical Respiratory Rate',
CriticalWbcKUl: 'Critical WBC', CRITICAL_WBC_K_UL: 'Critical WBC',
CriticalSystolicBp: 'Critical Systolic BP', CRITICAL_SYSTOLIC_BP: 'Critical Systolic BP',
CriticalDiastolicBp: 'Critical Diastolic BP', CRITICAL_DIASTOLIC_BP: 'Critical Diastolic BP',
CriticalLactateMmolL: 'Critical Lactate', CRITICAL_LACTATE_MMOL_L: 'Critical Lactate',
CriticalAvpu: 'Critical AVPU', CRITICAL_AVPU: 'Critical AVPU',
CriticalGlucoseMgDl: 'Critical Glucose', CRITICAL_GLUCOSE_MG_DL: 'Critical Glucose',
WarningHeartRate: 'Warning Heart Rate', WARNING_HEART_RATE: 'Warning Heart Rate',
WarningTempC: 'Warning Temperature', WARNING_TEMP_C: 'Warning Temperature',
WarningPotassiumMeqL: 'Warning Potassium', WARNING_POTASSIUM_MEQ_L: 'Warning Potassium',
WarningSpo2: 'Warning SpO₂', WARNING_SPO2: 'Warning SpO₂',
WarningRespRate: 'Warning Respiratory Rate', WARNING_RESP_RATE: 'Warning Respiratory Rate',
WarningWbcKUl: 'Warning WBC', WARNING_WBC_K_UL: 'Warning WBC',
WarningSystolicBp: 'Warning Systolic BP', WARNING_SYSTOLIC_BP: 'Warning Systolic BP',
WarningDiastolicBp: 'Warning Diastolic BP', WARNING_DIASTOLIC_BP: 'Warning Diastolic BP',
WarningLactateMmolL: 'Warning Lactate', WARNING_LACTATE_MMOL_L: 'Warning Lactate',
WarningGlucoseMgDl: 'Warning Glucose', WARNING_GLUCOSE_MG_DL: 'Warning Glucose',
CriticalPao2MmHg: 'Critical PaO₂', CRITICAL_PAO2_MMHG: 'Critical PaO₂',
WarningPao2MmHg: 'Warning PaO₂', WARNING_PAO2_MMHG: 'Warning PaO₂',
CriticalPlateletKUl: 'Critical Platelets', CRITICAL_PLATELET_K_UL: 'Critical Platelets',
WarningPlateletKUl: 'Warning Platelets', WARNING_PLATELET_K_UL: 'Warning Platelets',
CriticalBilirubinMgDl: 'Critical Bilirubin', CRITICAL_BILIRUBIN_MG_DL: 'Critical Bilirubin',
WarningBilirubinMgDl: 'Warning Bilirubin', WARNING_BILIRUBIN_MG_DL: 'Warning Bilirubin',
CriticalCreatinineMgDl: 'Critical Creatinine', CRITICAL_CREATININE_MG_DL: 'Critical Creatinine',
WarningCreatinineMgDl: 'Warning Creatinine', WARNING_CREATININE_MG_DL: 'Warning Creatinine',
} }
const BUNDLE_TRIGGER_LABELS = { const BUNDLE_TRIGGER_LABELS = {
@@ -49,7 +49,7 @@ const BUNDLE_TRIGGER_LABELS = {
export function alertTypeLabel(type) { export function alertTypeLabel(type) {
if (!type) return '' if (!type) return ''
return ALERT_TYPE_LABELS[type] return ALERT_TYPE_LABELS[type]
?? type.replace(/([A-Z])/g, '_$1').slice(1).toUpperCase() ?? type.replace(/_/g, ' ')
} }
export function bundleTriggerLabel(triggerType) { export function bundleTriggerLabel(triggerType) {
@@ -58,13 +58,7 @@ export function bundleTriggerLabel(triggerType) {
} }
export function alertStatusToApiFilter(status) { export function alertStatusToApiFilter(status) {
const map = { return status ?? null
Open: 'OPEN',
Acknowledged: 'ACKNOWLEDGED',
Resolved: 'RESOLVED',
Escalated: 'ESCALATED',
}
return map[status] ?? null
} }
const OBSERVATION_LABELS = { const OBSERVATION_LABELS = {
@@ -35,7 +35,7 @@ const notePreview = computed(() =>
) )
function severityVariant(severity) { function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning' return severity === 'CRITICAL' ? 'critical' : 'warning'
} }
function onConfirm() { function onConfirm() {
@@ -17,24 +17,24 @@ const emit = defineEmits(['acknowledge', 'resolve'])
const actionHint = computed(() => { const actionHint = computed(() => {
const hints = { const hints = {
QsofaScreen: 'Recommend SOFA labs', QSOFA_SCREEN: 'Recommend SOFA labs',
SofaSepsis: 'Review organ breakdown · Initiate bundle', SOFA_SEPSIS: 'Review organ breakdown · Initiate bundle',
GcsCritical: 'Urgent neuro assessment', GCS_CRITICAL: 'Urgent neuro assessment',
GcsWarning: 'Monitor consciousness', GCS_WARNING: 'Monitor consciousness',
} }
return hints[props.alert.alertType] ?? null return hints[props.alert.alertType] ?? null
}) })
function showActions(status) { function showActions(status) {
return status !== 'Resolved' return status !== 'RESOLVED'
} }
function canAcknowledge(status) { function canAcknowledge(status) {
return status === 'Open' || status === 'Escalated' return status === 'OPEN' || status === 'ESCALATED'
} }
function canResolve(status) { function canResolve(status) {
return status === 'Acknowledged' return status === 'ACKNOWLEDGED'
} }
function formatTime(iso) { function formatTime(iso) {
@@ -93,7 +93,7 @@ function formatTime(iso) {
</div> </div>
<div <div
v-if="alert.status === 'Acknowledged' || alert.status === 'Resolved'" v-if="alert.status === 'ACKNOWLEDGED' || alert.status === 'RESOLVED'"
class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700" class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700"
> >
<FeedbackButtons <FeedbackButtons
@@ -1,11 +1,11 @@
<script setup> <script setup>
const activeFilter = defineModel({ type: String, default: 'Open' }) const activeFilter = defineModel({ type: String, default: 'OPEN' })
const tabs = [ const tabs = [
{ label: 'Open', value: 'Open', badgeVariant: 'critical' }, { label: 'Open', value: 'OPEN', badgeVariant: 'critical' },
{ label: 'Acknowledged', value: 'Acknowledged', badgeVariant: 'warning' }, { label: 'Acknowledged', value: 'ACKNOWLEDGED', badgeVariant: 'warning' },
{ label: 'Resolved', value: 'Resolved', badgeVariant: 'success' }, { label: 'Resolved', value: 'RESOLVED', badgeVariant: 'success' },
{ label: 'Escalated', value: 'Escalated', badgeVariant: 'info' }, { label: 'Escalated', value: 'ESCALATED', badgeVariant: 'info' },
] ]
</script> </script>
@@ -19,57 +19,57 @@ const props = defineProps({
const CORRELATION_WINDOW_MS = 90 * 60 * 1000 const CORRELATION_WINDOW_MS = 90 * 60 * 1000
const reasoningMap = { const reasoningMap = {
WarningHeartRate: { WARNING_HEART_RATE: {
label: 'Heart Rate Warning', label: 'Heart Rate Warning',
explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).`, explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91110 bpm or 4150 bpm).`,
}, },
WarningSystolicBp: { WARNING_SYSTOLIC_BP: {
label: 'Systolic BP Warning', label: 'Systolic BP Warning',
explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).`, explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101110 mmHg).`,
}, },
WarningTempC: { WARNING_TEMP_C: {
label: 'Temperature Warning', label: 'Temperature Warning',
explain: (a) => `Temperature ${extractValue(a)} is in the warning range.`, explain: (a) => `Temperature ${extractValue(a)} is in the warning range.`,
}, },
SepsisWarning: { SEPSIS_WARNING: {
label: 'SIRS / Sepsis Alert (Legacy)', label: 'SIRS / Sepsis Alert (Legacy)',
explain: () => explain: () =>
'Historical alert: ≥2 of 4 SIRS criteria met. New sepsis detection uses SOFA delta ≥ 2.', 'Historical alert: ≥2 of 4 SIRS criteria met. New sepsis detection uses SOFA delta ≥ 2.',
}, },
QsofaWarning: { QSOFA_WARNING: {
label: 'qSOFA Alert (Legacy)', label: 'qSOFA Alert (Legacy)',
explain: () => 'Historical alert: ≥2 of 3 qSOFA criteria met.', explain: () => 'Historical alert: ≥2 of 3 qSOFA criteria met.',
}, },
QsofaScreen: { QSOFA_SCREEN: {
label: 'qSOFA Screen', label: 'qSOFA Screen',
explain: () => explain: () =>
'Bedside screen positive (≥2/3). Recommend ordering SOFA labs to evaluate organ dysfunction.', 'Bedside screen positive (≥2/3). Recommend ordering SOFA labs to evaluate organ dysfunction.',
}, },
SofaSepsis: { SOFA_SEPSIS: {
label: 'Sepsis Alert (SOFA)', label: 'Sepsis Alert (SOFA)',
explain: (a) => explainSofaAlert(a, true), explain: (a) => explainSofaAlert(a, true),
}, },
SofaWarning: { SOFA_WARNING: {
label: 'SOFA Warning', label: 'SOFA Warning',
explain: (a) => explainSofaAlert(a, false), explain: (a) => explainSofaAlert(a, false),
}, },
GcsCritical: { GCS_CRITICAL: {
label: 'GCS Critical', label: 'GCS Critical',
explain: (a) => explainGcsAlert(a), explain: (a) => explainGcsAlert(a),
}, },
GcsWarning: { GCS_WARNING: {
label: 'GCS Warning', label: 'GCS Warning',
explain: (a) => explainGcsAlert(a), explain: (a) => explainGcsAlert(a),
}, },
News2Warning: { NEWS2_WARNING: {
label: 'NEWS2 Medium Risk', label: 'NEWS2 Medium Risk',
explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.', explain: () => 'NEWS2 composite score 56 indicating medium clinical risk.',
}, },
News2Emergency: { NEWS2_EMERGENCY: {
label: 'NEWS2 High Risk', label: 'NEWS2 High Risk',
explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.', explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.',
}, },
RapidDeterioration: { RAPID_DETERIORATION: {
label: 'Rapid Deterioration', label: 'Rapid Deterioration',
explain: () => 'Vital sign trajectory shows rapid change within the sliding window.', explain: () => 'Vital sign trajectory shows rapid change within the sliding window.',
}, },
@@ -104,10 +104,10 @@ const recentMedications = computed(() => {
const actionHint = computed(() => { const actionHint = computed(() => {
const hints = { const hints = {
QsofaScreen: 'Recommend ordering SOFA labs (PaO₂, platelets, bilirubin, creatinine).', QSOFA_SCREEN: 'Recommend ordering SOFA labs (PaO₂, platelets, bilirubin, creatinine).',
SofaSepsis: 'Review organ dysfunction and confirm sepsis bundle initiation.', SOFA_SEPSIS: 'Review organ dysfunction and confirm sepsis bundle initiation.',
GcsCritical: 'Urgent neurological assessment — GCS ≤ 8.', GCS_CRITICAL: 'Urgent neurological assessment — GCS ≤ 8.',
GcsWarning: 'Monitor consciousness closely — GCS 912.', GCS_WARNING: 'Monitor consciousness closely — GCS 912.',
} }
return hints[props.alert.alertType] ?? null return hints[props.alert.alertType] ?? null
}) })
@@ -155,7 +155,7 @@ function formatMed(med) {
<Card> <Card>
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<Badge :variant="alert.severity === 'Critical' ? 'critical' : 'warning'"> <Badge :variant="alert.severity === 'CRITICAL' ? 'critical' : 'warning'">
{{ alert.severity }} {{ alert.severity }}
</Badge> </Badge>
<h3 class="font-medium dark:text-white"> <h3 class="font-medium dark:text-white">
@@ -30,7 +30,7 @@ function loadEncounterAlerts() {
usePolling(loadEncounterAlerts, 5_000) usePolling(loadEncounterAlerts, 5_000)
const visibleAlerts = computed(() => const visibleAlerts = computed(() =>
alerts.value.filter(a => a.status !== 'Resolved'), alerts.value.filter(a => a.status !== 'RESOLVED'),
) )
async function handleAcknowledge(note) { async function handleAcknowledge(note) {
@@ -89,7 +89,7 @@ async function resolve(alertId) {
</div> </div>
<div class="flex shrink-0 gap-2"> <div class="flex shrink-0 gap-2">
<Button <Button
v-if="alert.status === 'Open' || alert.status === 'Escalated'" v-if="alert.status === 'OPEN' || alert.status === 'ESCALATED'"
size="sm" size="sm"
variant="secondary" variant="secondary"
:aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`" :aria-label="`Acknowledge ${alertTypeLabel(alert.alertType)} alert`"
@@ -98,7 +98,7 @@ async function resolve(alertId) {
Ack Ack
</Button> </Button>
<Button <Button
v-if="alert.status === 'Acknowledged'" v-if="alert.status === 'ACKNOWLEDGED'"
size="sm" size="sm"
variant="primary" variant="primary"
:aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`" :aria-label="`Resolve ${alertTypeLabel(alert.alertType)} alert`"
@@ -55,7 +55,7 @@ function formatTime(iso) {
function alertBadgeVariant(event) { function alertBadgeVariant(event) {
if (event.type !== 'alert') return timelineEventStyles(event.type).badge if (event.type !== 'alert') return timelineEventStyles(event.type).badge
return event.severity === 'Critical' ? 'critical' : 'warning' return event.severity === 'CRITICAL' ? 'critical' : 'warning'
} }
</script> </script>
@@ -1,7 +1,7 @@
export function isNotifiableCriticalAlert(alert) { export function isNotifiableCriticalAlert(alert) {
return ( return (
alert.severity === 'Critical' alert.severity === 'CRITICAL'
&& (alert.status === 'Open' || alert.status === 'Escalated') && (alert.status === 'OPEN' || alert.status === 'ESCALATED')
) )
} }
+4 -4
View File
@@ -11,8 +11,8 @@ export const useAlertStore = defineStore('alerts', () => {
const bannerAlerts = ref([]) const bannerAlerts = ref([])
const pollSeeded = ref(false) const pollSeeded = ref(false)
const openAlerts = computed(() => alerts.value.filter(a => a.status === 'Open')) const openAlerts = computed(() => alerts.value.filter(a => a.status === 'OPEN'))
const criticalAlerts = computed(() => alerts.value.filter(a => a.severity === 'Critical')) const criticalAlerts = computed(() => alerts.value.filter(a => a.severity === 'CRITICAL'))
function mergeBannerAlerts(newAlerts) { function mergeBannerAlerts(newAlerts) {
const existingIds = new Set(bannerAlerts.value.map(alert => alert.id)) const existingIds = new Set(bannerAlerts.value.map(alert => alert.id))
@@ -85,7 +85,7 @@ export const useAlertStore = defineStore('alerts', () => {
const updated = await alertsApi.acknowledgeAlert(alertId, note || undefined) const updated = await alertsApi.acknowledgeAlert(alertId, note || undefined)
const alert = alerts.value.find(a => a.id === alertId) const alert = alerts.value.find(a => a.id === alertId)
if (alert) { if (alert) {
alert.status = updated.status ?? 'Acknowledged' alert.status = updated.status ?? 'ACKNOWLEDGED'
alert.acknowledgedBy = updated.acknowledgedBy ?? alert.acknowledgedBy alert.acknowledgedBy = updated.acknowledgedBy ?? alert.acknowledgedBy
alert.acknowledgedAt = updated.acknowledgedAt ?? alert.acknowledgedAt alert.acknowledgedAt = updated.acknowledgedAt ?? alert.acknowledgedAt
} }
@@ -96,7 +96,7 @@ export const useAlertStore = defineStore('alerts', () => {
async function resolve(alertId) { async function resolve(alertId) {
await alertsApi.resolveAlert(alertId) await alertsApi.resolveAlert(alertId)
const alert = alerts.value.find(a => a.id === alertId) const alert = alerts.value.find(a => a.id === alertId)
if (alert) alert.status = 'Resolved' if (alert) alert.status = 'RESOLVED'
dismissBannerAlert(alertId) dismissBannerAlert(alertId)
} }
@@ -12,7 +12,7 @@ import EmptyState from '@/components/ui/EmptyState.vue'
const alertStore = useAlertStore() const alertStore = useAlertStore()
const { alerts, loading } = storeToRefs(alertStore) const { alerts, loading } = storeToRefs(alertStore)
const activeFilter = ref('Open') const activeFilter = ref('OPEN')
const confirmingAlert = ref(null) const confirmingAlert = ref(null)
watch(activeFilter, (status) => { watch(activeFilter, (status) => {
@@ -68,7 +68,7 @@ const selectedAlert = ref(null)
let nextAlertIndex = 0 let nextAlertIndex = 0
const openAlerts = computed(() => const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'), alerts.value.filter(a => a.status === 'OPEN' || a.status === 'ESCALATED'),
) )
const isDischarged = computed(() => encounter.value?.status === 'Discharged') const isDischarged = computed(() => encounter.value?.status === 'Discharged')