feature: Self-Service Clinical Testing Sessions
CI / backend (push) Successful in 8m52s
CI / frontend (push) Failing after 1m39s

This commit is contained in:
voltsrage
2026-08-06 04:03:04 +08:00
parent 1d28880920
commit 80b009fd23
41 changed files with 4922 additions and 123 deletions
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
const summary = {
simulatedPatients: 12,
encounters: 12,
observations: 340,
alerts: 28,
activeRuns: 0,
}
describe('ResetWardPanel', () => {
it('displaysCounts', () => {
const wrapper = mount(ResetWardPanel, { props: { summary } })
expect(wrapper.text()).toContain('12 simulated patients')
expect(wrapper.text()).toContain('340 observations')
expect(wrapper.text()).toContain('28 alerts')
})
it('requiresTypedConfirmation', async () => {
const wrapper = mount(ResetWardPanel, {
props: { summary },
attachTo: document.body,
})
await wrapper.findAll('button').find(b => b.text() === 'Reset ward').trigger('click')
await wrapper.vm.$nextTick()
expect(document.body.textContent).toContain('Type')
expect(document.body.textContent).toContain('RESET')
const input = document.body.querySelector('input')
expect(input).toBeTruthy()
input.value = 'RESET'
input.dispatchEvent(new Event('input'))
await wrapper.vm.$nextTick()
// Confirm button is the danger button inside the modal (last Reset ward).
const buttons = [...document.body.querySelectorAll('button')]
.filter(b => b.textContent.trim() === 'Reset ward')
const confirmBtn = buttons.at(-1)
expect(confirmBtn.disabled).toBe(false)
confirmBtn.click()
await wrapper.vm.$nextTick()
expect(wrapper.emitted('purge')).toHaveLength(1)
wrapper.unmount()
})
it('blocksWhileRunsActive', () => {
const wrapper = mount(ResetWardPanel, {
props: {
summary: { ...summary, activeRuns: 2 },
},
})
expect(wrapper.text()).toContain('Reset is blocked')
expect(wrapper.text()).toContain('Stop all runs')
const resetBtn = wrapper.findAll('button').find(b => b.text() === 'Reset ward')
expect(resetBtn.attributes('disabled')).toBeDefined()
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SessionCard from '@/components/simulation/SessionCard.vue'
const baseSession = {
id: 'session-b-alert-quality',
name: 'Session B — Alert quality deep dive',
goal: 'Compare alert types across contrasting scenarios.',
estimatedMinutes: 50,
defaultSpeed: 120,
scenarios: [
{ id: 'medication-false-alarm-01', name: 'Medication False Alarm' },
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
{ id: 'respiratory-failure-asthma-01', name: 'Respiratory Failure' },
],
}
describe('SessionCard', () => {
it('rendersPresetSummary', () => {
const wrapper = mount(SessionCard, {
props: { session: baseSession },
})
expect(wrapper.text()).toContain(baseSession.name)
expect(wrapper.text()).toContain(baseSession.goal)
expect(wrapper.text()).toContain('~50 min')
expect(wrapper.text()).toContain('3 patients')
expect(wrapper.text()).toContain('Medication False Alarm')
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
})
it('disablesStartWhenInsufficientCapacity', async () => {
const wrapper = mount(SessionCard, {
props: {
session: baseSession,
activeRunCount: 0,
maxConcurrentRuns: 2,
},
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
expect(startBtn.attributes('disabled')).toBeDefined()
expect(startBtn.attributes('title')).toMatch(/Needs 3 run slots/)
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toBeUndefined()
})
it('emitsStartWhenCapacityAvailable', async () => {
const wrapper = mount(SessionCard, {
props: {
session: baseSession,
activeRunCount: 0,
maxConcurrentRuns: 8,
},
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start session')
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toEqual([['session-b-alert-quality']])
})
})
@@ -5,24 +5,36 @@ import SimulationControlView from '@/views/SimulationControlView.vue'
const {
fetchScenarios,
fetchSessions,
fetchRuns,
fetchSimulationConfig,
fetchDataSummary,
startRun,
startSession,
stopRun,
purgeData,
} = vi.hoisted(() => ({
fetchScenarios: vi.fn(),
fetchSessions: vi.fn(),
fetchRuns: vi.fn(),
fetchSimulationConfig: vi.fn(),
fetchDataSummary: vi.fn(),
startRun: vi.fn(),
startSession: vi.fn(),
stopRun: vi.fn(),
purgeData: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchScenarios,
fetchSessions,
fetchRuns,
fetchSimulationConfig,
fetchDataSummary,
startRun,
startSession,
stopRun,
purgeData,
fetchRun: vi.fn(),
}))
@@ -30,6 +42,10 @@ vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters: vi.fn() }),
}))
vi.mock('@/stores/alerts', () => ({
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
}))
const scenarios = [
{
id: 'uti-sepsis-elderly-01',
@@ -51,12 +67,34 @@ const scenarios = [
},
]
const sessions = [
{
id: 'session-a-orientation',
name: 'Session A — Quick orientation',
goal: 'Learn the interface.',
estimatedMinutes: 25,
defaultSpeed: 60,
scenarios: [
{ id: 'stable-baseline-01', name: 'Stable Baseline' },
{ id: 'uti-sepsis-elderly-01', name: 'UTI Sepsis Elderly' },
],
},
]
describe('SimulationControlView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
fetchScenarios.mockResolvedValue(scenarios)
fetchSessions.mockResolvedValue(sessions)
fetchRuns.mockResolvedValue([])
fetchDataSummary.mockResolvedValue({
simulatedPatients: 0,
encounters: 0,
observations: 0,
alerts: 0,
activeRuns: 0,
})
fetchSimulationConfig.mockResolvedValue({
enabled: true,
maxSpeed: 600,
@@ -64,8 +102,30 @@ describe('SimulationControlView', () => {
})
})
it('defaultsToSessionsTab', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A — Quick orientation'))
expect(wrapper.text()).toContain('Testing sessions')
expect(wrapper.text()).not.toContain('Scenario catalogue')
})
it('switchesToScenariosTab', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
await scenariosTab.trigger('click')
await vi.waitFor(() => expect(wrapper.text()).toContain('Scenario catalogue'))
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
})
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
await scenariosTab.trigger('click')
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
@@ -78,9 +138,12 @@ describe('SimulationControlView', () => {
expect(instant.text()).toMatch(/1 minute for selected scenario/)
})
it('filtersCatalogueByTag', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
await scenariosTab.trigger('click')
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
@@ -93,6 +156,11 @@ describe('SimulationControlView', () => {
it('showsEmptyStateWhenCatalogueMissing', async () => {
fetchScenarios.mockResolvedValue([])
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Session A'))
const scenariosTab = wrapper.findAll('button').find(b => b.text() === 'Scenarios')
await scenariosTab.trigger('click')
await vi.waitFor(() =>
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
)
@@ -2,7 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useAlertQualityStore } from '@/stores/alertQuality'
vi.mock('@/api/alertQuality', () => ({
const {
submitAlertFeedback,
fetchQualityMetricsSummary,
fetchQualityMetrics,
fetchQualityFeedback,
} = vi.hoisted(() => ({
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
totalAlerts: 10, totalFeedback: 4,
@@ -13,12 +18,54 @@ vi.mock('@/api/alertQuality', () => ({
fetchQualityMetrics: vi.fn().mockResolvedValue({
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
}),
fetchQualityFeedback: vi.fn().mockResolvedValue({
items: [
{
id: '1',
alertId: 'a1',
alertType: 'NEWS2_WARNING',
feedbackType: 'USEFUL',
comment: null,
createdAt: '2026-06-23T00:00:00Z',
scenarioId: 'uti-sepsis-elderly-01',
sessionId: 'session-b-alert-quality',
},
{
id: '2',
alertId: 'a2',
alertType: 'NEWS2_WARNING',
feedbackType: 'FALSE_POSITIVE',
comment: null,
createdAt: '2026-06-23T01:00:00Z',
scenarioId: 'medication-false-alarm-01',
sessionId: 'session-b-alert-quality',
},
{
id: '3',
alertId: 'a3',
alertType: 'NEWS2_WARNING',
feedbackType: 'FALSE_POSITIVE',
comment: null,
createdAt: '2026-06-23T02:00:00Z',
scenarioId: 'medication-false-alarm-01',
sessionId: null,
},
],
}),
}))
vi.mock('@/api/alertQuality', () => ({
submitAlertFeedback,
fetchQualityMetricsSummary,
fetchQualityMetrics,
fetchQualityFeedback,
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
}))
describe('alertQuality store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('loads dashboard summary and snapshots', async () => {
@@ -34,4 +81,39 @@ describe('alertQuality store', () => {
await store.submitFeedback('alert-1', 'useful', 'test note')
expect(store.getFeedback('alert-1').rating).toBe('useful')
})
})
it('groupsFeedbackByScenario', async () => {
const store = useAlertQualityStore()
await store.loadDashboard()
expect(store.scenarioOptions).toEqual([
'medication-false-alarm-01',
'uti-sepsis-elderly-01',
])
const med = store.byScenario.find(s => s.scenarioId === 'medication-false-alarm-01')
expect(med.totalFeedback).toBe(2)
expect(med.falsePositiveRate).toBe(1)
expect(med.usefulRate).toBe(0)
})
it('passesScenarioFilterToFeedbackApi', async () => {
const store = useAlertQualityStore()
store.setScenarioFilter('uti-sepsis-elderly-01')
await store.loadDashboard()
expect(fetchQualityFeedback).toHaveBeenCalledWith(
expect.objectContaining({ scenarioId: 'uti-sepsis-elderly-01' }),
)
})
it('csvIncludesScenarioColumns', async () => {
const store = useAlertQualityStore()
await store.loadDashboard()
const csv = store.buildCsv()
expect(csv.split('\n')[0]).toContain('scenarioId')
expect(csv.split('\n')[0]).toContain('sessionId')
expect(csv).toContain('uti-sepsis-elderly-01')
expect(csv).toContain('session-b-alert-quality')
})
})
@@ -6,25 +6,43 @@ import { useSimulationStore } from '@/stores/simulation'
const {
fetchSimulationConfig,
fetchScenarios,
fetchSessions,
fetchRuns,
startRun,
startSession,
stopRun,
fetchDataSummary,
purgeData,
loadEncounters,
} = vi.hoisted(() => ({
fetchSimulationConfig: vi.fn(),
fetchScenarios: vi.fn(),
fetchSessions: vi.fn(),
fetchRuns: vi.fn(),
startRun: vi.fn(),
startSession: vi.fn(),
stopRun: vi.fn(),
fetchDataSummary: vi.fn().mockResolvedValue({
simulatedPatients: 0,
encounters: 0,
observations: 0,
alerts: 0,
activeRuns: 0,
}),
purgeData: vi.fn(),
loadEncounters: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchSimulationConfig,
fetchScenarios,
fetchSessions,
fetchRuns,
startRun,
startSession,
stopRun,
fetchDataSummary,
purgeData,
fetchRun: vi.fn(),
}))
@@ -32,6 +50,10 @@ vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters }),
}))
vi.mock('@/stores/alerts', () => ({
useAlertStore: () => ({ loadGlobalAlerts: vi.fn() }),
}))
function runningRun(overrides = {}) {
return {
runId: 'run-1',
@@ -34,4 +34,13 @@ export function fetchQualityMetrics({ alertType, from, to } = {}) {
if (to) params.set('to', to)
const qs = params.toString()
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
}
export function fetchQualityFeedback({ scenarioId, from, to } = {}) {
const params = new URLSearchParams()
if (scenarioId) params.set('scenarioId', scenarioId)
if (from) params.set('from', from)
if (to) params.set('to', to)
const qs = params.toString()
return api.get(`/api/v1/alerts/quality-metrics/feedback${qs ? `?${qs}` : ''}`)
}
@@ -2,7 +2,12 @@ import { api } from './client'
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
export const fetchSessions = () => api.get('/api/v1/simulation/sessions')
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
export const startSession = (sessionId, body = {}) =>
api.post(`/api/v1/simulation/sessions/${sessionId}/start`, body)
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
export const fetchDataSummary = () => api.get('/api/v1/simulation/data/summary')
export const purgeData = () => api.delete('/api/v1/simulation/data')
@@ -0,0 +1,173 @@
<script setup>
import { computed, ref, watch } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import Modal from '@/components/ui/Modal.vue'
const props = defineProps({
summary: {
type: Object,
default: () => ({
simulatedPatients: 0,
encounters: 0,
observations: 0,
alerts: 0,
activeRuns: 0,
}),
},
purging: { type: Boolean, default: false },
stoppingAll: { type: Boolean, default: false },
})
const emit = defineEmits(['purge', 'stop-all'])
const confirmOpen = ref(false)
const confirmText = ref('')
const hasActiveRuns = computed(() => (props.summary?.activeRuns ?? 0) > 0)
const patientCount = computed(() => props.summary?.simulatedPatients ?? 0)
const observationCount = computed(() => props.summary?.observations ?? 0)
const alertCount = computed(() => props.summary?.alerts ?? 0)
const countsSentence = computed(() => {
const patients = patientCount.value
const observations = observationCount.value
const alerts = alertCount.value
return [
`${patients} simulated patient${patients === 1 ? '' : 's'}`,
`${observations} observation${observations === 1 ? '' : 's'}`,
`${alerts} alert${alerts === 1 ? '' : 's'}`,
].join(', ')
})
const confirmEnabled = computed(() =>
confirmText.value.trim().toUpperCase() === 'RESET' && !props.purging,
)
watch(confirmOpen, (open) => {
if (open) confirmText.value = ''
})
function openConfirm() {
if (hasActiveRuns.value) return
confirmOpen.value = true
}
function closeConfirm() {
confirmOpen.value = false
confirmText.value = ''
}
function onConfirm() {
if (!confirmEnabled.value) return
emit('purge')
}
function onStopAll() {
emit('stop-all')
}
defineExpose({ closeConfirm })
</script>
<template>
<Card padding="md">
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Reset ward
</h2>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">
<template v-if="patientCount > 0">
{{ patientCount }} simulated patient{{ patientCount === 1 ? '' : 's' }}
currently on the ward
<span class="text-gray-500 dark:text-gray-400">
({{ observationCount }} observations, {{ alertCount }} alerts).
</span>
</template>
<template v-else>
No simulated patients on the ward.
</template>
</p>
<p
v-if="hasActiveRuns"
class="mt-2 text-sm text-amber-800 dark:text-amber-200"
role="status"
>
Reset is blocked while {{ summary.activeRuns }} run{{ summary.activeRuns === 1 ? '' : 's' }}
{{ summary.activeRuns === 1 ? 'is' : 'are' }} still active. Stop them first.
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button
v-if="hasActiveRuns"
variant="secondary"
size="sm"
:disabled="stoppingAll"
@click="onStopAll"
>
{{ stoppingAll ? 'Stopping…' : 'Stop all runs' }}
</Button>
<Button
variant="danger"
size="sm"
:disabled="hasActiveRuns || purging || patientCount === 0"
:title="hasActiveRuns
? 'Stop all active runs before resetting the ward.'
: patientCount === 0
? 'Nothing to reset.'
: undefined"
@click="openConfirm"
>
Reset ward
</Button>
</div>
</div>
<Modal
:open="confirmOpen"
title="Reset simulated ward"
title-id="reset-ward-title"
@close="closeConfirm"
>
<div class="space-y-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
This will permanently delete
<span class="font-medium text-gray-900 dark:text-white">{{ countsSentence }}</span>.
</p>
<p class="text-sm text-gray-700 dark:text-gray-300">
Alert feedback for those patients is deleted with them. Non-simulated patients are not affected.
</p>
<label class="block">
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Type <kbd class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-xs dark:bg-gray-700">RESET</kbd>
to confirm
</span>
<input
v-model="confirmText"
type="text"
autocomplete="off"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
placeholder="RESET"
>
</label>
<div class="flex justify-end gap-2">
<Button variant="secondary" size="sm" :disabled="purging" @click="closeConfirm">
Cancel
</Button>
<Button
variant="danger"
size="sm"
:disabled="!confirmEnabled"
@click="onConfirm"
>
{{ purging ? 'Resetting…' : 'Reset ward' }}
</Button>
</div>
</div>
</Modal>
</Card>
</template>
@@ -0,0 +1,90 @@
<script setup>
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
const props = defineProps({
session: { type: Object, required: true },
activeRunCount: { type: Number, default: 0 },
maxConcurrentRuns: { type: Number, default: 0 },
starting: { type: Boolean, default: false },
})
const emit = defineEmits(['start'])
const scenarioCount = computed(() => props.session.scenarios?.length ?? 0)
const summaryLabel = computed(() => {
const minutes = props.session.estimatedMinutes
const patients = scenarioCount.value
const timePart = minutes != null ? `~${minutes} min` : null
const patientPart = `${patients} patient${patients === 1 ? '' : 's'}`
return [timePart, patientPart].filter(Boolean).join(' · ')
})
const scenarioNames = computed(() =>
(props.session.scenarios ?? []).map(s => s.name || s.id),
)
const insufficientCapacity = computed(() => {
const max = props.maxConcurrentRuns
if (!max) return false
return props.activeRunCount + scenarioCount.value > max
})
const startDisabled = computed(() =>
props.starting || insufficientCapacity.value || scenarioCount.value === 0,
)
const disabledReason = computed(() => {
if (insufficientCapacity.value) {
const need = scenarioCount.value
const free = Math.max(0, props.maxConcurrentRuns - props.activeRunCount)
return `Needs ${need} run slot${need === 1 ? '' : 's'} but only ${free} available (max ${props.maxConcurrentRuns}). Stop active runs or reset the ward first.`
}
return undefined
})
function onStart() {
if (startDisabled.value) return
emit('start', props.session.id)
}
</script>
<template>
<Card padding="md" class="flex h-full flex-col transition hover:border-gray-300 dark:hover:border-gray-600">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ session.name }}
</h3>
<p
v-if="session.goal"
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
>
{{ session.goal }}
</p>
<p class="mt-3 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ summaryLabel }}
</p>
<ul
v-if="scenarioNames.length"
class="mt-3 list-inside list-disc space-y-1 text-sm text-gray-700 dark:text-gray-300"
>
<li v-for="name in scenarioNames" :key="name">{{ name }}</li>
</ul>
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
<Button
class="w-full"
size="sm"
:disabled="startDisabled"
:title="disabledReason"
@click="onStart"
>
Start session
</Button>
</div>
</Card>
</template>
+109 -2
View File
@@ -4,13 +4,24 @@ import {
submitAlertFeedback,
fetchQualityMetricsSummary,
fetchQualityMetrics,
fetchQualityFeedback,
} from '@/api/alertQuality'
function csvEscape(value) {
if (value == null) return ''
const s = String(value)
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`
return s
}
export const useAlertQualityStore = defineStore('alertQuality', () => {
const summary = ref(null)
const snapshots = ref([])
const feedbackRows = ref([])
const availableScenarioIds = ref([])
const periodDays = ref(7)
const selectedAlertType = ref(null)
const selectedScenarioId = ref(null)
const loading = ref(false)
const error = ref(null)
@@ -52,6 +63,35 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
return map
})
const scenarioOptions = computed(() => availableScenarioIds.value)
const byScenario = computed(() => {
const map = {}
for (const row of feedbackRows.value) {
const key = row.scenarioId || '(non-simulated)'
if (!map[key]) {
map[key] = {
scenarioId: row.scenarioId || null,
totalFeedback: 0,
useful: 0,
falsePositive: 0,
wouldAct: 0,
}
}
const bucket = map[key]
bucket.totalFeedback += 1
if (row.feedbackType === 'USEFUL') bucket.useful += 1
if (row.feedbackType === 'FALSE_POSITIVE') bucket.falsePositive += 1
if (row.feedbackType === 'WOULD_ACT') bucket.wouldAct += 1
}
return Object.values(map).map((b) => ({
...b,
usefulRate: b.totalFeedback ? b.useful / b.totalFeedback : 0,
falsePositiveRate: b.totalFeedback ? b.falsePositive / b.totalFeedback : 0,
wouldActRate: b.totalFeedback ? b.wouldAct / b.totalFeedback : 0,
})).sort((a, b) => (a.scenarioId || '').localeCompare(b.scenarioId || ''))
})
function periodRange() {
const to = new Date().toISOString()
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
@@ -63,16 +103,27 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
error.value = null
try {
const { from, to } = periodRange()
const [sum, list] = await Promise.all([
const [sum, list, feedback] = await Promise.all([
fetchQualityMetricsSummary(from, to),
fetchQualityMetrics({
alertType: selectedAlertType.value,
from,
to,
}),
fetchQualityFeedback({
scenarioId: selectedScenarioId.value,
from,
to,
}),
])
summary.value = sum
snapshots.value = list.items ?? []
feedbackRows.value = feedback.items ?? []
if (!selectedScenarioId.value) {
availableScenarioIds.value = [...new Set(
feedbackRows.value.map(r => r.scenarioId).filter(Boolean),
)].sort()
}
} catch (e) {
error.value = e.message
} finally {
@@ -103,19 +154,75 @@ export const useAlertQualityStore = defineStore('alertQuality', () => {
selectedAlertType.value = type
}
function setScenarioFilter(scenarioId) {
selectedScenarioId.value = scenarioId || null
}
function buildCsv() {
const includeScenario = feedbackRows.value.some(
r => Object.prototype.hasOwnProperty.call(r, 'scenarioId'),
)
const headers = [
'feedbackId',
'alertId',
'alertType',
'feedbackType',
'comment',
'createdAt',
]
if (includeScenario) {
headers.push('scenarioId', 'sessionId')
}
const lines = [headers.join(',')]
for (const row of feedbackRows.value) {
const cols = [
row.id,
row.alertId,
row.alertType,
row.feedbackType,
row.comment,
row.createdAt,
]
if (includeScenario) {
cols.push(row.scenarioId ?? '', row.sessionId ?? '')
}
lines.push(cols.map(csvEscape).join(','))
}
return lines.join('\n')
}
function exportCsv() {
const csv = buildCsv()
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `alert-quality-feedback-${periodDays.value}d.csv`
a.click()
URL.revokeObjectURL(url)
}
return {
summary,
snapshots,
feedbackRows,
periodDays,
selectedAlertType,
selectedScenarioId,
loading,
error,
summaryStats,
byAlertType,
byScenario,
scenarioOptions,
loadDashboard,
submitFeedback,
getFeedback,
setPeriodDays,
setAlertTypeFilter,
setScenarioFilter,
buildCsv,
exportCsv,
}
})
})
@@ -3,11 +3,16 @@ import { ref, computed, watch } from 'vue'
import {
fetchSimulationConfig,
fetchScenarios,
fetchSessions,
fetchRuns,
startRun,
startSession,
stopRun,
fetchDataSummary,
purgeData,
} from '@/api/simulation'
import { useWardStore } from '@/stores/ward'
import { useAlertStore } from '@/stores/alerts'
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
const POLL_INTERVAL_MS = 2_000
@@ -21,10 +26,20 @@ export const useSimulationStore = defineStore('simulation', () => {
const maxSpeed = ref(null)
const maxConcurrentRuns = ref(0)
const scenarios = ref([])
const sessions = ref([])
const runs = ref([])
const dataSummary = ref({
simulatedPatients: 0,
encounters: 0,
observations: 0,
alerts: 0,
activeRuns: 0,
})
const loading = ref(false)
const error = ref(null)
const successMessage = ref(null)
const starting = ref(false)
const purging = ref(false)
let pollTimer = null
let stopActiveWatch = null
@@ -62,6 +77,16 @@ export const useSimulationStore = defineStore('simulation', () => {
error.value = null
}
function clearSuccess() {
successMessage.value = null
}
function sessionFitsCapacity(session) {
const need = session?.scenarios?.length ?? 0
if (!need || !maxConcurrentRuns.value) return false
return activeRuns.value.length + need <= maxConcurrentRuns.value
}
async function loadConfig() {
try {
const data = await fetchSimulationConfig()
@@ -90,6 +115,32 @@ export const useSimulationStore = defineStore('simulation', () => {
}
}
async function loadSessions() {
error.value = null
try {
const data = await fetchSessions()
sessions.value = Array.isArray(data) ? data : []
} catch (e) {
error.value = e.message
sessions.value = []
}
}
async function loadDataSummary() {
try {
const data = await fetchDataSummary()
dataSummary.value = {
simulatedPatients: data?.simulatedPatients ?? 0,
encounters: data?.encounters ?? 0,
observations: data?.observations ?? 0,
alerts: data?.alerts ?? 0,
activeRuns: data?.activeRuns ?? activeRuns.value.length,
}
} catch (e) {
error.value = e.message
}
}
function detectNewlyRunning(previousById, nextRuns) {
return nextRuns.some((run) => {
if (run.status !== 'RUNNING') return false
@@ -112,6 +163,7 @@ export const useSimulationStore = defineStore('simulation', () => {
if (detectNewlyRunning(previousById, next)) {
useWardStore().loadEncounters()
loadDataSummary()
}
} catch (e) {
error.value = e.message
@@ -121,6 +173,7 @@ export const useSimulationStore = defineStore('simulation', () => {
async function start(scenarioId, speed) {
starting.value = true
error.value = null
successMessage.value = null
const scenario = scenarios.value.find(s => s.id === scenarioId)
const placeholderId = `optimistic-${Date.now()}`
@@ -132,6 +185,7 @@ export const useSimulationStore = defineStore('simulation', () => {
speed,
patientId: null,
encounterId: null,
sessionId: null,
patientDisplayName: '',
startedAt: new Date().toISOString(),
elapsedRealSeconds: 0,
@@ -155,6 +209,7 @@ export const useSimulationStore = defineStore('simulation', () => {
if (run.status === 'RUNNING') {
useWardStore().loadEncounters()
}
await loadDataSummary()
return run
} catch (e) {
runs.value = runs.value.filter(r => r.runId !== placeholderId)
@@ -165,6 +220,24 @@ export const useSimulationStore = defineStore('simulation', () => {
}
}
async function startSessionRun(sessionId, speed) {
starting.value = true
error.value = null
successMessage.value = null
try {
const session = await startSession(sessionId, { speed })
await refreshRuns()
useWardStore().loadEncounters()
await loadDataSummary()
return session
} catch (e) {
error.value = e.message
throw e
} finally {
starting.value = false
}
}
async function stop(runId) {
error.value = null
try {
@@ -175,6 +248,7 @@ export const useSimulationStore = defineStore('simulation', () => {
} else {
runs.value = [run, ...runs.value]
}
await loadDataSummary()
return run
} catch (e) {
error.value = e.message
@@ -182,6 +256,46 @@ export const useSimulationStore = defineStore('simulation', () => {
}
}
async function stopAll() {
error.value = null
const ids = activeRuns.value.map(r => r.runId)
for (const id of ids) {
try {
await stop(id)
} catch {
// continue stopping others; last error stays on store
}
}
await refreshRuns()
await loadDataSummary()
}
async function purge() {
purging.value = true
error.value = null
successMessage.value = null
try {
const result = await purgeData()
runs.value = []
await Promise.all([
loadDataSummary(),
useWardStore().loadEncounters(),
useAlertStore().loadGlobalAlerts(),
])
const patients = result?.patientsDeleted ?? 0
const alerts = result?.alertsDeleted ?? 0
successMessage.value =
`Ward reset — removed ${patients} simulated patient${patients === 1 ? '' : 's'}` +
` and ${alerts} alert${alerts === 1 ? '' : 's'}.`
return result
} catch (e) {
error.value = e.message
throw e
} finally {
purging.value = false
}
}
function syncPollTimer(active) {
if (active) {
if (!pollTimer) {
@@ -216,21 +330,32 @@ export const useSimulationStore = defineStore('simulation', () => {
maxSpeed,
maxConcurrentRuns,
scenarios,
sessions,
runs,
dataSummary,
loading,
error,
successMessage,
starting,
purging,
activeRuns,
recentRuns,
hasActiveRun,
atConcurrencyLimit,
scenariosByTag,
clearError,
clearSuccess,
sessionFitsCapacity,
loadConfig,
loadScenarios,
loadSessions,
loadDataSummary,
refreshRuns,
start,
startSessionRun,
stop,
stopAll,
purge,
startPolling,
stopPolling,
}
@@ -1,7 +1,8 @@
<script setup>
import { onMounted } from 'vue'
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useAlertQualityStore } from '@/stores/alertQuality'
import { useSimulationStore } from '@/stores/simulation'
import { alertTypeLabel } from '@/api/normalize'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
@@ -10,7 +11,20 @@ import Skeleton from '@/components/ui/Skeleton.vue'
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
const store = useAlertQualityStore()
const { summaryStats, byAlertType, snapshots, loading, error, periodDays } = storeToRefs(store)
const simulationStore = useSimulationStore()
const {
summaryStats,
byAlertType,
byScenario,
scenarioOptions,
snapshots,
feedbackRows,
loading,
error,
periodDays,
selectedScenarioId,
} = storeToRefs(store)
const { enabled: simulationEnabled } = storeToRefs(simulationStore)
const periodOptions = [
{ label: '7 days', value: 7 },
@@ -18,12 +32,32 @@ const periodOptions = [
{ label: '30 days', value: 30 },
]
onMounted(() => store.loadDashboard())
const showScenarioAttribution = computed(() =>
simulationEnabled.value
|| feedbackRows.value.some(r => Object.prototype.hasOwnProperty.call(r, 'scenarioId')),
)
onMounted(async () => {
if (!simulationStore.enabled) {
await simulationStore.loadConfig()
}
await store.loadDashboard()
})
function changePeriod(days) {
store.setPeriodDays(days)
store.loadDashboard()
}
function changeScenario(event) {
const value = event.target.value || null
store.setScenarioFilter(value)
store.loadDashboard()
}
function pct(rate) {
return Math.round((rate ?? 0) * 100)
}
</script>
<template>
@@ -48,9 +82,45 @@ function changePeriod(days) {
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
Refresh
</Button>
<Button
variant="secondary"
size="sm"
:disabled="loading || feedbackRows.length === 0"
@click="store.exportCsv()"
>
Export CSV
</Button>
</div>
</div>
<div
v-if="showScenarioAttribution"
class="flex flex-wrap items-end gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
>
<label class="block min-w-[12rem] flex-1">
<span class="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Scenario
</span>
<select
class="w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
:value="selectedScenarioId ?? ''"
@change="changeScenario"
>
<option value="">All scenarios</option>
<option
v-for="id in scenarioOptions"
:key="id"
:value="id"
>
{{ id }}
</option>
</select>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Filter feedback by the simulation scenario that produced the alert.
</p>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<!-- KPI cards -->
@@ -152,5 +222,34 @@ function changePeriod(days) {
</p>
</div>
</Card>
<!-- By scenario (simulation attribution) -->
<Card v-if="showScenarioAttribution">
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Scenario</h2>
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<div
v-for="row in byScenario"
:key="row.scenarioId ?? 'non-simulated'"
class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<span class="text-sm font-medium dark:text-white">
{{ row.scenarioId ?? 'Non-simulated' }}
</span>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
({{ row.totalFeedback }} rating{{ row.totalFeedback === 1 ? '' : 's' }})
</span>
</div>
<div class="flex flex-wrap gap-2">
<Badge variant="success" size="xs">{{ pct(row.usefulRate) }}% useful</Badge>
<Badge variant="critical" size="xs">{{ pct(row.falsePositiveRate) }}% FP</Badge>
<Badge variant="info" size="xs">{{ pct(row.wouldActRate) }}% would-act</Badge>
</div>
</div>
<p v-if="!byScenario.length" class="py-4 text-sm text-gray-400">
No feedback in this period. Rate alerts in Alert Center to populate scenario attribution.
</p>
</div>
</Card>
</div>
</template>
</template>
@@ -3,6 +3,8 @@ import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
import SessionCard from '@/components/simulation/SessionCard.vue'
import ResetWardPanel from '@/components/simulation/ResetWardPanel.vue'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
@@ -19,21 +21,28 @@ const SPEED_OPTIONS = [
const simulationStore = useSimulationStore()
const {
scenarios,
sessions,
activeRuns,
recentRuns,
dataSummary,
loading,
error,
successMessage,
starting,
purging,
atConcurrencyLimit,
maxConcurrentRuns,
maxSpeed,
} = storeToRefs(simulationStore)
const activeTab = ref('sessions')
const selectedScenarioId = ref(null)
const searchQuery = ref('')
const activeTag = ref(null)
const speed = ref(60)
const stoppingId = ref(null)
const stoppingAll = ref(false)
const resetPanelRef = ref(null)
const availableSpeeds = computed(() => {
const cap = maxSpeed.value
@@ -76,6 +85,11 @@ const filteredScenarios = computed(() => {
})
})
const summaryWithActive = computed(() => ({
...dataSummary.value,
activeRuns: activeRuns.value.length,
}))
function wallClockFor(multiplier) {
if (!selectedScenario.value?.durationMinutes) return null
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
@@ -100,6 +114,14 @@ async function onStart(scenarioId) {
}
}
async function onStartSession(sessionId) {
try {
await simulationStore.startSessionRun(sessionId, speed.value)
} catch (e) {
simulationStore.error = concurrencyErrorMessage(e.message)
}
}
async function onStop(runId) {
stoppingId.value = runId
try {
@@ -111,6 +133,24 @@ async function onStop(runId) {
}
}
async function onStopAll() {
stoppingAll.value = true
try {
await simulationStore.stopAll()
} finally {
stoppingAll.value = false
}
}
async function onPurge() {
try {
await simulationStore.purge()
resetPanelRef.value?.closeConfirm()
} catch {
// error already on store
}
}
function selectScenario(id) {
selectedScenarioId.value = id
}
@@ -119,10 +159,16 @@ function toggleTag(tag) {
activeTag.value = activeTag.value === tag ? null : tag
}
function switchTab(tab) {
activeTab.value = tab
}
onMounted(async () => {
await Promise.all([
simulationStore.loadSessions(),
simulationStore.loadScenarios(),
simulationStore.refreshRuns(),
simulationStore.loadDataSummary(),
])
simulationStore.startPolling()
if (!selectedScenarioId.value && scenarios.value[0]) {
@@ -146,7 +192,7 @@ onBeforeUnmount(() => {
<div>
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
Start a testing session or replay an individual scenario. All patients created here are simulated.
</p>
</div>
@@ -165,6 +211,30 @@ onBeforeUnmount(() => {
</button>
</div>
<div
v-if="successMessage"
role="status"
class="flex items-start justify-between gap-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-200"
>
<p>{{ successMessage }}</p>
<button
type="button"
class="shrink-0 font-medium underline"
@click="simulationStore.clearSuccess()"
>
Dismiss
</button>
</div>
<ResetWardPanel
ref="resetPanelRef"
:summary="summaryWithActive"
:purging="purging"
:stopping-all="stoppingAll"
@purge="onPurge"
@stop-all="onStopAll"
/>
<SimulationRunPanel
v-if="activeRuns.length || recentRuns.length"
:active-runs="activeRuns"
@@ -201,7 +271,7 @@ onBeforeUnmount(() => {
class="mt-0.5 block text-xs"
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
>
<template v-if="wallClockFor(opt.multiplier)">
<template v-if="activeTab === 'scenarios' && wallClockFor(opt.multiplier)">
{{ wallClockFor(opt.multiplier) }} for selected scenario
</template>
<template v-else>
@@ -210,14 +280,81 @@ onBeforeUnmount(() => {
</span>
</button>
</div>
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<p
v-if="activeTab === 'scenarios' && selectedScenario"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
Durations above use
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
({{ selectedScenario.durationMinutes }} simulated minutes).
</p>
<p v-else class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Session starts use this speed (overriding each presets default).
</p>
</section>
<section aria-labelledby="sim-catalogue-heading">
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'sessions'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
:aria-selected="activeTab === 'sessions'"
@click="switchTab('sessions')"
>
Sessions
</button>
<button
type="button"
class="border-b-2 px-4 py-2 text-sm font-medium transition"
:class="activeTab === 'scenarios'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
:aria-selected="activeTab === 'scenarios'"
@click="switchTab('scenarios')"
>
Scenarios
</button>
</div>
<section v-if="activeTab === 'sessions'" aria-labelledby="sim-sessions-heading">
<h2
id="sim-sessions-heading"
class="mb-4 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Testing sessions
</h2>
<Skeleton v-if="loading && sessions.length === 0 && scenarios.length === 0" :rows="3" />
<EmptyState
v-else-if="sessions.length === 0"
message="No session presets found. Check that sessions.json is present beside the scenario directory."
/>
<div
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
role="list"
>
<div
v-for="session in sessions"
:key="session.id"
role="listitem"
>
<SessionCard
:session="session"
:active-run-count="activeRuns.length"
:max-concurrent-runs="maxConcurrentRuns"
:starting="starting"
@start="onStartSession"
/>
</div>
</div>
</section>
<section v-else aria-labelledby="sim-catalogue-heading">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<h2
id="sim-catalogue-heading"