fix:
No SOFA trend chart or organ-system timeline No GCS trend chart or component history No qSOFA history view
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import GcsHistory from '@/components/charts/GcsHistory.vue'
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Line: {
|
||||
name: 'Line',
|
||||
template: '<div class="mock-chart" />',
|
||||
props: ['data', 'options'],
|
||||
},
|
||||
}))
|
||||
|
||||
describe('GcsHistory', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||
})
|
||||
|
||||
const history = [
|
||||
{
|
||||
eyeScore: 4,
|
||||
verbalScore: 5,
|
||||
motorScore: 6,
|
||||
totalScore: 15,
|
||||
classification: 'MILD',
|
||||
calculatedAt: '2026-06-23T10:00:00Z',
|
||||
},
|
||||
{
|
||||
eyeScore: 2,
|
||||
verbalScore: 3,
|
||||
motorScore: 3,
|
||||
totalScore: 8,
|
||||
classification: 'SEVERE',
|
||||
calculatedAt: '2026-06-23T14:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
it('rendersTitleAndChart', () => {
|
||||
const wrapper = mount(GcsHistory, { props: { history } })
|
||||
expect(wrapper.text()).toContain('GCS Over Time')
|
||||
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sortsHistoryChronologically', () => {
|
||||
const wrapper = mount(GcsHistory, { props: { history: [...history].reverse() } })
|
||||
const chart = wrapper.findComponent({ name: 'Line' })
|
||||
const totals = chart.props('data').datasets.find(d => d.label === 'GCS Total').data
|
||||
expect(totals).toEqual([15, 8])
|
||||
})
|
||||
|
||||
it('includesComponentDatasets', () => {
|
||||
const wrapper = mount(GcsHistory, { props: { history } })
|
||||
const chart = wrapper.findComponent({ name: 'Line' })
|
||||
const labels = chart.props('data').datasets.map(d => d.label)
|
||||
expect(labels).toContain('Eye')
|
||||
expect(labels).toContain('Verbal')
|
||||
expect(labels).toContain('Motor')
|
||||
expect(labels).toContain('GCS Total')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Line: {
|
||||
name: 'Line',
|
||||
template: '<div class="mock-chart" />',
|
||||
props: ['data', 'options'],
|
||||
},
|
||||
}))
|
||||
|
||||
describe('QsofaHistory', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||
})
|
||||
|
||||
const history = [
|
||||
{
|
||||
activeCriteria: 1,
|
||||
criteria: { respRate: 24, systolicBp: null, avpu: null },
|
||||
screenAlertFired: false,
|
||||
evaluatedAt: '2026-06-23T10:00:00Z',
|
||||
},
|
||||
{
|
||||
activeCriteria: 2,
|
||||
criteria: { respRate: 24, systolicBp: 95, avpu: null },
|
||||
screenAlertFired: true,
|
||||
evaluatedAt: '2026-06-23T14:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
it('rendersTitleAndChart', () => {
|
||||
const wrapper = mount(QsofaHistory, { props: { history } })
|
||||
expect(wrapper.text()).toContain('qSOFA Screen Over Time')
|
||||
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sortsHistoryChronologically', () => {
|
||||
const wrapper = mount(QsofaHistory, { props: { history: [...history].reverse() } })
|
||||
const chart = wrapper.findComponent({ name: 'Line' })
|
||||
const counts = chart.props('data').datasets.find(d => d.label === 'Active criteria').data
|
||||
expect(counts).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('marksAlertFiredPoints', () => {
|
||||
const wrapper = mount(QsofaHistory, { props: { history } })
|
||||
const chart = wrapper.findComponent({ name: 'Line' })
|
||||
const total = chart.props('data').datasets.find(d => d.label === 'Active criteria')
|
||||
expect(total.pointStyle).toEqual(['circle', 'star'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SofaHistory from '@/components/charts/SofaHistory.vue'
|
||||
|
||||
vi.mock('vue-chartjs', () => ({
|
||||
Chart: {
|
||||
name: 'Chart',
|
||||
template: '<div class="mock-chart" />',
|
||||
props: ['type', 'data', 'options'],
|
||||
},
|
||||
}))
|
||||
|
||||
describe('SofaHistory', () => {
|
||||
beforeEach(() => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||
})
|
||||
const history = [
|
||||
{
|
||||
totalScore: 4,
|
||||
respiratoryScore: 1,
|
||||
coagulationScore: 1,
|
||||
liverScore: 0,
|
||||
cardiovascularScore: 1,
|
||||
cnsScore: 1,
|
||||
renalScore: 0,
|
||||
calculatedAt: '2026-06-23T10:00:00Z',
|
||||
},
|
||||
{
|
||||
totalScore: 8,
|
||||
respiratoryScore: 2,
|
||||
coagulationScore: 1,
|
||||
liverScore: 1,
|
||||
cardiovascularScore: 2,
|
||||
cnsScore: 1,
|
||||
renalScore: 1,
|
||||
calculatedAt: '2026-06-23T14:00:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
it('rendersTitleAndChart', () => {
|
||||
const wrapper = mount(SofaHistory, { props: { history } })
|
||||
expect(wrapper.text()).toContain('SOFA Score Over Time')
|
||||
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('sortsHistoryChronologically', () => {
|
||||
const wrapper = mount(SofaHistory, { props: { history: [...history].reverse() } })
|
||||
const chart = wrapper.findComponent({ name: 'Chart' })
|
||||
const totals = chart.props('data').datasets.find(d => d.label === 'SOFA Total').data
|
||||
expect(totals).toEqual([4, 8])
|
||||
})
|
||||
|
||||
it('includesOrganSystemDatasets', () => {
|
||||
const wrapper = mount(SofaHistory, { props: { history } })
|
||||
const chart = wrapper.findComponent({ name: 'Chart' })
|
||||
const labels = chart.props('data').datasets.map(d => d.label)
|
||||
expect(labels).toContain('Respiratory')
|
||||
expect(labels).toContain('Renal')
|
||||
expect(labels).toContain('SOFA Total')
|
||||
})
|
||||
})
|
||||
@@ -66,6 +66,45 @@ export async function fetchNews2History(encounterId, { limit = 100 } = {}) {
|
||||
return all
|
||||
}
|
||||
|
||||
export async function fetchGcsHistory(encounterId, { limit = 100 } = {}) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const page = await api.get(`/api/v1/encounters/${encounterId}/gcs/history?${params}`)
|
||||
all.push(...(page.items ?? []))
|
||||
cursor = page.hasMore ? page.nextCursor : null
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
export async function fetchQsofaHistory(encounterId, { limit = 100 } = {}) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const page = await api.get(`/api/v1/encounters/${encounterId}/qsofa/history?${params}`)
|
||||
all.push(...(page.items ?? []))
|
||||
cursor = page.hasMore ? page.nextCursor : null
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
export async function fetchSofaHistory(encounterId, { limit = 100 } = {}) {
|
||||
const all = []
|
||||
let cursor = null
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: String(limit) })
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const page = await api.get(`/api/v1/encounters/${encounterId}/sofa/history?${params}`)
|
||||
all.push(...(page.items ?? []))
|
||||
cursor = page.hasMore ? page.nextCursor : null
|
||||
} while (cursor)
|
||||
return all
|
||||
}
|
||||
|
||||
export async function fetchMedications(encounterId, { pageSize = 50, since } = {}) {
|
||||
const all = []
|
||||
let page = 1
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef, markRaw } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||
import { formatTime } from '@/composables/chartFormat'
|
||||
|
||||
ChartJS.register(...registerables)
|
||||
|
||||
const props = defineProps({ history: { type: Array, required: true } })
|
||||
|
||||
const COMPONENT_DATASETS = [
|
||||
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
|
||||
{ label: 'Verbal', key: 'verbalScore', color: '#8b5cf6' },
|
||||
{ label: 'Motor', key: 'motorScore', color: '#06b6d4' },
|
||||
]
|
||||
|
||||
function gcsRiskBorder(score) {
|
||||
if (score <= 8) return '#dc2626'
|
||||
if (score <= 12) return '#f59e0b'
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
function gcsSeverityLabel(score) {
|
||||
if (score <= 8) return 'Severe (3–8)'
|
||||
if (score <= 12) return 'Moderate (9–12)'
|
||||
return 'Mild (13–15)'
|
||||
}
|
||||
|
||||
const sortedHistory = computed(() =>
|
||||
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
|
||||
)
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sorted = sortedHistory.value
|
||||
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||
|
||||
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
|
||||
label,
|
||||
data: sorted.map(h => h[key] ?? 0),
|
||||
borderColor: color,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1.5,
|
||||
borderDash: [4, 3],
|
||||
pointRadius: 2,
|
||||
tension: 0.2,
|
||||
order: 2,
|
||||
}))
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'GCS Total',
|
||||
data: sorted.map(h => h.totalScore),
|
||||
borderColor: '#111827',
|
||||
backgroundColor: sorted.map(h => {
|
||||
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
|
||||
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
|
||||
return 'rgba(34, 197, 94, 0.15)'
|
||||
}),
|
||||
pointBackgroundColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
|
||||
pointBorderColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
|
||||
pointRadius: 4,
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
order: 1,
|
||||
},
|
||||
...componentDatasets,
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = shallowRef(markRaw({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: {
|
||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||
},
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
y: {
|
||||
min: 3,
|
||||
max: 15,
|
||||
title: { display: true, text: 'GCS Score' },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer(items) {
|
||||
const total = items.find(i => i.dataset.label === 'GCS Total')
|
||||
if (!total) return ''
|
||||
return gcsSeverityLabel(total.parsed.y)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</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-1 text-sm font-medium text-gray-700 dark:text-gray-300">GCS Over Time</h3>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Total score with Eye, Verbal, and Motor components.
|
||||
<span class="text-green-600 dark:text-green-400">Mild 13–15</span>,
|
||||
<span class="text-amber-600 dark:text-amber-400">Moderate 9–12</span>,
|
||||
<span class="text-red-600 dark:text-red-400">Severe 3–8</span>.
|
||||
</p>
|
||||
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef, markRaw } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||
import { formatTime } from '@/composables/chartFormat'
|
||||
|
||||
ChartJS.register(...registerables)
|
||||
|
||||
const props = defineProps({ history: { type: Array, required: true } })
|
||||
|
||||
const CRITERION_LABELS = [
|
||||
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
|
||||
{ label: 'Systolic BP', key: 'systolicBp', color: '#8b5cf6' },
|
||||
{ label: 'Altered mentation', key: 'avpu', color: '#ec4899' },
|
||||
]
|
||||
|
||||
function criteriaColor(count) {
|
||||
if (count >= 2) return '#dc2626'
|
||||
if (count === 1) return '#f59e0b'
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
function criteriaLabel(count) {
|
||||
if (count >= 2) return 'Screen positive (≥2) — consider SOFA labs'
|
||||
if (count === 1) return '1 criterion met'
|
||||
return 'Screen negative'
|
||||
}
|
||||
|
||||
function criterionMet(value) {
|
||||
return value != null ? 1 : 0
|
||||
}
|
||||
|
||||
const sortedHistory = computed(() =>
|
||||
[...props.history].sort((a, b) => new Date(a.evaluatedAt) - new Date(b.evaluatedAt)),
|
||||
)
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sorted = sortedHistory.value
|
||||
const labels = sorted.map(h => formatTime(h.evaluatedAt))
|
||||
|
||||
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
|
||||
label,
|
||||
data: sorted.map(h => criterionMet(h.criteria?.[key])),
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}33`,
|
||||
borderWidth: 1,
|
||||
borderDash: [3, 3],
|
||||
pointRadius: 0,
|
||||
stepped: true,
|
||||
yAxisID: 'criteria',
|
||||
order: 2,
|
||||
}))
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Active criteria',
|
||||
data: sorted.map(h => h.activeCriteria),
|
||||
borderColor: '#111827',
|
||||
backgroundColor: sorted.map(h => {
|
||||
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
|
||||
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
|
||||
return 'rgba(34, 197, 94, 0.15)'
|
||||
}),
|
||||
pointBackgroundColor: sorted.map(h => criteriaColor(h.activeCriteria)),
|
||||
pointBorderColor: sorted.map(h => criteriaColor(h.activeCriteria)),
|
||||
pointRadius: sorted.map(h => (h.screenAlertFired ? 7 : 4)),
|
||||
pointStyle: sorted.map(h => (h.screenAlertFired ? 'star' : 'circle')),
|
||||
borderWidth: 2,
|
||||
fill: true,
|
||||
tension: 0.2,
|
||||
yAxisID: 'count',
|
||||
order: 1,
|
||||
},
|
||||
...criterionDatasets,
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = shallowRef(markRaw({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: {
|
||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||
},
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
count: {
|
||||
type: 'linear',
|
||||
position: 'left',
|
||||
min: 0,
|
||||
max: 3,
|
||||
ticks: { stepSize: 1 },
|
||||
title: { display: true, text: 'Criteria count' },
|
||||
},
|
||||
criteria: {
|
||||
type: 'linear',
|
||||
position: 'right',
|
||||
min: 0,
|
||||
max: 1,
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer(items) {
|
||||
const total = items.find(i => i.dataset.label === 'Active criteria')
|
||||
if (!total) return ''
|
||||
const idx = total.dataIndex
|
||||
const entry = sortedHistory.value[idx]
|
||||
const lines = [criteriaLabel(total.parsed.y)]
|
||||
if (entry?.screenAlertFired) lines.push('qSOFA screen alert fired')
|
||||
return lines.join(' · ')
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</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-1 text-sm font-medium text-gray-700 dark:text-gray-300">qSOFA Screen Over Time</h3>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Criteria count (0–3); star markers indicate when a screen alert fired.
|
||||
<span class="text-green-600 dark:text-green-400">0</span>,
|
||||
<span class="text-amber-600 dark:text-amber-400">1</span>,
|
||||
<span class="text-red-600 dark:text-red-400">≥2</span>.
|
||||
</p>
|
||||
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef, markRaw } from 'vue'
|
||||
import { Chart } from 'vue-chartjs'
|
||||
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||
import { formatTime } from '@/composables/chartFormat'
|
||||
|
||||
ChartJS.register(...registerables)
|
||||
|
||||
const props = defineProps({ history: { type: Array, required: true } })
|
||||
|
||||
const ORGAN_DATASETS = [
|
||||
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
|
||||
{ label: 'Coagulation', key: 'coagulationScore', color: '#8b5cf6' },
|
||||
{ label: 'Liver', key: 'liverScore', color: '#f59e0b' },
|
||||
{ label: 'Cardiovascular', key: 'cardiovascularScore', color: '#ef4444' },
|
||||
{ label: 'CNS', key: 'cnsScore', color: '#ec4899' },
|
||||
{ label: 'Renal', key: 'renalScore', color: '#06b6d4' },
|
||||
]
|
||||
|
||||
function sofaRiskBorder(score) {
|
||||
if (score >= 10) return '#dc2626'
|
||||
if (score >= 6) return '#f59e0b'
|
||||
return '#22c55e'
|
||||
}
|
||||
|
||||
const sortedHistory = computed(() =>
|
||||
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
|
||||
)
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sorted = sortedHistory.value
|
||||
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||
|
||||
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
|
||||
type: 'line',
|
||||
label,
|
||||
data: sorted.map(h => h[key] ?? 0),
|
||||
backgroundColor: `${color}66`,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
fill: true,
|
||||
stack: 'organs',
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
order: 2,
|
||||
}))
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
...organDatasets,
|
||||
{
|
||||
type: 'line',
|
||||
label: 'SOFA Total',
|
||||
data: sorted.map(h => h.totalScore),
|
||||
borderColor: '#111827',
|
||||
backgroundColor: 'transparent',
|
||||
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||
pointRadius: 4,
|
||||
borderWidth: 2,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = shallowRef(markRaw({
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
animation: {
|
||||
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||
},
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: {
|
||||
stacked: true,
|
||||
min: 0,
|
||||
max: 24,
|
||||
title: { display: true, text: 'SOFA Score' },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { boxWidth: 12, font: { size: 11 } },
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
footer(items) {
|
||||
const total = items.find(i => i.dataset.label === 'SOFA Total')
|
||||
if (!total) return ''
|
||||
const score = total.parsed.y
|
||||
if (score >= 10) return 'Risk: High (≥10)'
|
||||
if (score >= 6) return 'Risk: Moderate (6–9)'
|
||||
return 'Risk: Low (0–5)'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
</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-1 text-sm font-medium text-gray-700 dark:text-gray-300">SOFA Score Over Time</h3>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Stacked areas show per-organ contributions; line shows total score.
|
||||
<span class="text-green-600 dark:text-green-400">0–5</span>,
|
||||
<span class="text-amber-600 dark:text-amber-400">6–9</span>,
|
||||
<span class="text-red-600 dark:text-red-400">10+</span>.
|
||||
</p>
|
||||
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||
<Chart type="line" :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,6 +16,9 @@ import OrdersPanel from '@/components/patient/OrdersPanel.vue'
|
||||
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
|
||||
import TrendsGrid from '@/components/charts/TrendsGrid.vue'
|
||||
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 ReplayControls from '@/components/replay/ReplayControls.vue'
|
||||
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
@@ -47,6 +50,9 @@ const encounter = ref(null)
|
||||
const loading = ref(true)
|
||||
const observations = ref([])
|
||||
const news2History = ref([])
|
||||
const gcsHistory = ref([])
|
||||
const qsofaHistory = ref([])
|
||||
const sofaHistory = ref([])
|
||||
const medications = ref([])
|
||||
const sepsisBundle = ref(null)
|
||||
const orders = ref([])
|
||||
@@ -65,10 +71,25 @@ const replayNews2History = computed(() =>
|
||||
news2History.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||
)
|
||||
|
||||
const replayGcsHistory = computed(() =>
|
||||
gcsHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||
)
|
||||
|
||||
const replayQsofaHistory = computed(() =>
|
||||
qsofaHistory.value.filter(h => isAtOrBefore(h.evaluatedAt)),
|
||||
)
|
||||
|
||||
const replaySofaHistory = computed(() =>
|
||||
sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||
)
|
||||
|
||||
function collectScenarioTimes() {
|
||||
return [
|
||||
...observations.value.map(o => new Date(o.recordedAt).getTime()),
|
||||
...news2History.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()),
|
||||
...sofaHistory.value.map(h => new Date(h.calculatedAt).getTime()),
|
||||
...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
|
||||
].filter(Number.isFinite)
|
||||
}
|
||||
@@ -88,10 +109,13 @@ async function loadAll() {
|
||||
const id = route.params.encounterId
|
||||
loading.value = true
|
||||
try {
|
||||
const [enc, obs, history, meds, bundle, ord] = await Promise.all([
|
||||
const [enc, obs, history, gcsHist, qsofaHist, sofaHist, meds, bundle, ord] = await Promise.all([
|
||||
encountersApi.fetchEncounter(id),
|
||||
encountersApi.fetchObservations(id),
|
||||
clinicalApi.fetchNews2History(id).catch(() => []),
|
||||
clinicalApi.fetchGcsHistory(id).catch(() => []),
|
||||
clinicalApi.fetchQsofaHistory(id).catch(() => []),
|
||||
clinicalApi.fetchSofaHistory(id).catch(() => []),
|
||||
clinicalApi.fetchMedications(id).catch(() => []),
|
||||
clinicalApi.fetchSepsisBundle(id),
|
||||
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
||||
@@ -100,6 +124,9 @@ async function loadAll() {
|
||||
encounter.value = enc
|
||||
observations.value = obs
|
||||
news2History.value = history
|
||||
gcsHistory.value = gcsHist
|
||||
qsofaHistory.value = qsofaHist
|
||||
sofaHistory.value = sofaHist
|
||||
medications.value = meds
|
||||
sepsisBundle.value = bundle
|
||||
orders.value = ord.items ?? ord
|
||||
@@ -147,7 +174,7 @@ watch(openAlerts, (list) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch([observations, news2History, alerts], syncReplayBounds, { deep: true })
|
||||
watch([observations, news2History, gcsHistory, qsofaHistory, sofaHistory, alerts], syncReplayBounds, { deep: true })
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopPlayback()
|
||||
@@ -168,7 +195,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<ScoresPanel />
|
||||
<div class="space-y-4">
|
||||
<ScoresPanel />
|
||||
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
||||
</div>
|
||||
<VitalsPanel :observations="replayObservations" />
|
||||
<AlertsList
|
||||
:encounter-id="route.params.encounterId"
|
||||
@@ -184,7 +214,10 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
||||
<div class="space-y-4">
|
||||
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
||||
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
|
||||
</div>
|
||||
<OrdersPanel :orders="orders" />
|
||||
</div>
|
||||
|
||||
@@ -197,6 +230,7 @@ onBeforeUnmount(() => {
|
||||
<div id="clinical-review" class="w-full min-w-0 space-y-8">
|
||||
<TrendsGrid :observations="replayObservations" />
|
||||
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
||||
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
|
||||
<ReplayControls
|
||||
:alerts="openAlerts"
|
||||
:is-paused="isPaused"
|
||||
|
||||
Reference in New Issue
Block a user