fix: Replay controls

This commit is contained in:
voltsrage
2026-06-20 14:41:11 +08:00
parent 584d1edd58
commit 7cfc5567fd
9 changed files with 291 additions and 73 deletions
+3 -3
View File
@@ -29,7 +29,7 @@ VITE_API_URL=http://localhost:5270
| `npm run dev` | Vite dev server (port 5173) |
| `npm run build` | Production build |
| `npm run preview` | Preview production build |
| `npm test` | Vitest — 38 tests across 10 files |
| `npm test` | Vitest — 41 tests across 10 files |
## Routes
@@ -48,7 +48,7 @@ VITE_API_URL=http://localhost:5270
| Patient Detail | Vitals, scores, alerts, orders, sepsis bundle; 5 vital charts + NEWS2 history |
| Alert Center | Global alert inbox; acknowledge / resolve; six feedback ratings per alert |
| Alert Reasoning | Plain-language “why it fired” + optional medication context (90 min window) |
| Replay Controls | Local pause/resume/speed scrub through fetched data (not live simulator control) |
| Replay Controls | Local pause/resume/speed scrub; charts/vitals filter by replay clock; **Next Alert →** jumps to each open alert |
| Clinician Feedback | Six ratings + optional notes on every alert; persisted in `localStorage` |
| Feedback Summary | Aggregate stats by alert type; export JSON/CSV for study analysis |
@@ -92,7 +92,7 @@ npm test
| `AlertCard.test.js` | Severity display, acknowledge/resolve, feedback integration |
| `AlertReasoning.test.js` | Reasoning text for sepsis, qSOFA, unknown types |
| `useChartData.test.js` | Chart data transformation |
| `useReplayControls.test.js` | Pause, speed, progress, jump |
| `useReplayControls.test.js` | Scenario bounds, progress, jumpToOffset/Timestamp, isAtOrBefore, instant speed |
| `usePolling.test.js` | Polling interval composable |
| `WardTable.test.js` | Ward table rendering |
| `Badge.test.js` | Severity badge variants |
@@ -1,14 +1,21 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { useReplayControls } from '@/composables/useReplayControls'
describe('useReplayControls', () => {
afterEach(() => {
vi.useRealTimers()
})
it('pauseAndResume', () => {
const { isPaused, pause, resume } = useReplayControls()
expect(isPaused.value).toBe(false)
pause()
expect(isPaused.value).toBe(true)
resume()
expect(isPaused.value).toBe(false)
const replay = useReplayControls()
replay.setScenarioBounds(0, 60_000)
replay.syncToEnd()
expect(replay.isPaused.value).toBe(true)
replay.resume()
expect(replay.isPaused.value).toBe(false)
replay.pause()
expect(replay.isPaused.value).toBe(true)
})
it('speedPresets', () => {
@@ -19,15 +26,42 @@ describe('useReplayControls', () => {
})
it('progressComputation', () => {
const { currentOffsetMinutes, scenarioDurationMinutes, progress } = useReplayControls()
scenarioDurationMinutes.value = 100
currentOffsetMinutes.value = 50
expect(progress.value).toBe(50)
const replay = useReplayControls()
replay.setScenarioBounds(0, 100 * 60_000)
replay.jumpToOffset(50)
expect(replay.progress.value).toBe(50)
})
it('jumpToOffset', () => {
const { currentOffsetMinutes, jumpToOffset } = useReplayControls()
jumpToOffset(42)
expect(currentOffsetMinutes.value).toBe(42)
const replay = useReplayControls()
replay.setScenarioBounds(0, 100 * 60_000)
replay.jumpToOffset(42)
expect(replay.currentOffsetMinutes.value).toBe(42)
})
it('jumpToTimestamp_clampsWithinScenario', () => {
const replay = useReplayControls()
const start = new Date('2026-06-19T12:00:00Z').getTime()
const end = new Date('2026-06-19T14:00:00Z').getTime()
replay.setScenarioBounds(start, end)
replay.jumpToTimestamp('2026-06-19T13:00:00Z')
expect(replay.currentOffsetMinutes.value).toBe(60)
})
it('isAtOrBefore_filtersByCurrentTime', () => {
const replay = useReplayControls()
replay.setScenarioBounds(0, 120 * 60_000)
replay.jumpToOffset(30)
expect(replay.isAtOrBefore(new Date(20 * 60_000).toISOString())).toBe(true)
expect(replay.isAtOrBefore(new Date(40 * 60_000).toISOString())).toBe(false)
})
it('instantSpeedJumpsToEnd', () => {
const replay = useReplayControls()
replay.setScenarioBounds(0, 60_000)
replay.jumpToOffset(10)
replay.setSpeed(0)
expect(replay.progress.value).toBe(100)
expect(replay.isPaused.value).toBe(true)
})
})
@@ -1,5 +1,6 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useAlertStore } from '@/stores/alerts'
import { useSettingsStore } from '@/stores/settings'
import { usePolling } from '@/composables/usePolling'
@@ -20,11 +21,15 @@ const alertStore = useAlertStore()
const settings = useSettingsStore()
const { alerts, loading } = storeToRefs(alertStore)
function loadOpenAlerts() {
return alertStore.loadAlerts(props.encounterId, 'OPEN')
function loadEncounterAlerts() {
return alertStore.loadAlerts(props.encounterId)
}
usePolling(loadOpenAlerts, 5_000)
usePolling(loadEncounterAlerts, 5_000)
const visibleAlerts = computed(() =>
alerts.value.filter(a => a.status !== 'Resolved'),
)
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
@@ -32,12 +37,12 @@ function severityVariant(severity) {
async function acknowledge(alertId) {
await alertStore.acknowledge(alertId, settings.clinicianId)
await loadOpenAlerts()
await loadEncounterAlerts()
}
async function resolve(alertId) {
await alertStore.resolve(alertId)
await loadOpenAlerts()
await loadEncounterAlerts()
}
</script>
@@ -49,10 +54,11 @@ async function resolve(alertId) {
</h2>
</template>
<EmptyState v-if="!loading && alerts.length === 0" message="No open alerts" />
<EmptyState v-if="!loading && visibleAlerts.length === 0" message="No open alerts" />
<ul v-else class="divide-y divide-gray-200 dark:divide-gray-700">
<li
v-for="alert in alerts"
v-for="alert in visibleAlerts"
:id="`alert-row-${alert.id}`"
:key="alert.id"
class="flex cursor-pointer flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
@@ -1,11 +1,15 @@
<script setup>
import { useReplayControls } from '@/composables/useReplayControls'
import Button from '@/components/ui/Button.vue'
const { isPaused, speed, progress, formattedTime, pause, resume, setSpeed } = useReplayControls()
defineProps({
alerts: { type: Array, default: () => [] },
isPaused: { type: Boolean, required: true },
progress: { type: Number, required: true },
formattedTime: { type: String, required: true },
speed: { type: Number, required: true },
})
defineProps({ alerts: { type: Array, default: () => [] } })
const emit = defineEmits(['jump-to-alert'])
const emit = defineEmits(['jump-to-alert', 'pause', 'resume', 'set-speed'])
const speedPresets = [
{ label: '1×', value: 1 },
@@ -17,15 +21,17 @@ const speedPresets = [
<template>
<div class="flex w-full min-w-0 flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900 sm:flex-row sm:items-center">
<Button variant="ghost" size="sm" @click="isPaused ? resume() : pause()">
<Button variant="ghost" size="sm" @click="isPaused ? emit('resume') : emit('pause')">
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
{{ isPaused ? '▶' : '⏸' }}
</Button>
<div class="min-w-0 flex-1">
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-full rounded-full bg-blue-500 transition-all duration-300"
:style="{ width: `${progress}%` }" />
<div
class="h-full rounded-full bg-blue-500 transition-all duration-300"
:style="{ width: `${progress}%` }"
/>
</div>
</div>
@@ -41,16 +47,16 @@ const speedPresets = [
:class="speed === preset.value
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
@click="setSpeed(preset.value)"
@click="emit('set-speed', preset.value)"
>
{{ preset.label }}
</button>
</div>
<div v-if="alerts.length" class="border-t border-gray-200 pt-4 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0 dark:border-gray-700">
<Button variant="ghost" size="sm" @click="$emit('jump-to-alert')">
<Button variant="ghost" size="sm" @click="emit('jump-to-alert')">
Next Alert &rarr;
</Button>
</div>
</div>
</template>
</template>
@@ -1,30 +1,137 @@
import { ref, computed } from 'vue'
export function useReplayControls() {
const isPaused = ref(false)
const speed = ref(60)
const currentOffsetMinutes = ref(0)
const scenarioDurationMinutes = ref(0)
const TICK_MS = 100
const progress = computed(() =>
scenarioDurationMinutes.value > 0
? (currentOffsetMinutes.value / scenarioDurationMinutes.value) * 100
: 0
)
export function useReplayControls() {
const isPaused = ref(true)
const speed = ref(60)
const scenarioStartMs = ref(0)
const scenarioEndMs = ref(0)
const currentMs = ref(0)
let timer = null
const scenarioDurationMinutes = computed(() => {
const span = scenarioEndMs.value - scenarioStartMs.value
return span > 0 ? span / 60_000 : 0
})
const currentOffsetMinutes = computed(() => {
const span = currentMs.value - scenarioStartMs.value
return span > 0 ? span / 60_000 : 0
})
const progress = computed(() => {
const span = scenarioEndMs.value - scenarioStartMs.value
if (span <= 0) return 0
return Math.min(100, ((currentMs.value - scenarioStartMs.value) / span) * 100)
})
const formattedTime = computed(() => {
const h = Math.floor(currentOffsetMinutes.value / 60)
const m = Math.floor(currentOffsetMinutes.value % 60)
const mins = currentOffsetMinutes.value
const h = Math.floor(mins / 60)
const m = Math.floor(mins % 60)
return `${h}:${String(m).padStart(2, '0')}`
})
function pause() { isPaused.value = true }
function resume() { isPaused.value = false }
function setSpeed(s) { speed.value = s }
function jumpToOffset(offset) {
currentOffsetMinutes.value = offset
function setScenarioBounds(startMs, endMs) {
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return
scenarioStartMs.value = startMs
scenarioEndMs.value = endMs
if (currentMs.value < startMs || currentMs.value > endMs) {
currentMs.value = endMs
}
}
return { isPaused, speed, currentOffsetMinutes, scenarioDurationMinutes, progress, formattedTime, pause, resume, setSpeed, jumpToOffset }
}
function syncToEnd() {
if (scenarioEndMs.value > scenarioStartMs.value) {
currentMs.value = scenarioEndMs.value
}
}
function pause() {
isPaused.value = true
stopTick()
}
function resume() {
if (scenarioEndMs.value <= scenarioStartMs.value) return
if (currentMs.value >= scenarioEndMs.value) {
currentMs.value = scenarioStartMs.value
}
isPaused.value = false
startTick()
}
function setSpeed(s) {
speed.value = s
if (s === 0) {
currentMs.value = scenarioEndMs.value
pause()
}
}
function jumpToOffset(minutes) {
if (scenarioEndMs.value <= scenarioStartMs.value) return
const target = scenarioStartMs.value + minutes * 60_000
currentMs.value = Math.min(Math.max(target, scenarioStartMs.value), scenarioEndMs.value)
}
function jumpToTimestamp(iso) {
if (!iso || scenarioEndMs.value <= scenarioStartMs.value) return
const ms = new Date(iso).getTime()
currentMs.value = Math.min(Math.max(ms, scenarioStartMs.value), scenarioEndMs.value)
}
function isAtOrBefore(iso) {
if (!iso) return true
return new Date(iso).getTime() <= currentMs.value
}
function tick() {
if (isPaused.value || scenarioEndMs.value <= scenarioStartMs.value) return
if (speed.value === 0) {
currentMs.value = scenarioEndMs.value
pause()
return
}
currentMs.value = Math.min(currentMs.value + TICK_MS * speed.value, scenarioEndMs.value)
if (currentMs.value >= scenarioEndMs.value) pause()
}
function startTick() {
stopTick()
timer = setInterval(tick, TICK_MS)
}
function stopTick() {
if (timer) clearInterval(timer)
timer = null
}
function stopPlayback() {
stopTick()
}
return {
isPaused,
speed,
scenarioStartMs,
scenarioEndMs,
currentMs,
scenarioDurationMinutes,
currentOffsetMinutes,
progress,
formattedTime,
setScenarioBounds,
syncToEnd,
pause,
resume,
setSpeed,
jumpToOffset,
jumpToTimestamp,
isAtOrBefore,
stopPlayback,
}
}
@@ -1,8 +1,9 @@
<script setup>
import { ref, watch, computed } from 'vue'
import { ref, watch, computed, onBeforeUnmount } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { usePolling } from '@/composables/usePolling'
import { useReplayControls } from '@/composables/useReplayControls'
import { useAlertStore } from '@/stores/alerts'
import * as encountersApi from '@/api/encounters'
import * as clinicalApi from '@/api/clinical'
@@ -20,6 +21,7 @@ import Skeleton from '@/components/ui/Skeleton.vue'
const route = useRoute()
const alertStore = useAlertStore()
const { alerts } = storeToRefs(alertStore)
const replay = useReplayControls()
const encounter = ref(null)
const loading = ref(true)
@@ -36,6 +38,33 @@ const openAlerts = computed(() =>
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
)
const replayObservations = computed(() =>
observations.value.filter(o => replay.isAtOrBefore(o.recordedAt)),
)
const replayNews2History = computed(() =>
news2History.value.filter(h => replay.isAtOrBefore(h.calculatedAt)),
)
function collectScenarioTimes() {
return [
...observations.value.map(o => new Date(o.recordedAt).getTime()),
...news2History.value.map(h => new Date(h.calculatedAt).getTime()),
...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
].filter(Number.isFinite)
}
function syncReplayBounds() {
const times = collectScenarioTimes()
if (!times.length) return
const atEnd = replay.currentMs.value >= replay.scenarioEndMs.value - 1_000
replay.setScenarioBounds(Math.min(...times), Math.max(...times))
if (atEnd || replay.scenarioEndMs.value === replay.scenarioStartMs.value) {
replay.syncToEnd()
}
}
async function loadAll() {
const id = route.params.encounterId
loading.value = true
@@ -49,6 +78,7 @@ async function loadAll() {
clinicalApi.fetchSepsisBundle(id).catch(() => null),
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
])
await alertStore.loadAlerts(id)
encounter.value = enc
observations.value = obs
news2.value = n2
@@ -56,6 +86,7 @@ async function loadAll() {
medications.value = meds
sepsisBundle.value = bundle
orders.value = ord.items ?? ord
syncReplayBounds()
} finally {
loading.value = false
}
@@ -63,6 +94,7 @@ async function loadAll() {
function onSelectAlert(alert) {
selectedAlert.value = alert
replay.jumpToTimestamp(alert.triggeredAt)
}
function jumpToNextAlert() {
@@ -70,8 +102,13 @@ function jumpToNextAlert() {
(a, b) => new Date(a.triggeredAt) - new Date(b.triggeredAt),
)
if (!sorted.length) return
selectedAlert.value = sorted[nextAlertIndex % sorted.length]
const alert = sorted[nextAlertIndex % sorted.length]
selectedAlert.value = alert
nextAlertIndex++
replay.jumpToTimestamp(alert.triggeredAt)
document.getElementById(`alert-row-${alert.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
document.getElementById('clinical-review')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
@@ -80,8 +117,20 @@ usePolling(loadAll, 5_000)
watch(() => route.params.encounterId, () => {
selectedAlert.value = null
nextAlertIndex = 0
replay.pause()
loadAll()
})
watch(openAlerts, (list) => {
nextAlertIndex = 0
if (selectedAlert.value && !list.some(a => a.id === selectedAlert.value.id)) {
selectedAlert.value = null
}
})
watch([observations, news2History, alerts], syncReplayBounds, { deep: true })
onBeforeUnmount(() => replay.stopPlayback())
</script>
<template>
@@ -98,7 +147,7 @@ watch(() => route.params.encounterId, () => {
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<ScoresPanel :news2="news2" :encounter="encounter" />
<VitalsPanel :observations="observations" />
<VitalsPanel :observations="replayObservations" />
<AlertsList
:encounter-id="route.params.encounterId"
:selected-id="selectedAlert?.id"
@@ -118,9 +167,19 @@ watch(() => route.params.encounterId, () => {
</div>
<div id="clinical-review" class="w-full min-w-0 space-y-8">
<TrendsGrid :observations="observations" />
<News2History v-if="news2History.length" :history="news2History" />
<ReplayControls :alerts="openAlerts" @jump-to-alert="jumpToNextAlert" />
<TrendsGrid :observations="replayObservations" />
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
<ReplayControls
:alerts="openAlerts"
:is-paused="replay.isPaused"
:progress="replay.progress"
:formatted-time="replay.formattedTime"
:speed="replay.speed"
@pause="replay.pause()"
@resume="replay.resume()"
@set-speed="replay.setSpeed"
@jump-to-alert="jumpToNextAlert"
/>
</div>
</div>
</template>