fix: Replay controls
This commit is contained in:
@@ -70,7 +70,7 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
|
||||
- **Medication Administration** — `POST /encounters/:id/medications` records drug administrations (name, dose, route, timestamp, administered-by); `GET /encounters/:id/medications` lists with optional `since` filter; `GET /medications/:id` detail; active-encounter guard; FluentValidation on request DTOs
|
||||
- **Medication Correlation Annotations** — `MedicationCorrelationHelper` appends medication context to warning and NEWS2 alert details when a mapped drug was administered within the correlation window (default 90 min); drug-to-vital mappings in `MedicationCorrelation` config (`appsettings.json`); annotates rather than suppresses — alerts still fire; sepsis, trend, and critical sync-path alerts are never annotated; design rationale in `docs/decisions/medication-correlation-design.md`
|
||||
- **Ward Dashboard APIs** — `GET /encounters` returns paginated `WardEncounterSummary` rows (patient name/MRN, room/bed, department, status, latest NEWS2 score, live qSOFA criteria count from Redis, sepsis bundle status, open alert count); filterable by `status` and `department`; `GET /encounters/:id/qsofa/current` exposes Redis-backed qSOFA state; CORS policy `Dashboard` allows configured origins (default `http://localhost:5173`)
|
||||
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (NEWS2-sorted), patient detail (vitals, scores, alerts, orders, sepsis bundle), alert center (global acknowledge/resolve), vital sign trend charts, NEWS2 history chart, local replay controls, alert reasoning with optional medication context, and clinician feedback on every alert; polls API every 5–10 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
|
||||
- **Ward Dashboard Frontend** — Vue 3 SPA (`vigilcare-dashboard/`) with virtual ward table (NEWS2-sorted), patient detail (vitals, scores, alerts, orders, sepsis bundle), alert center (global acknowledge/resolve), vital sign trend charts with local replay scrubbing, NEWS2 history chart, alert reasoning with optional medication context, and clinician feedback on every alert; polls API every 5–10 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
|
||||
- **Clinician Feedback Mode** — six quick ratings per alert (useful, too early, too late, false positive, missing context, would act); optional notes; Feedback Summary with aggregate stats and JSON/CSV export; client-side persistence for product research
|
||||
- **Console Replay Simulator** — standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed (`--speed 0` instant, `60` = 60× faster); commands: `replay`, `replay-all`, `validate`, `dry-run`; optional `--poll` shows alerts, NEWS2, and sepsis bundle state during replay; eight sample scenarios in `VigilCare.Simulator/Scenarios/List/`; user guide in `docs/simulator-guide.md`
|
||||
- **RabbitMQ Notification Workers** — `NotificationPublisherService` reads `alert.generated` from Kafka and publishes paging jobs to `alerts.paging.queue`; `PagingWorkerService` sends the page and waits for acknowledgment; if no ack arrives before timeout it NACKs to `alerts.paging.dlq` with `x-message-ttl = 300000ms`; if the host is stopping, in-flight paging messages are NACKed with `requeue=true` so they are retried after restart and do not false-escalate; `EscalationWorkerService` pages the on-call backup and sets alert status to `escalated`; `DischargeSummaryWorkerService` reads `encounter.status.changed`, generates a discharge summary, and stores it in MinIO under `/discharge-summaries/{encounterId}/summary.pdf`
|
||||
@@ -350,7 +350,7 @@ vigilcare-dashboard/ # Phases 17–19 — Vue 3 war
|
||||
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, chartFormat
|
||||
│ ├── stores/ # Pinia — ward, alerts, settings, feedback (localStorage)
|
||||
│ ├── views/ # WardDashboard, PatientDetail, AlertCenter, FeedbackSummary
|
||||
│ └── __tests__/ # Vitest — 38 tests (store, feedback, charts, alerts, ward)
|
||||
│ └── __tests__/ # Vitest — 41 tests (store, feedback, replay, charts, alerts, ward)
|
||||
├── vite.config.js
|
||||
└── README.md # Dev quick start → docs/dashboard-guide.md
|
||||
|
||||
@@ -530,7 +530,7 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` — **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons (useful, too early, too late, false positive, missing context, would act) and export results from **Feedback Summary** (`/feedback`).
|
||||
Open `http://localhost:5173` — **Virtual Ward** lists active patients sorted by NEWS2 score. Click a row for patient detail (vitals, alerts, charts, replay scrubbing, alert reasoning). Use **Alert Center** for hospital-wide triage. After reviewing alerts, rate them with the six feedback buttons and export results from **Feedback Summary** (`/feedback`).
|
||||
|
||||
Replay a simulator scenario in another terminal to watch charts and alerts populate in real time. Run dashboard tests with `cd vigilcare-dashboard && npm test`. See `docs/dashboard-guide.md` for technical documentation and `docs/clinical-testing-guide.md` for structured clinician evaluation sessions.
|
||||
|
||||
@@ -1436,6 +1436,6 @@ Nineteen phases from the project roadmap are implemented and verified. Integrati
|
||||
| 18 | Clinical review mode — Chart.js vital sign trends (5 charts), NEWS2 history chart, local replay controls, alert reasoning panel, medication context on alerts; `fetchNews2History` / `fetchMedications`; Vitest composable and component tests; `docs/dashboard-guide.md` | Done |
|
||||
| 19 | Clinician feedback mode — six rating buttons per alert, optional notes, Feedback Summary with aggregate stats, JSON/CSV export, localStorage persistence; `docs/clinical-testing-guide.md` for doctor/nurse evaluation sessions | Done |
|
||||
|
||||
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (38 tests: feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table).
|
||||
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (41 tests: replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table).
|
||||
|
||||
**Optional follow-up:** execute and document the Kafka replay demonstration for the data lake (reset `data-lake-writer` offsets, clear MinIO prefixes, restart API, confirm Parquet rebuild). See `docs/plans/phase-9-plan.md` § Replay demonstration.
|
||||
|
||||
@@ -110,7 +110,7 @@ Use the sidebar (desktop) or bottom navigation (mobile) to move between screens.
|
||||
| **Sepsis bundle** | If sepsis was suspected — four time-critical elements and compliance status |
|
||||
| **Vital sign charts** | Trends for HR, RR, systolic BP, SpO₂, temperature |
|
||||
| **NEWS2 history** | How the early warning score changed over time |
|
||||
| **Replay controls** | Local timeline bar (pause, speed, **Next Alert →**) to step through the case |
|
||||
| **Replay controls** | Local timeline bar (pause, speed, **Next Alert →**) — scrubs charts/vitals to a point in time; does not control the simulator |
|
||||
|
||||
**What to notice:** Would you trust these charts and explanations during a real handoff? Is anything missing?
|
||||
|
||||
@@ -271,7 +271,7 @@ Use this during or after your session.
|
||||
- [ ] Vital sign charts show sensible trends
|
||||
- [ ] NEWS2 history chart updates over time
|
||||
- [ ] Sepsis bundle panel appears when sepsis alerts fire
|
||||
- [ ] **Next Alert →** scrolls to review section and selects alerts
|
||||
- [ ] **Next Alert →** selects each open alert, scrubs charts to that time, and scrolls the review section into view
|
||||
|
||||
### Alert Center
|
||||
- [ ] Open / Acknowledged / Resolved tabs filter correctly
|
||||
@@ -330,7 +330,7 @@ No. Rate any alert in any status.
|
||||
*Useful* = good alert clinically. *Would act* = you would specifically change care because of it. An alert can be useful information but not change your plan — use the button that best matches your reasoning.
|
||||
|
||||
**The replay bar doesn’t pause the simulator.**
|
||||
Correct — replay controls only scrub data already loaded in the browser. The facilitator controls simulator speed separately.
|
||||
Correct — replay controls scrub observation/chart data already loaded in the browser (pause, speed, and **Next Alert →** move a local timeline). The facilitator controls simulator speed separately.
|
||||
|
||||
**Dark mode?**
|
||||
Toggle in the header if your eyes prefer it; all screens support dark mode.
|
||||
|
||||
+12
-6
@@ -197,13 +197,17 @@ Five **vital sign trend** line charts (Chart.js via `vue-chartjs`):
|
||||
|
||||
#### Replay controls
|
||||
|
||||
Local playback UI for reviewing scenario timelines:
|
||||
Local playback UI for reviewing scenario timelines against data already loaded for this encounter:
|
||||
|
||||
- Pause / resume, speed presets (1×, 60×, 360×, Instant)
|
||||
- Progress bar and elapsed time display
|
||||
- **Next Alert →** cycles through open alerts chronologically, selects each for reasoning, and scrolls to the chart section
|
||||
- **Pause / resume** — starts paused at the end of the timeline (all data visible). Resume rewinds to the start and plays forward.
|
||||
- **Speed presets** (1×, 60×, 360×, Instant) — advance the replay clock through observation and NEWS2 timestamps; **Instant** jumps to the end.
|
||||
- **Progress bar and elapsed time** — derived from the earliest to latest timestamp across observations, NEWS2 history, and alerts.
|
||||
- **Chart scrubbing** — vitals panel, vital trend charts, and NEWS2 history show only data at or before the replay clock.
|
||||
- **Next Alert →** — cycles open/escalated alerts chronologically, selects each for the reasoning panel, scrubs the replay clock to that alert’s `triggeredAt`, scrolls the alert row into view, and scrolls to the chart section.
|
||||
|
||||
> Replay state is **client-side only**. It does not send commands to the simulator.
|
||||
Selecting an alert row in **Active Alerts** also scrubs the replay clock to that alert’s time.
|
||||
|
||||
> Replay state is **client-side only**. It does not send commands to the simulator. Patient detail loads encounter-scoped alerts on every poll so the alert list and **Next Alert →** are not affected by a prior visit to Alert Center.
|
||||
|
||||
Patient detail data polls every **5 seconds**.
|
||||
|
||||
@@ -232,7 +236,7 @@ Typical demo flow:
|
||||
2. Replay a scenario at moderate speed (`--speed 60`).
|
||||
3. Open **Virtual Ward** — new or updated encounters appear as the simulator creates them.
|
||||
4. Click the patient — watch vitals and charts fill in as observations ingest.
|
||||
5. When alerts fire, click an alert row to read reasoning; try **Next Alert →** on the replay bar.
|
||||
5. When alerts fire, click an alert row to read reasoning (replay scrubs to that time); try **Next Alert →** on the replay bar.
|
||||
6. Use **Alert Center** to acknowledge alerts globally.
|
||||
|
||||
**Suggested scenarios:**
|
||||
@@ -305,6 +309,8 @@ Implementation plans: `docs/plans/phase-17-plan.md` (ward shell + detail), `docs
|
||||
| Blank ward / network errors in console | API not running or wrong URL | Start API; check `VITE_API_URL` |
|
||||
| CORS error in browser | Dashboard origin not allowed | Add origin to API `DashboardOptions` in `appsettings.json` |
|
||||
| Charts crash on mount | Chart data not unwrapped | Ensure `VitalTrendChart` wrapper is used (see `TrendsGrid.vue`) |
|
||||
| Replay bar stuck at 0:00 | No timestamped data yet | Wait for observations/alerts to load; bounds sync after first fetch |
|
||||
| Next Alert shows wrong patient | Stale global alert store | Fixed in Patient Detail — encounter alerts reload on mount/poll |
|
||||
| No patients listed | No active encounters | Run simulator or use seeded API data |
|
||||
| NEWS2 chart empty | Scores not computed yet | Wait for Kafka `news2-scoring` consumer after observations ingest |
|
||||
| Acknowledge fails 400 | Invalid clinician ID | Set `clinicianId` in localStorage or settings store |
|
||||
|
||||
@@ -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,14 +47,14 @@ 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 →
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user