From 7cfc5567fd128ae528392a202367aebe3708155f Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 20 Jun 2026 14:41:11 +0800 Subject: [PATCH] fix: Replay controls --- README.md | 8 +- docs/clinical-testing-guide.md | 6 +- docs/dashboard-guide.md | 18 ++- vigilcare-dashboard/README.md | 6 +- .../src/__tests__/useReplayControls.test.js | 62 ++++++-- .../src/components/patient/AlertsList.vue | 20 ++- .../src/components/replay/ReplayControls.vue | 26 ++-- .../src/composables/useReplayControls.js | 147 +++++++++++++++--- .../src/views/PatientDetail.vue | 71 ++++++++- 9 files changed, 291 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index aa25790..9e62dbd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/clinical-testing-guide.md b/docs/clinical-testing-guide.md index 6c4b2e0..c73408a 100644 --- a/docs/clinical-testing-guide.md +++ b/docs/clinical-testing-guide.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6fda3c6..bf9d473 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 | diff --git a/vigilcare-dashboard/README.md b/vigilcare-dashboard/README.md index a38eec6..d6cf7d9 100644 --- a/vigilcare-dashboard/README.md +++ b/vigilcare-dashboard/README.md @@ -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 | diff --git a/vigilcare-dashboard/src/__tests__/useReplayControls.test.js b/vigilcare-dashboard/src/__tests__/useReplayControls.test.js index 033a403..1cc02c6 100644 --- a/vigilcare-dashboard/src/__tests__/useReplayControls.test.js +++ b/vigilcare-dashboard/src/__tests__/useReplayControls.test.js @@ -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) }) }) diff --git a/vigilcare-dashboard/src/components/patient/AlertsList.vue b/vigilcare-dashboard/src/components/patient/AlertsList.vue index c6d0432..a83e615 100644 --- a/vigilcare-dashboard/src/components/patient/AlertsList.vue +++ b/vigilcare-dashboard/src/components/patient/AlertsList.vue @@ -1,5 +1,6 @@ @@ -49,10 +54,11 @@ async function resolve(alertId) { - +
  • -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 = [ \ No newline at end of file + diff --git a/vigilcare-dashboard/src/composables/useReplayControls.js b/vigilcare-dashboard/src/composables/useReplayControls.js index 9e58edc..0e38e51 100644 --- a/vigilcare-dashboard/src/composables/useReplayControls.js +++ b/vigilcare-dashboard/src/composables/useReplayControls.js @@ -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 } -} \ No newline at end of file + 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, + } +} diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 7a44027..a2d43cf 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -1,8 +1,9 @@