feature: Simulation Control Center (Dashboard)
CI / backend (push) Successful in 8m43s
CI / frontend (push) Successful in 2m0s

This commit is contained in:
voltsrage
2026-08-06 02:22:55 +08:00
parent 24f45851e9
commit 1d28880920
91 changed files with 1943 additions and 25 deletions
@@ -40,17 +40,15 @@ describe('PatientBanner', () => {
expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
})
it('showsNkdaWhenNoAllergies', () => {
it('showsSimChipWhenPatientIsSimulated', () => {
const wrapper = mount(PatientBanner, {
props: {
encounter: {
...encounter,
patient: { ...encounter.patient, allergies: null },
patient: { ...encounter.patient, isSimulated: true },
},
},
})
expect(wrapper.text()).toContain('NKDA')
const strip = wrapper.find('[role="status"]')
expect(strip.classes().join(' ')).not.toMatch(/bg-red-600/)
expect(wrapper.text()).toContain('SIM')
})
})
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
const baseScenario = {
id: 'stable-baseline-01',
name: 'Stable Baseline — Routine Inpatient Monitoring',
description: '52-year-old female admitted for elective cholecystectomy. Completely uneventful 8-hour post-op monitoring period with all vitals remaining normal throughout. This is a CONTROL scenario that validates the system does NOT generate false alerts on a stable patient.',
durationMinutes: 480,
tags: ['stable', 'baseline', 'control', 'surgery'],
department: 'Surgery',
eventCount: 312,
}
describe('ScenarioCard', () => {
it('rendersMetadata', () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
expect(wrapper.text()).toContain(baseScenario.name)
expect(wrapper.text()).toContain('8 hours of monitoring')
expect(wrapper.text()).toContain('Surgery')
expect(wrapper.text()).toContain('312 events')
expect(wrapper.text()).toContain('control')
})
it('labelsControlScenario', () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
expect(wrapper.text()).toContain('Control scenario — should stay quiet')
})
it('labelsRealDataScenario', () => {
const wrapper = mount(ScenarioCard, {
props: {
scenario: {
...baseScenario,
id: 'mimic-case-01',
tags: ['mimic-iv', 'real-data', 'sepsis'],
},
},
})
expect(wrapper.text()).toContain('Real de-identified ICU record')
})
it('disablesStartAtConcurrencyLimit', async () => {
const wrapper = mount(ScenarioCard, {
props: {
scenario: baseScenario,
atConcurrencyLimit: true,
maxConcurrentRuns: 2,
},
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
expect(startBtn.attributes('disabled')).toBeDefined()
expect(startBtn.attributes('title')).toMatch(/Concurrency limit reached/)
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toBeUndefined()
})
it('emitsStartWithScenarioId', async () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toEqual([['stable-baseline-01']])
})
})
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SimulationControlView from '@/views/SimulationControlView.vue'
const {
fetchScenarios,
fetchRuns,
fetchSimulationConfig,
startRun,
stopRun,
} = vi.hoisted(() => ({
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
fetchSimulationConfig: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchScenarios,
fetchRuns,
fetchSimulationConfig,
startRun,
stopRun,
fetchRun: vi.fn(),
}))
vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters: vi.fn() }),
}))
const scenarios = [
{
id: 'uti-sepsis-elderly-01',
name: 'UTI Sepsis Elderly',
description: 'Elderly patient with UTI progressing to sepsis.',
durationMinutes: 480,
tags: ['sepsis', 'uti'],
department: 'GeneralMedicine',
eventCount: 200,
},
{
id: 'stable-baseline-01',
name: 'Stable Baseline',
description: 'Control scenario.',
durationMinutes: 480,
tags: ['control', 'stable'],
department: 'Surgery',
eventCount: 312,
},
]
describe('SimulationControlView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
fetchScenarios.mockResolvedValue(scenarios)
fetchRuns.mockResolvedValue([])
fetchSimulationConfig.mockResolvedValue({
enabled: true,
maxSpeed: 600,
maxConcurrentRuns: 2,
})
})
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
expect(wrapper.text()).toMatch(/8 minutes for selected scenario/)
const realTime = wrapper.findAll('button').find(b => b.text().includes('Real time'))
expect(realTime.text()).toMatch(/8 hours for selected scenario/)
const instant = wrapper.findAll('button').find(b => b.text().includes('Instant'))
expect(instant.text()).toMatch(/1 minute for selected scenario/)
})
it('filtersCatalogueByTag', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
await sepsisChip.trigger('click')
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
expect(wrapper.text()).not.toContain('Stable Baseline')
})
it('showsEmptyStateWhenCatalogueMissing', async () => {
fetchScenarios.mockResolvedValue([])
const wrapper = mount(SimulationControlView)
await vi.waitFor(() =>
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
)
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
import { useSimulationStore } from '@/stores/simulation'
describe('SimulationModeBanner', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('rendersWhenEnabled', () => {
useSimulationStore().enabled = true
const wrapper = mount(SimulationModeBanner)
expect(wrapper.text()).toContain('SIMULATION MODE')
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('isAbsentWhenDisabled', () => {
useSimulationStore().enabled = false
const wrapper = mount(SimulationModeBanner)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
const RouterLinkStub = {
props: ['to'],
template: '<a :href="typeof to === \'string\' ? to : \'\'"><slot /></a>',
}
const activeRun = {
runId: 'run-1',
scenarioId: 'uti-sepsis-elderly-01',
scenarioName: 'UTI Sepsis Elderly',
status: 'RUNNING',
speed: 60,
encounterId: 'enc-99',
patientDisplayName: 'Mary Chen',
startedAt: '2026-08-05T10:00:00Z',
elapsedRealSeconds: 125,
lastOffsetMinutes: 200,
totalOffsetMinutes: 480,
progressPercent: 41.6,
observationsSent: 40,
medicationsSent: 2,
ordersPlaced: 1,
failureReason: null,
}
function mountPanel(props) {
return mount(SimulationRunPanel, {
props,
global: { stubs: { RouterLink: RouterLinkStub } },
})
}
describe('SimulationRunPanel', () => {
it('showsProgressAndSimClock', () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
expect(wrapper.text()).toContain('03:20 of 08:00 elapsed')
expect(wrapper.text()).toContain('42%')
expect(wrapper.text()).toContain('60×')
expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
})
it('linksToPatientEncounter', () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
const link = wrapper.find('a[href="/patients/enc-99"]')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('Mary Chen')
})
it('requiresStopConfirmBeforeEmitting', async () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
await wrapper.findAll('button').find(b => b.text() === 'Stop').trigger('click')
expect(wrapper.emitted('stop')).toBeUndefined()
expect(wrapper.text()).toContain('Stopping leaves the patient on the ward')
await wrapper.findAll('button').find(b => b.text() === 'Confirm stop').trigger('click')
expect(wrapper.emitted('stop')).toEqual([['run-1']])
})
it('showsFailureReasonForFailedRecentRun', () => {
const wrapper = mountPanel({
activeRuns: [],
recentRuns: [{
...activeRun,
status: 'FAILED',
failureReason: 'Loopback authentication failed',
}],
})
expect(wrapper.text()).toContain('Failed')
expect(wrapper.text()).toContain('Loopback authentication failed')
})
})
@@ -1,5 +1,15 @@
import { describe, it, expect } from 'vitest'
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess'
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import {
canAccessOps,
filterNavLinks,
isDashboardRole,
roleCanAccessRoute,
MAIN_NAV_LINKS,
useRoleAccess,
} from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
describe('roleAccess', () => {
it('filtersNavLinksByRole', () => {
@@ -29,3 +39,50 @@ describe('roleAccess', () => {
expect(canAccessOps('INTEGRATION')).toBe(false)
})
})
describe('useRoleAccess simulation nav', () => {
beforeEach(() => {
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'tok'
auth.user = {
userId: '1',
username: 'nurse',
displayName: 'Nurse',
role: 'NURSE',
}
})
it('includesSimulationLinkOnlyWhenEnabled', () => {
const sim = useSimulationStore()
const { mainNavLinks } = useRoleAccess()
sim.enabled = false
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
sim.enabled = true
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
})
it('omitsSimulationLinkWhenDisabledRegardlessOfRole', () => {
const auth = useAuthStore()
const sim = useSimulationStore()
sim.enabled = false
for (const role of ['NURSE', 'PHYSICIAN', 'ADMIN']) {
auth.user = { ...auth.user, role }
const { mainNavLinks } = useRoleAccess()
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
}
})
it('movesQualityToOverflowWhenSimulationEnabledOnMobile', () => {
const sim = useSimulationStore()
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
sim.enabled = true
expect(mobileNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
expect(mobileNavLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(false)
expect(mobileOverflowLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
const { fetchSimulationConfig } = vi.hoisted(() => ({
fetchSimulationConfig: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchSimulationConfig,
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
fetchRun: vi.fn(),
}))
describe('simulation router guard', () => {
beforeEach(async () => {
vi.clearAllMocks()
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'test-token'
auth.user = {
userId: 'u1',
username: 'nurse',
displayName: 'Test Nurse',
role: 'NURSE',
}
fetchSimulationConfig.mockResolvedValue({ enabled: false })
const { default: router } = await import('@/router')
// Reset to a known route; ignore failures from first load
await router.push('/ward').catch(() => {})
await router.isReady()
})
it('redirectsDirectSimulationNavigationWhenDisabled', async () => {
const sim = useSimulationStore()
sim.enabled = false
const { default: router } = await import('@/router')
await router.push('/simulation')
expect(router.currentRoute.value.path).toBe('/ward')
expect(fetchSimulationConfig).toHaveBeenCalled()
})
it('allowsSimulationWhenEnabled', async () => {
const sim = useSimulationStore()
sim.enabled = true
const { default: router } = await import('@/router')
await router.push('/simulation')
expect(router.currentRoute.value.path).toBe('/simulation')
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useSimulationStore } from '@/stores/simulation'
const {
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
loadEncounters,
} = vi.hoisted(() => ({
fetchSimulationConfig: vi.fn(),
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
loadEncounters: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
fetchRun: vi.fn(),
}))
vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters }),
}))
function runningRun(overrides = {}) {
return {
runId: 'run-1',
scenarioId: 'uti-sepsis-elderly-01',
scenarioName: 'UTI Sepsis',
status: 'RUNNING',
speed: 60,
patientId: 'p1',
encounterId: 'e1',
patientDisplayName: 'Test Patient',
startedAt: '2026-08-05T10:00:00Z',
elapsedRealSeconds: 30,
lastOffsetMinutes: 20,
totalOffsetMinutes: 480,
progressPercent: 4,
observationsSent: 3,
medicationsSent: 0,
ordersPlaced: 0,
failureReason: null,
...overrides,
}
}
describe('useSimulationStore', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
setActivePinia(createPinia())
fetchRuns.mockResolvedValue([])
fetchScenarios.mockResolvedValue([])
fetchSimulationConfig.mockResolvedValue({
enabled: true,
maxSpeed: 600,
maxConcurrentRuns: 2,
})
})
afterEach(() => {
const store = useSimulationStore()
store.stopPolling()
vi.useRealTimers()
})
it('activeRuns_and_atConcurrencyLimit', () => {
const store = useSimulationStore()
store.maxConcurrentRuns = 2
store.runs = [
runningRun({ runId: 'a', status: 'RUNNING' }),
runningRun({ runId: 'b', status: 'PENDING' }),
runningRun({ runId: 'c', status: 'COMPLETED' }),
]
expect(store.activeRuns.map(r => r.runId)).toEqual(['a', 'b'])
expect(store.hasActiveRun).toBe(true)
expect(store.atConcurrencyLimit).toBe(true)
expect(store.recentRuns.map(r => r.runId)).toEqual(['c'])
})
it('start_pushesOptimisticPlaceholderThenReconciles', async () => {
const store = useSimulationStore()
store.scenarios = [{ id: 's1', name: 'Scenario One', durationMinutes: 480 }]
let resolveStart
startRun.mockReturnValue(new Promise((resolve) => {
resolveStart = resolve
}))
const startPromise = store.start('s1', 60)
await nextTick()
expect(store.activeRuns).toHaveLength(1)
expect(store.activeRuns[0].status).toBe('PENDING')
expect(store.activeRuns[0]._optimistic).toBe(true)
expect(store.starting).toBe(true)
resolveStart(runningRun({ runId: 'real-1', scenarioId: 's1', scenarioName: 'Scenario One' }))
await startPromise
expect(store.runs).toHaveLength(1)
expect(store.runs[0].runId).toBe('real-1')
expect(store.runs[0]._optimistic).toBeUndefined()
expect(store.starting).toBe(false)
expect(loadEncounters).toHaveBeenCalled()
})
it('start_rollsBackPlaceholderOnFailure', async () => {
const store = useSimulationStore()
startRun.mockRejectedValue(new Error('Maximum concurrent simulation runs (2) reached.'))
await expect(store.start('s1', 60)).rejects.toThrow(/concurrent/)
expect(store.runs).toEqual([])
expect(store.error).toMatch(/concurrent/)
expect(store.starting).toBe(false)
})
it('stop_updatesRunFromApi', async () => {
const store = useSimulationStore()
store.runs = [runningRun()]
stopRun.mockResolvedValue(runningRun({ status: 'CANCELLED' }))
await store.stop('run-1')
expect(stopRun).toHaveBeenCalledWith('run-1')
expect(store.runs[0].status).toBe('CANCELLED')
})
it('polling_runsOnlyWhileActiveRunExists', async () => {
const store = useSimulationStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(5_000)
expect(fetchRuns).not.toHaveBeenCalled()
store.runs = [runningRun()]
await nextTick()
await vi.advanceTimersByTimeAsync(2_000)
expect(fetchRuns).toHaveBeenCalledTimes(1)
fetchRuns.mockResolvedValue([runningRun({ status: 'COMPLETED', progressPercent: 100 })])
await store.refreshRuns()
await nextTick()
fetchRuns.mockClear()
await vi.advanceTimersByTimeAsync(4_000)
expect(fetchRuns).not.toHaveBeenCalled()
store.stopPolling()
})
it('loadConfig_failsClosedOnError', async () => {
const store = useSimulationStore()
fetchSimulationConfig.mockRejectedValue(new Error('network'))
await store.loadConfig()
expect(store.enabled).toBe(false)
expect(store.maxConcurrentRuns).toBe(0)
})
})
+12 -1
View File
@@ -88,4 +88,15 @@ const BUNDLE_ELEMENT_LABELS = {
export function bundleElementLabel(elementType) {
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
}
}
/**
* True when the ward summary or encounter detail marks the patient as simulated.
* Ward list: top-level `isSimulated`. Patient detail: `patient.isSimulated`.
*/
export function isSimulatedPatient(source) {
if (!source) return false
if (source.isSimulated === true) return true
if (source.patient?.isSimulated === true) return true
return false
}
@@ -0,0 +1,8 @@
import { api } from './client'
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
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 stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
@@ -1,13 +1,31 @@
<script setup>
import { onMounted, watch } from 'vue'
import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue'
import MobileNav from './MobileNav.vue'
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
import { useAuthStore } from '@/stores/auth'
import { useSettingsStore } from '@/stores/settings'
import { useSimulationStore } from '@/stores/simulation'
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
import { useSimulationAvailable } from '@/composables/useSimulationAvailable'
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const simulationStore = useSimulationStore()
const { detect: detectSimulation } = useSimulationAvailable()
useCriticalAlertPolling(settingsStore.pollInterval)
onMounted(() => {
if (authStore.isAuthenticated) detectSimulation()
})
watch(() => authStore.isAuthenticated, (ok) => {
if (ok) detectSimulation()
else simulationStore.enabled = false
})
</script>
<template>
@@ -21,6 +39,7 @@ useCriticalAlertPolling(settingsStore.pollInterval)
<AppSidebar />
<div class="flex min-w-0 flex-1 flex-col">
<AppHeader />
<SimulationModeBanner />
<CriticalAlertBanner />
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
<div class="mx-auto w-full max-w-7xl">
@@ -133,6 +133,27 @@ function linkClasses(path) {
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
<svg
v-else-if="link.icon === 'simulation'"
class="h-6 w-6 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else
class="h-6 w-6 shrink-0"
@@ -1,14 +1,28 @@
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const authStore = useAuthStore()
const { mobileNavLinks } = useRoleAccess()
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
const overflowOpen = ref(false)
const activePath = computed(() => route.path)
function linkActive(to) {
return activePath.value.startsWith(to)
}
function toggleOverflow() {
overflowOpen.value = !overflowOpen.value
}
function closeOverflow() {
overflowOpen.value = false
}
</script>
<template>
@@ -16,14 +30,32 @@ const activePath = computed(() => route.path)
class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900"
aria-label="Mobile navigation"
>
<div v-if="overflowOpen && mobileOverflowLinks.length" class="border-b border-gray-200 dark:border-gray-800">
<ul class="flex flex-col py-2">
<li v-for="link in mobileOverflowLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
:class="linkActive(link.to)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'"
@click="closeOverflow"
>
{{ link.label }}
</RouterLink>
</li>
</ul>
</div>
<ul class="flex h-16 items-stretch">
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1">
<li v-for="link in mobileNavLinks" :key="link.to" class="min-w-0 flex-1">
<RouterLink
:to="link.to"
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
:class="activePath.startsWith(link.to)
class="flex h-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
:class="linkActive(link.to)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="closeOverflow"
>
<svg
v-if="link.icon === 'ward'"
@@ -70,6 +102,27 @@ const activePath = computed(() => route.path)
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<svg
v-else-if="link.icon === 'simulation'"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else
class="h-6 w-6"
@@ -85,19 +138,38 @@ const activePath = computed(() => route.path)
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
{{ link.label }}
<span class="truncate">{{ link.label }}</span>
</RouterLink>
</li>
<li class="flex-1">
<li v-if="mobileOverflowLinks.length" class="min-w-0 flex-1">
<button
type="button"
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400"
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
:class="overflowOpen || mobileOverflowLinks.some(l => linkActive(l.to))
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
:aria-expanded="overflowOpen"
aria-label="More navigation"
@click="toggleOverflow"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
<span class="truncate">More</span>
</button>
</li>
<li class="min-w-0 flex-1">
<button
type="button"
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400 sm:gap-2 sm:px-4 sm:text-xs"
@click="authStore.logout()"
>
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Sign out
<span class="truncate">Sign out</span>
</button>
</li>
</ul>
@@ -8,6 +8,7 @@ import {
formatGender,
hasKnownAllergies,
} from '@/composables/patientFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({
encounter: { type: Object, required: true },
@@ -15,6 +16,8 @@ const props = defineProps({
const patient = computed(() => props.encounter.patient ?? {})
const simulated = computed(() => isSimulatedPatient(props.encounter))
const fullName = computed(() =>
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
)
@@ -49,6 +52,13 @@ const emergencyContact = computed(() => {
<h1 class="text-xl font-bold text-gray-900 dark:text-white">
{{ fullName }}
</h1>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">
MRN {{ patient.mrn ?? '—' }}
</span>
@@ -0,0 +1,163 @@
<script setup>
import { computed, ref } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import { formatDepartment } from '@/composables/sepsisFormat'
import {
formatMonitoringDuration,
tagChipClass,
isControlTag,
isRealDataTag,
} from '@/composables/simulationFormat'
const props = defineProps({
scenario: { type: Object, required: true },
atConcurrencyLimit: { type: Boolean, default: false },
maxConcurrentRuns: { type: Number, default: 0 },
selected: { type: Boolean, default: false },
starting: { type: Boolean, default: false },
})
const emit = defineEmits(['start', 'select'])
const expanded = ref(false)
const description = computed(() => props.scenario.description?.trim() || '')
const canExpand = computed(() => description.value.length > 140)
const durationLabel = computed(() =>
formatMonitoringDuration(props.scenario.durationMinutes),
)
const departmentLabel = computed(() =>
formatDepartment(props.scenario.department),
)
const eventLabel = computed(() => {
const n = props.scenario.eventCount ?? 0
return `${n} event${n === 1 ? '' : 's'}`
})
const tags = computed(() => props.scenario.tags ?? [])
const showControlNote = computed(() => tags.value.some(isControlTag))
const showRealDataNote = computed(() => tags.value.some(isRealDataTag))
const startDisabled = computed(() =>
props.atConcurrencyLimit || props.starting,
)
const disabledReason = computed(() => {
if (props.atConcurrencyLimit) {
const n = props.maxConcurrentRuns
return n > 0
? `Concurrency limit reached (${n} run${n === 1 ? '' : 's'} max). Stop an active run first.`
: 'Concurrency limit reached. Stop an active run first.'
}
return undefined
})
function onStart() {
if (startDisabled.value) return
emit('start', props.scenario.id)
}
function onSelect() {
emit('select', props.scenario.id)
}
</script>
<template>
<Card
padding="md"
class="flex h-full flex-col transition"
:class="selected
? 'ring-2 ring-blue-500 dark:ring-blue-400'
: 'hover:border-gray-300 dark:hover:border-gray-600'"
>
<div
class="flex-1 cursor-pointer"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="onSelect"
@keydown.enter.prevent="onSelect"
@keydown.space.prevent="onSelect"
>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ scenario.name }}
</h3>
<p
v-if="description"
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
:class="expanded ? '' : 'line-clamp-3'"
>
{{ description }}
</p>
</div>
<button
v-if="canExpand"
type="button"
class="mt-1 self-start text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
@click="expanded = !expanded"
>
{{ expanded ? 'Show less' : 'Show more' }}
</button>
<div class="mt-3 flex flex-wrap gap-2">
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ durationLabel }}
</span>
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ departmentLabel }}
</span>
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ eventLabel }}
</span>
</div>
<div v-if="tags.length" class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="tag in tags"
:key="tag"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="tagChipClass(tag)"
>
{{ tag }}
</span>
</div>
<p
v-if="showControlNote"
class="mt-3 text-xs font-medium text-emerald-700 dark:text-emerald-300"
>
Control scenario should stay quiet
</p>
<p
v-if="showRealDataNote"
class="mt-1 text-xs font-medium text-violet-700 dark:text-violet-300"
>
Real de-identified ICU record
</p>
<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
</Button>
</div>
</Card>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
const { enabled } = storeToRefs(useSimulationStore())
</script>
<template>
<div
v-if="enabled"
role="status"
class="border-b border-amber-400 bg-amber-100 px-4 py-2.5 text-center text-sm text-amber-950 dark:border-amber-600 dark:bg-amber-950/60 dark:text-amber-100"
>
<span class="font-bold tracking-wide">SIMULATION MODE</span>
<span class="mx-1.5"></span>
patients shown here are simulated. Do not use for clinical decisions.
</div>
</template>
@@ -0,0 +1,238 @@
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import {
formatSimClock,
formatElapsedReal,
formatSpeedBadge,
runStatusLabel,
runStatusVariant,
} from '@/composables/simulationFormat'
defineProps({
activeRuns: { type: Array, default: () => [] },
recentRuns: { type: Array, default: () => [] },
stoppingId: { type: [String, null], default: null },
})
const emit = defineEmits(['stop'])
const confirmStopId = ref(null)
function requestStop(runId) {
confirmStopId.value = runId
}
function cancelStop() {
confirmStopId.value = null
}
function confirmStop(runId) {
confirmStopId.value = null
emit('stop', runId)
}
function patientLink(run) {
return run.encounterId ? `/patients/${run.encounterId}` : null
}
function progressWidth(run) {
const pct = Math.min(100, Math.max(0, Number(run.progressPercent) || 0))
return `${pct}%`
}
</script>
<template>
<div class="space-y-6">
<div v-if="activeRuns.length" class="space-y-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Active runs
</h2>
<Card
v-for="run in activeRuns"
:key="run.runId"
padding="md"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ run.scenarioName }}
</h3>
<Badge :variant="runStatusVariant(run.status)" size="xs">
{{ runStatusLabel(run.status) }}
</Badge>
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ formatSpeedBadge(run.speed) }}
</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<template v-if="patientLink(run)">
<RouterLink
:to="patientLink(run)"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ run.patientDisplayName || 'Open patient' }}
</RouterLink>
</template>
<template v-else>
{{ run.patientDisplayName || 'Patient pending' }}
</template>
</p>
</div>
<div class="shrink-0">
<template v-if="confirmStopId === run.runId">
<div class="flex flex-col items-end gap-2">
<p class="max-w-xs text-right text-xs text-gray-500 dark:text-gray-400">
Stopping leaves the patient on the ward.
</p>
<div class="flex gap-2">
<Button size="sm" variant="secondary" @click="cancelStop">
Cancel
</Button>
<Button
size="sm"
variant="danger"
:disabled="stoppingId === run.runId"
@click="confirmStop(run.runId)"
>
Confirm stop
</Button>
</div>
</div>
</template>
<Button
v-else
size="sm"
variant="danger"
:disabled="run._optimistic || stoppingId === run.runId"
@click="requestStop(run.runId)"
>
Stop
</Button>
</div>
</div>
<div class="mt-4">
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{{ formatSimClock(run.lastOffsetMinutes, run.totalOffsetMinutes) }}</span>
<span>{{ Math.round(run.progressPercent ?? 0) }}%</span>
</div>
<div
class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"
role="progressbar"
:aria-valuenow="Math.round(run.progressPercent ?? 0)"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full rounded-full bg-blue-600 transition-all dark:bg-blue-500"
:style="{ width: progressWidth(run) }"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Wall clock {{ formatElapsedReal(run.elapsedRealSeconds) }}
</p>
</div>
<dl class="mt-4 grid grid-cols-3 gap-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Observations</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.observationsSent ?? 0 }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Medications</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.medicationsSent ?? 0 }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Orders</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.ordersPlaced ?? 0 }}
</dd>
</div>
</dl>
<p
v-if="run.status === 'FAILED' && run.failureReason"
class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
role="alert"
>
{{ run.failureReason }}
</p>
</Card>
</div>
<div v-if="recentRuns.length">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Recent runs
</h2>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Scenario</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Duration</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Counters</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Patient</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<tr
v-for="run in recentRuns"
:key="run.runId"
:class="run.status === 'FAILED' ? 'bg-red-50/50 dark:bg-red-950/20' : ''"
>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{{ run.scenarioName }}
<p
v-if="run.status === 'FAILED' && run.failureReason"
class="mt-1 text-xs text-red-600 dark:text-red-400"
>
{{ run.failureReason }}
</p>
</td>
<td class="px-4 py-3">
<Badge :variant="runStatusVariant(run.status)" size="xs">
{{ runStatusLabel(run.status) }}
</Badge>
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
{{ formatElapsedReal(run.elapsedRealSeconds) }}
<span class="text-gray-400">·</span>
{{ formatSpeedBadge(run.speed) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
{{ run.observationsSent ?? 0 }} obs ·
{{ run.medicationsSent ?? 0 }} med ·
{{ run.ordersPlaced ?? 0 }} ord
</td>
<td class="px-4 py-3">
<RouterLink
v-if="patientLink(run)"
:to="patientLink(run)"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ run.patientDisplayName || 'Open' }}
</RouterLink>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
@@ -9,6 +9,7 @@ import {
patientRoom,
stalenessClass,
} from '@/composables/wardFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({ patient: { type: Object, required: true } })
@@ -23,8 +24,10 @@ const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
const simulated = computed(() => isSimulatedPatient(props.patient))
const cardLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
)
const emit = defineEmits(['activate'])
@@ -53,6 +56,13 @@ function onKeydown(event) {
Room {{ patientRoom(patient) }}
</span>
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
</div>
<h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white">
{{ patient.firstName }} {{ patient.lastName }}
@@ -10,6 +10,7 @@ import {
stalenessClass,
} from '@/composables/wardFormat'
import { formatDepartment } from '@/composables/sepsisFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({
patient: { type: Object, required: true },
@@ -18,6 +19,8 @@ const props = defineProps({
const emit = defineEmits(['activate'])
const simulated = computed(() => isSimulatedPatient(props.patient))
const riskVariant = computed(() => {
const score = props.patient.news2Score ?? 0
if (score >= 7) return 'critical'
@@ -45,7 +48,7 @@ const vitalsStaleness = computed(() =>
)
const rowLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`,
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
)
function onKeydown(event) {
@@ -68,8 +71,17 @@ function onKeydown(event) {
{{ patientRoom(patient) }}
</td>
<td class="px-4 py-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ patient.firstName }} {{ patient.lastName }}
<div class="flex flex-wrap items-center gap-2">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ patient.firstName }} {{ patient.lastName }}
</div>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
</td>
@@ -1,6 +1,7 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
@@ -10,6 +11,19 @@ export const OPS_NAV_LINK = {
icon: 'ops',
}
export const SIMULATION_NAV_LINK = {
to: '/simulation',
label: 'Simulation',
icon: 'simulation',
roles: CLINICAL_ROLES,
}
/** Shorter label for the bottom bar at ~360 px. */
export const SIMULATION_MOBILE_NAV_LINK = {
...SIMULATION_NAV_LINK,
label: 'Sim',
}
export const MAIN_NAV_LINKS = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
@@ -37,6 +51,8 @@ export const MOBILE_NAV_LINKS = [
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
]
const QUALITY_MOBILE_PATH = '/admin/reconciliation'
export function isDashboardRole(role) {
return CLINICAL_ROLES.includes(role)
}
@@ -60,6 +76,7 @@ export function filterNavLinks(links, role) {
export function useRoleAccess() {
const auth = useAuthStore()
const simulation = useSimulationStore()
const { role } = storeToRefs(auth)
const isNurse = computed(() => role.value === 'NURSE')
@@ -69,11 +86,30 @@ export function useRoleAccess() {
const mainNavLinks = computed(() => {
const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
if (simulation.enabled && CLINICAL_ROLES.includes(role.value)) {
links.push(SIMULATION_NAV_LINK)
}
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
return links
})
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
// Keep the bottom bar usable at ~360 px: when simulation is on, Quality moves to overflow.
const mobileNavLinks = computed(() => {
const links = filterNavLinks(MOBILE_NAV_LINKS, role.value)
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return links
return [
...links.filter(l => l.to !== QUALITY_MOBILE_PATH),
SIMULATION_MOBILE_NAV_LINK,
]
})
const mobileOverflowLinks = computed(() => {
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return []
return filterNavLinks(MOBILE_NAV_LINKS, role.value)
.filter(l => l.to === QUALITY_MOBILE_PATH)
})
const showAdminSection = computed(() => adminNavLinks.value.length > 0)
const nursePatientLayout = computed(() => isNurse.value)
@@ -88,6 +124,7 @@ export function useRoleAccess() {
mainNavLinks,
adminNavLinks,
mobileNavLinks,
mobileOverflowLinks,
showAdminSection,
nursePatientLayout,
physicianPatientLayout,
@@ -0,0 +1,98 @@
/** Formats a minute count as a short clinician-facing duration. */
export function formatDurationMinutes(minutes) {
if (minutes == null || Number.isNaN(Number(minutes))) return '—'
const total = Math.max(0, Number(minutes))
if (total < 60) {
const m = Math.round(total)
return `${m} minute${m === 1 ? '' : 's'}`
}
const hoursExact = total / 60
if (Math.abs(hoursExact - Math.round(hoursExact)) < 0.05) {
const h = Math.round(hoursExact)
return `${h} hour${h === 1 ? '' : 's'}`
}
const h = Math.floor(total / 60)
const m = Math.round(total % 60)
if (m === 0) return `${h} hour${h === 1 ? '' : 's'}`
return `${h}h ${m}m`
}
/** Scenario duration pill: "8 hours of monitoring". */
export function formatMonitoringDuration(durationMinutes) {
return `${formatDurationMinutes(durationMinutes)} of monitoring`
}
/** Wall-clock time for a scenario at a given speed multiplier. */
export function formatWallClockForSpeed(durationMinutes, speed) {
if (durationMinutes == null || !speed) return '—'
return formatDurationMinutes(Number(durationMinutes) / Number(speed))
}
/** Simulated clock: "03:20 of 08:00 elapsed". */
export function formatSimClock(lastOffsetMinutes, totalOffsetMinutes) {
const fmt = (mins) => {
const total = Math.max(0, Math.floor(Number(mins) || 0))
const h = Math.floor(total / 60)
const m = total % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
return `${fmt(lastOffsetMinutes)} of ${fmt(totalOffsetMinutes)} elapsed`
}
/** Wall-clock elapsed from elapsedRealSeconds. */
export function formatElapsedReal(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0))
const m = Math.floor(s / 60)
const rem = s % 60
if (m >= 60) {
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m`
}
return `${m}:${String(rem).padStart(2, '0')}`
}
export function formatSpeedBadge(speed) {
if (speed == null) return '—'
return `${speed}×`
}
export function runStatusLabel(status) {
const map = {
PENDING: 'Pending',
RUNNING: 'Running',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
FAILED: 'Failed',
}
return map[status] ?? status ?? '—'
}
export function runStatusVariant(status) {
if (status === 'FAILED') return 'critical'
if (status === 'CANCELLED') return 'warning'
if (status === 'RUNNING') return 'success'
if (status === 'COMPLETED') return 'success'
return 'info'
}
const TAG_CHIP_CLASS = {
sepsis: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
'mimic-iv': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
'real-data': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
stable: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
baseline: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
}
export function tagChipClass(tag) {
return TAG_CHIP_CLASS[tag]
?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
export function isControlTag(tag) {
return tag === 'control'
}
export function isRealDataTag(tag) {
return tag === 'real-data' || tag === 'mimic-iv'
}
@@ -0,0 +1,25 @@
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
/**
* Feature-detects in-app simulation via GET /api/v1/simulation/config.
* Fail-closed: any error leaves simulationStore.enabled = false.
*/
export function useSimulationAvailable() {
const auth = useAuthStore()
const simulation = useSimulationStore()
const available = computed(() => simulation.enabled)
async function detect() {
if (!auth.isAuthenticated) {
simulation.enabled = false
return false
}
await simulation.loadConfig()
return simulation.enabled
}
return { available, detect }
}
+17 -1
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
@@ -45,6 +46,12 @@ const routes = [
component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
},
{
path: '/simulation',
name: 'SimulationControl',
component: () => import('@/views/SimulationControlView.vue'),
meta: { title: 'Simulation', layout: 'default', allowedRoles: CLINICAL, requiresSimulation: true },
},
{
path: '/admin/thresholds',
name: 'ThresholdManagement',
@@ -92,7 +99,7 @@ const router = createRouter({
routes,
})
router.beforeEach((to) => {
router.beforeEach(async (to) => {
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
const auth = useAuthStore()
@@ -108,6 +115,15 @@ router.beforeEach((to) => {
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
return { path: defaultRouteForRole(auth.role) }
}
if (to.meta.requiresSimulation && auth.isAuthenticated) {
const simulation = useSimulationStore()
if (!simulation.enabled) {
await simulation.loadConfig()
}
if (!simulation.enabled) {
return { path: '/ward' }
}
}
})
export default router
@@ -0,0 +1,237 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import {
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
} from '@/api/simulation'
import { useWardStore } from '@/stores/ward'
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
const POLL_INTERVAL_MS = 2_000
function isActiveStatus(status) {
return ACTIVE_STATUSES.has(status)
}
export const useSimulationStore = defineStore('simulation', () => {
const enabled = ref(false)
const maxSpeed = ref(null)
const maxConcurrentRuns = ref(0)
const scenarios = ref([])
const runs = ref([])
const loading = ref(false)
const error = ref(null)
const starting = ref(false)
let pollTimer = null
let stopActiveWatch = null
const activeRuns = computed(() =>
runs.value.filter(r => isActiveStatus(r.status)),
)
const recentRuns = computed(() =>
runs.value
.filter(r => !isActiveStatus(r.status))
.slice()
.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
.slice(0, 10),
)
const hasActiveRun = computed(() => activeRuns.value.length > 0)
const atConcurrencyLimit = computed(() =>
activeRuns.value.length >= maxConcurrentRuns.value,
)
const scenariosByTag = computed(() => {
const groups = {}
for (const scenario of scenarios.value) {
for (const tag of scenario.tags ?? []) {
if (!groups[tag]) groups[tag] = []
groups[tag].push(scenario)
}
}
return groups
})
function clearError() {
error.value = null
}
async function loadConfig() {
try {
const data = await fetchSimulationConfig()
enabled.value = !!data?.enabled
maxSpeed.value = data?.maxSpeed ?? null
maxConcurrentRuns.value = data?.maxConcurrentRuns ?? 0
} catch {
// Fail closed — never surface simulation controls on a broken probe.
enabled.value = false
maxSpeed.value = null
maxConcurrentRuns.value = 0
}
}
async function loadScenarios() {
loading.value = true
error.value = null
try {
const data = await fetchScenarios()
scenarios.value = Array.isArray(data) ? data : []
} catch (e) {
error.value = e.message
scenarios.value = []
} finally {
loading.value = false
}
}
function detectNewlyRunning(previousById, nextRuns) {
return nextRuns.some((run) => {
if (run.status !== 'RUNNING') return false
const prev = previousById.get(run.runId)
return prev !== 'RUNNING'
})
}
async function refreshRuns() {
try {
const previousById = new Map(runs.value.map(r => [r.runId, r.status]))
const data = await fetchRuns()
const next = Array.isArray(data) ? data : []
// Keep optimistic placeholders until the server echoes a real run.
const optimistic = runs.value.filter(r => r._optimistic)
const serverIds = new Set(next.map(r => r.runId))
const stillPending = optimistic.filter(r => !serverIds.has(r.runId))
runs.value = [...stillPending, ...next]
if (detectNewlyRunning(previousById, next)) {
useWardStore().loadEncounters()
}
} catch (e) {
error.value = e.message
}
}
async function start(scenarioId, speed) {
starting.value = true
error.value = null
const scenario = scenarios.value.find(s => s.id === scenarioId)
const placeholderId = `optimistic-${Date.now()}`
const placeholder = {
runId: placeholderId,
scenarioId,
scenarioName: scenario?.name ?? scenarioId,
status: 'PENDING',
speed,
patientId: null,
encounterId: null,
patientDisplayName: '',
startedAt: new Date().toISOString(),
elapsedRealSeconds: 0,
lastOffsetMinutes: 0,
totalOffsetMinutes: scenario?.durationMinutes ?? 0,
progressPercent: 0,
observationsSent: 0,
medicationsSent: 0,
ordersPlaced: 0,
failureReason: null,
_optimistic: true,
}
runs.value = [placeholder, ...runs.value]
try {
const run = await startRun({ scenarioId, speed })
runs.value = [
run,
...runs.value.filter(r => r.runId !== placeholderId && r.runId !== run.runId),
]
if (run.status === 'RUNNING') {
useWardStore().loadEncounters()
}
return run
} catch (e) {
runs.value = runs.value.filter(r => r.runId !== placeholderId)
error.value = e.message
throw e
} finally {
starting.value = false
}
}
async function stop(runId) {
error.value = null
try {
const run = await stopRun(runId)
const idx = runs.value.findIndex(r => r.runId === runId)
if (idx >= 0) {
runs.value[idx] = run
} else {
runs.value = [run, ...runs.value]
}
return run
} catch (e) {
error.value = e.message
throw e
}
}
function syncPollTimer(active) {
if (active) {
if (!pollTimer) {
pollTimer = setInterval(() => {
refreshRuns()
}, POLL_INTERVAL_MS)
}
} else if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** View-owned lifecycle: start watching active runs and poll while any are in flight. */
function startPolling() {
if (stopActiveWatch) return
stopActiveWatch = watch(hasActiveRun, (active) => {
syncPollTimer(active)
}, { immediate: true })
}
function stopPolling() {
if (stopActiveWatch) {
stopActiveWatch()
stopActiveWatch = null
}
syncPollTimer(false)
}
return {
enabled,
maxSpeed,
maxConcurrentRuns,
scenarios,
runs,
loading,
error,
starting,
activeRuns,
recentRuns,
hasActiveRun,
atConcurrencyLimit,
scenariosByTag,
clearError,
loadConfig,
loadScenarios,
refreshRuns,
start,
stop,
startPolling,
stopPolling,
}
})
@@ -0,0 +1,297 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { formatWallClockForSpeed } from '@/composables/simulationFormat'
const SPEED_OPTIONS = [
{ label: 'Real time', multiplier: 1 },
{ label: 'Fast', multiplier: 60 },
{ label: 'Very fast', multiplier: 240 },
{ label: 'Instant', multiplier: 600 },
]
const simulationStore = useSimulationStore()
const {
scenarios,
activeRuns,
recentRuns,
loading,
error,
starting,
atConcurrencyLimit,
maxConcurrentRuns,
maxSpeed,
} = storeToRefs(simulationStore)
const selectedScenarioId = ref(null)
const searchQuery = ref('')
const activeTag = ref(null)
const speed = ref(60)
const stoppingId = ref(null)
const availableSpeeds = computed(() => {
const cap = maxSpeed.value
return SPEED_OPTIONS.map((opt) => {
const multiplier = cap != null ? Math.min(opt.multiplier, cap) : opt.multiplier
return { ...opt, multiplier }
}).filter((opt, index, arr) =>
// Drop Instant (or any option) if a lower option already uses the same capped value
index === arr.findIndex(o => o.multiplier === opt.multiplier),
)
})
const selectedScenario = computed(() =>
scenarios.value.find(s => s.id === selectedScenarioId.value)
?? filteredScenarios.value[0]
?? null,
)
const allTags = computed(() => {
const set = new Set()
for (const s of scenarios.value) {
for (const tag of s.tags ?? []) set.add(tag)
}
return [...set].sort()
})
const filteredScenarios = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return scenarios.value.filter((s) => {
if (activeTag.value && !(s.tags ?? []).includes(activeTag.value)) return false
if (!q) return true
const haystack = [
s.name,
s.description,
s.id,
s.department,
...(s.tags ?? []),
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
})
})
function wallClockFor(multiplier) {
if (!selectedScenario.value?.durationMinutes) return null
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
}
function concurrencyErrorMessage(raw) {
const n = maxConcurrentRuns.value
if (/concurrent|concurrency|409/i.test(raw ?? '')) {
return n > 0
? `Concurrency limit reached — at most ${n} simulation run${n === 1 ? '' : 's'} can run at once. Stop an active run first.`
: 'Concurrency limit reached. Stop an active run first.'
}
return raw
}
async function onStart(scenarioId) {
selectedScenarioId.value = scenarioId
try {
await simulationStore.start(scenarioId, speed.value)
} catch (e) {
simulationStore.error = concurrencyErrorMessage(e.message)
}
}
async function onStop(runId) {
stoppingId.value = runId
try {
await simulationStore.stop(runId)
} catch {
// error already on store
} finally {
stoppingId.value = null
}
}
function selectScenario(id) {
selectedScenarioId.value = id
}
function toggleTag(tag) {
activeTag.value = activeTag.value === tag ? null : tag
}
onMounted(async () => {
await Promise.all([
simulationStore.loadScenarios(),
simulationStore.refreshRuns(),
])
simulationStore.startPolling()
if (!selectedScenarioId.value && scenarios.value[0]) {
selectedScenarioId.value = scenarios.value[0].id
}
if (!availableSpeeds.value.some(o => o.multiplier === speed.value)) {
const preferred = availableSpeeds.value.find(o => o.multiplier === 60)
?? availableSpeeds.value[1]
?? availableSpeeds.value[0]
if (preferred) speed.value = preferred.multiplier
}
})
onBeforeUnmount(() => {
simulationStore.stopPolling()
})
</script>
<template>
<div class="space-y-8">
<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.
</p>
</div>
<div
v-if="error"
role="alert"
class="flex items-start justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
<p>{{ error }}</p>
<button
type="button"
class="shrink-0 font-medium underline"
@click="simulationStore.clearError()"
>
Dismiss
</button>
</div>
<SimulationRunPanel
v-if="activeRuns.length || recentRuns.length"
:active-runs="activeRuns"
:recent-runs="recentRuns"
:stopping-id="stoppingId"
@stop="onStop"
/>
<section aria-labelledby="sim-speed-heading">
<h2
id="sim-speed-heading"
class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Replay speed
</h2>
<div
class="inline-flex max-w-full flex-wrap rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-900"
role="group"
aria-label="Replay speed"
>
<button
v-for="opt in availableSpeeds"
:key="opt.label"
type="button"
class="min-h-11 rounded-md px-4 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="speed === opt.multiplier
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'"
:aria-pressed="speed === opt.multiplier"
@click="speed = opt.multiplier"
>
<span class="font-medium">{{ opt.label }}</span>
<span
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)">
{{ wallClockFor(opt.multiplier) }} for selected scenario
</template>
<template v-else>
{{ opt.multiplier }}×
</template>
</span>
</button>
</div>
<p v-if="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>
</section>
<section aria-labelledby="sim-catalogue-heading">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<h2
id="sim-catalogue-heading"
class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Scenario catalogue
</h2>
</div>
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<label class="block">
<span class="sr-only">Search scenarios</span>
<input
v-model="searchQuery"
type="search"
placeholder="Search scenarios by name, tag, or department…"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 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"
>
</label>
<div v-if="allTags.length" class="flex flex-wrap items-center gap-2">
<Button
v-for="tag in allTags"
:key="tag"
size="sm"
:variant="activeTag === tag ? 'primary' : 'secondary'"
@click="toggleTag(tag)"
>
{{ tag }}
</Button>
<Button
v-if="activeTag"
size="sm"
variant="ghost"
@click="activeTag = null"
>
Clear filter
</Button>
</div>
</div>
<Skeleton v-if="loading && scenarios.length === 0" :rows="3" />
<EmptyState
v-else-if="scenarios.length === 0"
message="No scenarios found. Check that Simulation:ScenarioDirectory is configured and contains scenario JSON files."
/>
<EmptyState
v-else-if="filteredScenarios.length === 0"
message="No scenarios match the current search or tag filter."
/>
<div
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
role="list"
>
<div
v-for="scenario in filteredScenarios"
:key="scenario.id"
role="listitem"
>
<ScenarioCard
:scenario="scenario"
:selected="selectedScenarioId === scenario.id"
:at-concurrency-limit="atConcurrencyLimit"
:max-concurrent-runs="maxConcurrentRuns"
:starting="starting"
@select="selectScenario"
@start="onStart"
/>
</div>
</div>
</section>
</div>
</template>