feature: Clinical Review Mode: Charts, Replay Controls & Alert Reasoning
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
|
||||
const props = defineProps({
|
||||
alert: { type: Object, required: true },
|
||||
medications: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const CORRELATION_WINDOW_MS = 90 * 60 * 1000
|
||||
|
||||
const reasoningMap = {
|
||||
WarningHeartRate: { label: 'Heart Rate Warning', explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91–110 bpm or 41–50 bpm).` },
|
||||
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101–110 mmHg).` },
|
||||
WarningTempC: { label: 'Temperature Warning', explain: (a) => `Temperature ${extractValue(a)} is in the warning range.` },
|
||||
SepsisWarning: { label: 'SIRS / Sepsis Alert', explain: () => '≥2 of 4 SIRS criteria met: temperature, heart rate, respiratory rate, WBC.' },
|
||||
QsofaWarning: { label: 'qSOFA Alert', explain: () => '≥2 of 3 qSOFA criteria met: RR ≥22, SBP ≤100, altered mentation (AVPU ≥1).' },
|
||||
News2Warning: { label: 'NEWS2 Medium Risk', explain: () => 'NEWS2 composite score 5–6 indicating medium clinical risk.' },
|
||||
News2Emergency: { label: 'NEWS2 High Risk', explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.' },
|
||||
RapidDeterioration: { label: 'Rapid Deterioration', explain: () => 'Vital sign trajectory shows rapid change within the sliding window.' },
|
||||
}
|
||||
|
||||
const recentMedications = computed(() => {
|
||||
if (!props.medications.length || !props.alert.triggeredAt) return []
|
||||
const alertTime = new Date(props.alert.triggeredAt).getTime()
|
||||
return props.medications.filter(m => {
|
||||
const administeredAt = new Date(m.administeredAt).getTime()
|
||||
const delta = alertTime - administeredAt
|
||||
return delta >= 0 && delta <= CORRELATION_WINDOW_MS
|
||||
})
|
||||
})
|
||||
|
||||
function extractValue(alert) {
|
||||
const match = alert.details?.match(/(\d+\.?\d*)/)
|
||||
return match ? match[1] : '—'
|
||||
}
|
||||
|
||||
function formatMed(med) {
|
||||
return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Badge :variant="alert.severity === 'Critical' ? 'critical' : 'warning'">
|
||||
{{ alert.severity }}
|
||||
</Badge>
|
||||
<h3 class="font-medium dark:text-white">
|
||||
{{ reasoningMap[alert.alertType]?.label ?? alert.alertType }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="recentMedications.length"
|
||||
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
>
|
||||
<p class="mb-2 font-medium">Recent medications (within 90 min)</p>
|
||||
<ul class="space-y-2">
|
||||
<li v-for="med in recentMedications" :key="med.id">
|
||||
{{ formatMed(med) }} — {{ new Date(med.administeredAt).toLocaleString() }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="alert.details" class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{{ alert.details }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Triggered at {{ new Date(alert.triggeredAt).toLocaleString() }}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef, markRaw } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { formatTime } from '@/composables/chartFormat'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({ history: { type: Array, required: true } })
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.history.map(h => formatTime(h.calculatedAt)),
|
||||
datasets: [{
|
||||
label: 'NEWS2',
|
||||
data: props.history.map(h => h.totalScore),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: props.history.map(h => {
|
||||
if (h.totalScore >= 7) return 'rgba(220, 38, 38, 0.3)'
|
||||
if (h.totalScore >= 5) return 'rgba(245, 158, 11, 0.3)'
|
||||
return 'rgba(34, 197, 94, 0.3)'
|
||||
}),
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
}],
|
||||
}))
|
||||
|
||||
const chartOptions = shallowRef(markRaw({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: {
|
||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||
},
|
||||
scales: {
|
||||
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">NEWS2 Score Over Time</h3>
|
||||
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup>
|
||||
import VitalTrendChart from './VitalTrendChart.vue'
|
||||
|
||||
defineProps({ observations: { type: Array, required: true } })
|
||||
|
||||
const charts = [
|
||||
{ code: 'HEART_RATE', title: 'Heart Rate (bpm)', yMin: 30, yMax: 180 },
|
||||
{ code: 'RESP_RATE', title: 'Respiratory Rate (/min)', yMin: 0, yMax: 40 },
|
||||
{ code: 'SYSTOLIC_BP', title: 'Systolic BP (mmHg)', yMin: 60, yMax: 250 },
|
||||
{ code: 'SPO2', title: 'SpO₂ (%)', yMin: 80, yMax: 100 },
|
||||
{ code: 'TEMP_C', title: 'Temperature (°C)', yMin: 34, yMax: 42 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid w-full min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<VitalTrendChart
|
||||
v-for="chart in charts"
|
||||
:key="chart.code"
|
||||
:observations="observations"
|
||||
:code="chart.code"
|
||||
:title="chart.title"
|
||||
:y-min="chart.yMin"
|
||||
:y-max="chart.yMax"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup>
|
||||
import { computed, toValue, shallowRef, markRaw } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
chartData: { type: Object, required: true },
|
||||
title: { type: String, required: true },
|
||||
yMin: { type: Number, default: undefined },
|
||||
yMax: { type: Number, default: undefined },
|
||||
})
|
||||
|
||||
const lineData = computed(() => {
|
||||
const data = toValue(props.chartData)
|
||||
return data?.datasets ? data : { labels: [], datasets: [] }
|
||||
})
|
||||
|
||||
const chartOptions = shallowRef(markRaw({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: {
|
||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||
},
|
||||
scales: {
|
||||
y: { min: props.yMin, max: props.yMax },
|
||||
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<h3 class="mb-4 text-sm font-medium text-gray-700 dark:text-gray-300">{{ title }}</h3>
|
||||
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||
<Line :data="lineData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { useChartData } from '@/composables/useChartData'
|
||||
import VitalChart from './VitalChart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
observations: { type: Array, required: true },
|
||||
code: { type: String, required: true },
|
||||
title: { type: String, required: true },
|
||||
yMin: { type: Number, default: undefined },
|
||||
yMax: { type: Number, default: undefined },
|
||||
})
|
||||
|
||||
const { chartData } = useChartData(() => props.observations, props.code)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VitalChart
|
||||
:chart-data="chartData"
|
||||
:title="title"
|
||||
:y-min="yMin"
|
||||
:y-max="yMax"
|
||||
/>
|
||||
</template>
|
||||
@@ -11,8 +11,11 @@ import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
const props = defineProps({
|
||||
encounterId: { type: String, required: true },
|
||||
selectedId: { type: String, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const alertStore = useAlertStore()
|
||||
const settings = useSettingsStore()
|
||||
const { alerts, loading } = storeToRefs(alertStore)
|
||||
@@ -51,7 +54,9 @@ async function resolve(alertId) {
|
||||
<li
|
||||
v-for="alert in alerts"
|
||||
:key="alert.id"
|
||||
class="flex flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||
class="flex cursor-pointer flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
|
||||
@click="emit('select', alert)"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { useReplayControls } from '@/composables/useReplayControls'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
|
||||
const { isPaused, speed, progress, formattedTime, pause, resume, setSpeed } = useReplayControls()
|
||||
|
||||
defineProps({ alerts: { type: Array, default: () => [] } })
|
||||
const emit = defineEmits(['jump-to-alert'])
|
||||
|
||||
const speedPresets = [
|
||||
{ label: '1×', value: 1 },
|
||||
{ label: '60×', value: 60 },
|
||||
{ label: '360×', value: 360 },
|
||||
{ label: 'Instant', value: 0 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex w-full min-w-0 flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900 sm:flex-row sm:items-center">
|
||||
<Button variant="ghost" size="sm" @click="isPaused ? resume() : pause()">
|
||||
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
|
||||
{{ isPaused ? '▶' : '⏸' }}
|
||||
</Button>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||
:style="{ width: `${progress}%` }" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="min-w-16 text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||
{{ formattedTime }}
|
||||
</span>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="preset in speedPresets"
|
||||
:key="preset.value"
|
||||
class="rounded px-2 py-2 text-xs transition"
|
||||
:class="speed === preset.value
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
|
||||
@click="setSpeed(preset.value)"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="alerts.length" class="border-t border-gray-200 pt-4 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0 dark:border-gray-700">
|
||||
<Button variant="ghost" size="sm" @click="$emit('jump-to-alert')">
|
||||
Next Alert →
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user