update readme

do  Ward table missing critical clinical columns
This commit is contained in:
voltsrage
2026-06-23 18:24:49 +08:00
parent 5d46200941
commit dd0fd88731
10 changed files with 365 additions and 26 deletions
+55 -18
View File
@@ -2,7 +2,7 @@
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The domain models the observe-alert-acknowledge lifecycle at the center of any clinical monitoring system: patient encounters, continuous vital sign and lab result ingest, real-time sepsis and NEWS2 scoring, and clinician notification with automatic escalation.
**Implementation status:** Twenty-eight planned phases are complete through Phase 31 (plus Phases 2021) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, and **Role-Based Access Control (RBAC) with clinical audit logging**. Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
**Implementation status:** Twenty-nine planned phases are complete through Phase 31 (plus Phases 2022) — from schema and CRUD through Kafka, Elasticsearch CQRS, sepsis detection, RabbitMQ paging with DLQ escalation, reconciliation jobs, Prometheus/Grafana observability, the MinIO Parquet data lake, clinical data model expansion, warning alerts and orders, the NEWS2 composite scoring engine, trend detection with alert suppression, qSOFA bedside screening, medication administration with alert correlation annotations, the console replay simulator, the **Vue 3 ward dashboard**, clinician feedback mode, **Glasgow Coma Scale (GCS) scoring**, **SOFA organ-dysfunction scoring with baseline tracking and delta sepsis alerts**, the **Sepsis-3 clinical refactor** (SIRS removed, qSOFA repositioned as screening, SOFA delta ≥ 2 triggers bundles), **frontend GCS entry and SOFA display**, **expanded simulator scenarios with clinical validation**, the **Site & Gateway Registry** with dual authentication, shared clinical sync contracts, and fleet health Prometheus gauges, the **Ward Gateway Service** (local-first clinical path with offline buffering and central sync), the **FHIR R4 Inbound Facade** for EHR integration, **Role-Based Access Control (RBAC) with clinical audit logging**, and the **Dashboard Gap Analysis Fixes** (SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers on vital charts). Post-phase hardening includes health check endpoints, Kafka poison pill protection, outbox dead-letter with retry tracking, data lake partial-commit safety, MRN sequence-based generation, FHIR bundle transaction rollback, **FHIR R4 read/search endpoints** (Patient and Encounter), **alert threshold deletion with audit trail**, **FHIR API key rotation** (constant-time multi-key validation), **authorization failure logging** with Prometheus metrics, **JWT signing key validation** at startup, and **concurrency hardening** (transactional sepsis bundle creation, unique active encounter constraint). See [Implemented Phases](#implemented-phases) for the full breakdown. Guides: [dashboard-guide.md](docs/dashboard-guide.md) (technical), [clinical-testing-guide.md](docs/clinical-testing-guide.md) (doctors & nurses).
## Domain Model — How It Maps to a Real Clinical System
@@ -62,17 +62,17 @@ An `OutboxEvent` is written in the same transaction as any observation or alert,
- **Outbox Relay** — `IHostedService` polling every 500ms; reads unprocessed outbox rows with `FOR UPDATE SKIP LOCKED` (safe for concurrent instances), publishes to Kafka via idempotent producer (`EnableIdempotence = true`), marks processed; per-event retry tracking (`RetryCount`, `LastError`); events exceeding `OutboxMaxRetries` (default 10) are marked permanently failed (`FailedAt`) and excluded from future polls; partitioned by `encounterId` for per-encounter ordering
- **Kafka Pipeline** — core topics (`observation.recorded`, `alert.generated`, `encounter.status.changed`, `gcs.scored`, sepsis bundle topics) with six partitions each; configurable `ReplicationFactor` (default 3); KRaft mode, no Zookeeper; `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner` (including `gcs.scored` for SOFA CNS re-scoring and `sofa.scored` for downstream consumers); all consumers protected by `PoisonPillGuard` (permanent errors skipped, transient errors retried up to `MaxPoisonRetries`)
- **Elasticsearch CQRS Projection** — `EsIndexerService` consumer group upserts `patient_encounters` documents, appends to the `observations` index, increments `openAlertCount` on alert events, stamps `news2Score` / `news2RiskLevel` when a NEWS2 alert is generated, and projects `sepsisBundleStatus` / `sepsisBundleElementsCompleted` / `sepsisBundleDeadlineAt` from SOFA-triggered sepsis bundle events; patient/encounter search; per-encounter observation trend (hourly avg/min/max); alert volume summary by department and severity; population query (numeric range aggregation across all patients)
- **Sepsis Screening Engine** — `SepsisEngineService` Kafka consumer evaluates qSOFA criteria per encounter using Redis keys with a 30-minute TTL sliding window; qSOFA evaluates respiratory rate ≥ 22, systolic BP ≤ 100, and altered mentation (GCS < 15 or AVPU ≥ 1); on ≥ 2 active criteria and no open screening alert, inserts a `QSOFA_SCREEN` (WARNING-level) alert idempotently (`INSERT WHERE NOT EXISTS`); qSOFA screening recommends ordering SOFA labs — definitive sepsis detection and bundle triggering are handled by `SofaScoringService` via SOFA delta ≥ 2
- **Sepsis Screening Engine** — `SepsisEngineService` Kafka consumer evaluates qSOFA criteria per encounter using Redis keys with a 30-minute TTL sliding window; qSOFA evaluates respiratory rate ≥ 22, systolic BP ≤ 100, and altered mentation (GCS < 15 or AVPU ≥ 1); on ≥ 2 active criteria and no open screening alert, inserts a `QSOFA_SCREEN` (WARNING-level) alert idempotently (`INSERT WHERE NOT EXISTS`); every evaluation is persisted to `qsofa_evaluations` with criteria values and screen-alert-fired flag; `GET /encounters/:id/qsofa/history` provides cursor-paginated evaluation history; qSOFA screening recommends ordering SOFA labs — definitive sepsis detection and bundle triggering are handled by `SofaScoringService` via SOFA delta ≥ 2
- **Sepsis Bundle Compliance** — `SepsisBundleService` creates a four-element treatment bundle (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) when a `SOFA_SEPSIS` alert fires (delta ≥ 2 from baseline); each element maps to an auto-created clinical order (`orderedBy: sepsis-bundle-engine`); one-hour compliance deadline from recognition; `OrderService.RecordResult` calls back to `OnOrderResultedAsync` to mark elements complete; final element completion sets bundle to `COMPLIANT` or `NON_COMPLIANT`; `SepsisBundleMonitorService` scans every 5 minutes for overdue in-progress bundles past their deadline and marks them `NON_COMPLIANT`; idempotent — only one in-progress bundle per encounter; `GET /encounters/:id/sepsis-bundle/current` and `GET /sepsis-bundles/:id` expose bundle state; Kafka topics `sepsis.bundle.created` / `sepsis.bundle.updated`; Prometheus `qsofa_detections_total` and `sepsis_bundle_compliance_total`
- **NEWS2 Composite Scoring Engine** — `News2ScoringService` Kafka consumer (`news2-scoring`) evaluates seven vital parameters per encounter (`RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2`) using Redis keys with a 4-hour TTL; when all seven are present, computes the official NEWS2 aggregate score, persists to `news2_scores`, and creates `NEWS2_WARNING` (score 56 or single param = 3) or `NEWS2_EMERGENCY` (score ≥ 7) alerts idempotently; consciousness resolves GCS-first with AVPU fallback; `GET /encounters/:id/news2/current` and `/history` expose score history; Prometheus `news2_scores_total` and `news2_scoring_duration_seconds`
- **Glasgow Coma Scale (GCS) Scoring** — `GcsScoringService` Kafka consumer (`gcs-scoring`) tracks three components (`GCS_EYE`, `GCS_VERBAL`, `GCS_MOTOR`) in Redis; when all three are present, computes total score and classification (`MILD` / `MODERATE` / `SEVERE`), persists to `gcs_scores`, creates `GCS_CRITICAL` (total ≤ 8) or `GCS_WARNING` (912) alerts idempotently, and publishes `gcs.scored` via outbox for downstream SOFA CNS re-scoring; feeds NEWS2 consciousness and qSOFA altered mentation; `GET /encounters/:id/gcs` exposes the latest score; Prometheus `gcs_scores_total`
- **Glasgow Coma Scale (GCS) Scoring** — `GcsScoringService` Kafka consumer (`gcs-scoring`) tracks three components (`GCS_EYE`, `GCS_VERBAL`, `GCS_MOTOR`) in Redis; when all three are present, computes total score and classification (`MILD` / `MODERATE` / `SEVERE`), persists to `gcs_scores`, creates `GCS_CRITICAL` (total ≤ 8) or `GCS_WARNING` (912) alerts idempotently, and publishes `gcs.scored` via outbox for downstream SOFA CNS re-scoring; feeds NEWS2 consciousness and qSOFA altered mentation; `GET /encounters/:id/gcs` exposes the latest score; `GET /encounters/:id/gcs/history` provides cursor-paginated score history with component breakdown; Prometheus `gcs_scores_total`
- **SOFA Organ-Dysfunction Scoring** — `SofaScoringService` Kafka consumer (`sofa-scoring`) subscribes to `observation.recorded` and `gcs.scored`; scores six organ systems (respiratory, coagulation, liver, cardiovascular, CNS, renal) from Redis lab cache with carry-forward staleness, MAP derivation, SpO₂/FiO₂ fallback, and vasopressor detection from `MedicationAdministration`; persists to `sofa_scores` with baseline tracking (≥ 4 populated organ systems) and delta-from-baseline; delta ≥ 2 creates `SOFA_SEPSIS` (CRITICAL), delta = 1 creates `SOFA_WARNING`; skips stale Kafka events when the encounter row no longer exists; `GET /encounters/:id/sofa` and `/sofa/history` expose scores; Prometheus `sofa_scores_total` and `sofa_scoring_duration_seconds`
- **Trend Detection Engine** — `TrendAnalyzerService` Kafka consumer (`trend-analyzer`) tracks rate-of-change for five vital parameters (`HEART_RATE`, `RESP_RATE`, `SYSTOLIC_BP`, `TEMP_C`, `SPO2`) using Redis sliding-window history; when velocity exceeds configured thresholds (e.g. 72→95 bpm in 30 min), creates a `RAPID_DETERIORATION` alert even if the current value is below warning thresholds; Prometheus `trend_alerts_total` and `trend_analysis_duration_seconds`
- **Alert Suppression Windows** — acknowledging a suppressible alert (`WARNING_*`, `NEWS2_WARNING`) sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min from `AlertSuppression` config; optional per-code override via `alert_thresholds.suppression_window_minutes`); `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts; critical alerts (`CRITICAL_*`, `NEWS2_EMERGENCY`, `SEPSIS_WARNING`, `RAPID_DETERIORATION`) are never suppressed; observations and NEWS2 scores continue to persist during suppression; Prometheus `alert_suppressions_total`
- **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, GCS entry form, SOFA score panel), 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 510 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, GCS entry form, SOFA score panel, patient banner with demographics/allergies/emergency contact, encounter timeline), alert center (global acknowledge/resolve), vital sign trend charts with medication administration markers and local replay scrubbing, NEWS2 history chart, SOFA history chart with organ-system breakdown, GCS history chart with component tracking, qSOFA evaluation history, alert reasoning with optional medication context, and clinician feedback on every alert; polls API every 510 s; guides in `docs/dashboard-guide.md` and `docs/clinical-testing-guide.md`
- **FHIR R4 Inbound Facade** — `POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}` accepts FHIR R4 JSON resources (`application/fhir+json`); `POST /fhir/R4` processes transaction Bundles (Patient → Encounter → Observation in dependency order); `GET /fhir/R4/metadata` returns a CapabilityStatement; LOINC-to-internal code mapping (19 observation codes + SNOMED CT fallbacks); Fahrenheit-to-Celsius unit conversion; `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts; `FhirApiKeyOrJwtMiddleware` authenticates via JWT bearer or `X-Api-Key` header (supports multiple keys via `Fhir:ApiKeys` array for zero-downtime rotation; constant-time comparison via `CryptographicOperations.FixedTimeEquals`); `FhirExceptionFilter` returns FHIR `OperationOutcome` on errors; configurable identifier systems, department codes, and encounter class mappings via `Fhir` config section; Prometheus `fhir_ingest_total` and `fhir_mapping_errors_total`; integration guide for Mirth Connect HL7v2→FHIR channels in `docs/integration/mirth-fhir-channels.md`
- **FHIR R4 Read/Search** — `GET /fhir/R4/Patient/{id}` reads a Patient by internal ID; `GET /fhir/R4/Patient` searches by `identifier` (system|value) or lists all patients; `GET /fhir/R4/Encounter/{id}` reads an Encounter by internal ID; `GET /fhir/R4/Encounter` searches by `patient` (UUID) and/or `status` (`in-progress`, `finished`, `cancelled`); all return FHIR R4 JSON (`application/fhir+json`); search endpoints return `Bundle.type=searchset`; requires `fhir:read` permission (Admin and Integration roles); internal resources mapped back to FHIR via `PatientFhirMapper.ToFhirResponse` / `EncounterFhirMapper.ToFhirResponse` with hospital identifier resolution; Prometheus `fhir_read_total` counter with `resource_type`, `interaction`, `outcome` labels
- **Role-Based Access Control (RBAC)** — JWT bearer authentication (`POST /auth/login`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 granular permissions (`patients:read`, `alerts:acknowledge`, `thresholds:write`, `fhir:ingest`, `fhir:read`, `audit:read`, etc.); `AuthorizePermission` attribute on every controller action; `PermissionAuthorizationHandler` resolves role → permission at runtime from `ClinicalRolePermissionMap` and logs authorization failures with structured details (user, role, permission, endpoint) plus `authorization_failures_total` Prometheus counter; `CurrentUserService` exposes authenticated identity (user ID, display name, role, IP address) to services; nurses and physicians get clinical read/write permissions; admins additionally get `thresholds:write`, `fhir:read`, `audit:read`, and `users:admin`; integration accounts get FHIR ingest and read access; FHIR endpoints accept both JWT and `X-Api-Key` authentication via `FhirApiKeyOrJwtMiddleware`; alert `acknowledgedBy` is set from the authenticated user identity, not the request body; startup validates JWT signing key is at least 256 bits (HMAC-SHA256 minimum); four seeded demo users (`nurse.demo`, `physician.demo`, `admin.demo`, `integration.mirth`); frontend login page with `localStorage` token persistence and automatic `Authorization: Bearer` header injection
@@ -201,13 +201,13 @@ VigilCareClinicalAPI/
│ ├── PatientsController.cs # Patient CRUD, search by name/MRN
│ ├── EncountersController.cs # Encounter list (ward summary), get, status PATCH, timeline
│ ├── MedicationsController.cs # Medication administration create, list, get
│ ├── QsofaController.cs # Current qSOFA criteria count (Redis-backed)
│ ├── QsofaController.cs # Current qSOFA criteria count (Redis-backed) + cursor-paginated evaluation history
│ ├── ObservationsController.cs # Ingest POST, cursor-paginated GET
│ ├── AlertThresholdsController.cs # Threshold CRUD + cache invalidation
│ ├── AlertsController.cs # Alert list (global + per-encounter), acknowledge, resolve
│ ├── OrdersController.cs # Order create, list, get, status transition, record result
│ ├── News2Controller.cs # Current NEWS2 score and cursor-paginated history
│ ├── GcsController.cs # Latest GCS score per encounter
│ ├── GcsController.cs # Latest GCS score and cursor-paginated history per encounter
│ ├── SofaController.cs # Current SOFA score and cursor-paginated history
│ ├── SepsisBundlesController.cs # Current bundle per encounter, bundle detail by ID
│ ├── SitesController.cs # Clinical site CRUD (create, list, get)
@@ -227,6 +227,7 @@ VigilCareClinicalAPI/
│ │ ├── News2Score.cs # Composite score with seven component scores + risk level
│ │ ├── GcsScore.cs # Eye/verbal/motor components, total, classification
│ │ ├── SofaScore.cs # Six organ-system scores, baseline flag, delta, staleness JSON
│ │ ├── QsofaEvaluation.cs # Per-evaluation qSOFA record: criteria count, values, screen alert fired
│ │ ├── OutboxEvent.cs # topic + payload JSONB + processed_at
│ │ ├── ReconciliationAlert.cs
│ │ ├── SepsisBundle.cs # Four-element treatment bundle with 1-hour compliance deadline
@@ -365,7 +366,7 @@ VigilCareClinicalAPI/
├── Sepsis/
│ ├── AlertCreationGuard.cs # Prevents creation of deprecated alert types (SEPSIS_WARNING)
│ ├── QsofaCalculator.cs # Pure static qSOFA scoring (3 criteria, no I/O)
│ ├── QsofaDetector.cs # Redis qSOFA state, QSOFA_SCREEN alert creation
│ ├── QsofaDetector.cs # Redis qSOFA state, QSOFA_SCREEN alert creation, evaluation persistence to qsofa_evaluations
│ └── SepsisAlertHandler.cs # Bridges SOFA_SEPSIS alert → SepsisBundleService
├── News2/
│ ├── News2Calculator.cs # Pure static NEWS2 scoring tables (no I/O)
@@ -401,7 +402,7 @@ VigilCareClinicalAPI/
│ └── Sepsis/QsofaResult.cs, QsofaOutcome.cs # qSOFA detector result and screening outcome enum
├── Data/
│ ├── AppDbContext.cs # EF Core context — entity configs, indexes, constraints
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ ├── Configurations/ # IEntityTypeConfiguration per entity; ClinicalUserConfiguration, ClinicalAuditLogConfiguration, ClinicalSiteConfiguration, WardGatewayConfiguration, QsofaEvaluationConfiguration; ElasticsearchOptions, ElasticIndexOptions
│ └── Seed/
│ ├── DataSeeder.cs # Seeds patients, encounters, thresholds, observations
│ ├── GatewayRegistrySeeder.cs # Seeds demo site (SITE-DEMO) and gateway (GW-ICU-3B) with fixed GUIDs
@@ -459,6 +460,7 @@ tests/
├── SofaScoringTests.cs # SOFA organ scores, baseline, delta alerts, carry-forward, vasopressors
├── BackgroundServiceTests.cs # Outbox relay, Kafka consumer, sepsis bundle monitor, reconciliation
├── ConcurrencyTests.cs # Parallel patient MRN, sepsis bundle, observation idempotency, encounter open
├── GapAnalysisFixTests.cs # Phase 22 — GCS history, qSOFA evaluation persistence/history, encounter timeline
├── GatewayRegistryTests.cs # Gateway register, heartbeat, API key auth, department filter
├── Helpers/GatewayAuthHelper.cs # WithGatewayApiKey extension method for test clients
├── Auth/
@@ -547,14 +549,15 @@ VigilCare.Simulator/ # Phase 16 — console replay
├── Scenarios/ # schema.json, ScenarioLoader, ScenarioValidator
└── Scenarios/List/ # Eleven sample scenarios (sepsis, GCS, SOFA, NEWS2, stable, …)
vigilcare-dashboard/ # Phases 1719, 2728, 31 — Vue 3 ward dashboard SPA
vigilcare-dashboard/ # Phases 1719, 22, 2728, 31 — Vue 3 ward dashboard SPA
├── src/
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA), alerts, normalize
│ ├── components/ # charts, replay, alerts, feedback, patient (GcsEntryForm, SofaScorePanel), ward, layout, ui
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, chartFormat
│ ├── api/ # HTTP client (auto Bearer header), encounters, clinical (GCS, SOFA, qSOFA history), alerts, normalize
│ ├── components/ # charts (SofaHistory, GcsHistory, QsofaHistory, VitalChart with medication markers), replay, alerts, feedback, patient (GcsEntryForm, SofaScorePanel, PatientBanner, EncounterTimeline), ward, layout, ui
│ ├── composables/ # useChartData, useReplayControls, usePolling, useFeedback, useGcs, useSofa, chartFormat, patientFormat, timelineFormat, chartMedications
│ ├── plugins/ # medicationMarkerPlugin (Chart.js plugin for medication administration markers on vital charts)
│ ├── stores/ # Pinia — ward, alerts, settings, feedback, scoring, auth (localStorage token + user)
│ ├── views/ # LoginView, WardDashboard, PatientDetail, AlertCenter, FeedbackSummary
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA)
│ └── __tests__/ # Vitest — tests (store, feedback, replay, charts, alerts, ward, GCS, SOFA, qSOFA, PatientBanner, EncounterTimeline, patientFormat, timelineFormat, chartMedications)
├── vite.config.js
└── README.md # Dev quick start → docs/dashboard-guide.md
@@ -579,6 +582,7 @@ scripts/
├── run-phase28-verification.sh # Phase 28 — Frontend GCS entry + SOFA display + sepsis UI refactor
├── run-phase20-verification.sh # Phase 20 — Gateway registry tests + site/gateway/heartbeat curl checks
├── run-phase21-verification.sh # Phase 21 — Ward gateway local-first path, partition tests, sync upload
├── run-phase22-verification.sh # Phase 22 — Dashboard gap analysis fixes (SOFA/GCS/qSOFA history, patient banner, timeline, medication markers)
├── run-phase29-verification.sh # Phase 29 — Simulator scenario expansion + clinical validation
├── run-phase30-verification.sh # Phase 30 — FHIR R4 ingest integration tests + manual bundle/metadata checks
└── run-phase31-verification.sh # Phase 31 — RBAC integration tests + JWT login + audit log query
@@ -587,6 +591,7 @@ docs/
├── plans/ # Phase implementation and verification guides
├── clinical-testing-guide.md # Doctor/nurse guide — alert review & feedback sessions
├── dashboard-guide.md # VigilCare Dashboard user guide (ward, patient detail, charts)
├── dashboard-gap-analysis.md # Comprehensive gap analysis — P0P5 clinical usefulness assessment
├── patient-encounter-api-lifecycle.md # Full API walkthrough: registration → active stay → discharge
├── simulator-guide.md # VigilCare.Simulator user guide
├── integration/
@@ -834,6 +839,7 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
| `RbacTests` | 31 | Unauthenticated 401, nurse 403 on threshold write, admin threshold update with audit log creation, alert acknowledge uses authenticated user |
| `BackgroundServiceTests` | — | Outbox relay, Kafka consumer, sepsis bundle monitor, reconciliation |
| `ConcurrencyTests` | — | Parallel patient registration unique MRNs, parallel sepsis alerts single bundle, parallel observation idempotency, parallel encounter open duplicate rejection |
| `GapAnalysisFixTests` | 22 | GCS history API, qSOFA evaluation persistence and history API, encounter timeline with patient data |
| `WardGatewayLocalPathTests` | 21 | Local observation ingest, critical/warning alert creation, buffered sync item generation |
| `WardGatewayPartitionTests` | 21 | Network partition simulation — offline buffering, sync upload to central API, encounter replica sync |
@@ -842,6 +848,7 @@ Integration tests use `WebApplicationFactory` with a `Testing` environment and T
With the API running (`dotnet run`) and Docker Compose up:
```bash
./scripts/run-phase22-verification.sh # Dashboard gap analysis fixes — SOFA/GCS/qSOFA history, patient banner, timeline
./scripts/run-phase21-verification.sh # Ward gateway local-first path, partition tests, sync upload verification
./scripts/run-phase20-verification.sh # Gateway registry tests, site/gateway/heartbeat API verification
./scripts/run-phase8-verification.sh # Prometheus target UP, ten metrics, alerts_unacknowledged_gauge live update
@@ -903,6 +910,12 @@ Phase 31 RBAC tests only:
dotnet test --filter "FullyQualifiedName~Rbac"
```
Phase 22 dashboard gap analysis tests only:
```bash
dotnet test --filter "FullyQualifiedName~GapAnalysisFix"
```
Per-phase test runners (subset of `dotnet test`):
```bash
@@ -1019,6 +1032,7 @@ Error response:
| PATCH | `/encounters/{id}/status` | Advance encounter status |
| GET | `/encounters/{id}/timeline` | Merged chronological view: status changes, observations, alerts |
| GET | `/encounters/{id}/qsofa/current` | Current qSOFA active criteria count (03) from Redis |
| GET | `/encounters/{id}/qsofa/history` | Cursor-paginated qSOFA evaluation history |
**GET `/encounters` query params:** `status` (DB literal, e.g. `ACTIVE`), `department` (DB literal, e.g. `ICU`), `page`, `pageSize`
@@ -1235,9 +1249,12 @@ Scores are computed asynchronously by `News2ScoringService` after observations a
| Method | Path | Description |
|---|---|---|
| GET | `/encounters/{id}/gcs` | Latest GCS score for an encounter (404 if none computed) |
| GET | `/encounters/{id}/gcs` | Latest GCS score for an encounter (null data if none computed) |
| GET | `/encounters/{id}/gcs/history` | Cursor-paginated GCS score history |
**Response** includes `eyeScore`, `verbalScore`, `motorScore`, `totalScore` (315), `classification` (`MILD`, `MODERATE`, `SEVERE`), and `calculatedAt`.
**`GET /gcs` response** includes `eyeScore`, `verbalScore`, `motorScore`, `totalScore` (315), `classification` (`MILD`, `MODERATE`, `SEVERE`), and `calculatedAt`.
**`GET /gcs/history` query params:** `limit` (default 20), `cursor` (opaque token from previous response).
All three components (`GCS_EYE`, `GCS_VERBAL`, `GCS_MOTOR`) must be recorded before a score is computed. Scores are asynchronous via `GcsScoringService`. A completed GCS score publishes `gcs.scored` to Kafka (via outbox) for SOFA CNS re-scoring.
@@ -1575,6 +1592,23 @@ calculatedAt DateTimeOffset
Indexes: `(encounter_id, calculated_at DESC)`, partial `(encounter_id) WHERE is_baseline = true`
### QsofaEvaluation
```
id Guid PK
encounterId Guid FK → Encounter
patientId Guid FK → Patient (via Encounter)
activeCriteria int 03 (check constraint)
respRate decimal? respiratory rate value at evaluation time
systolicBp decimal? systolic BP value at evaluation time
avpu decimal? AVPU/GCS value at evaluation time
screenAlertFired bool true if this evaluation triggered a QSOFA_SCREEN alert
evaluatedAt DateTimeOffset
createdAt DateTimeOffset
```
Indexes: `(encounter_id, evaluated_at)`
### Order
```
@@ -1866,7 +1900,7 @@ Redis key pattern: `qsofa:{encounterId}:{code}` with 30-minute TTL. When a crite
**Clinical role:** qSOFA is a bedside screening tool — it identifies patients who should have SOFA labs ordered. It does **not** trigger the sepsis bundle directly. Only `SOFA_SEPSIS` (delta ≥ 2 from baseline) triggers bundle creation. This matches the Sepsis-3 two-tier workflow: screen → confirm → treat.
**API:** `GET /encounters/{id}/qsofa/current` returns `activeCriteria` (03) and per-criterion values from Redis via `QsofaService` — used by ward dashboards and the simulator poll loop.
**API:** `GET /encounters/{id}/qsofa/current` returns `activeCriteria` (03) and per-criterion values from Redis via `QsofaService` — used by ward dashboards and the simulator poll loop. `GET /encounters/{id}/qsofa/history` returns cursor-paginated evaluation history from the `qsofa_evaluations` table, including criteria values and whether a screen alert was fired — used by the `QsofaHistory.vue` dashboard chart.
---
@@ -2000,7 +2034,7 @@ Observation history uses cursor pagination on `(recorded_at DESC, id DESC)`. Off
## Implemented Phases
Twenty-eight phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), and **RBAC with clinical audit logging** (Phase 31). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2021, 2531. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
Twenty-nine phases from the project roadmap are implemented and verified, including the **Site & Gateway Registry** (Phase 20), the **Ward Gateway Service** (Phase 21), the **Dashboard Gap Analysis Fixes** (Phase 22), the **Sepsis-3 clinical refactor** (Phases 2729), the **FHIR R4 Inbound Facade** (Phase 30), and **RBAC with clinical audit logging** (Phase 31). Integration tests (`dotnet test`) and per-phase verification scripts cover Phases 815, 2022, 2531. Phases 1719 add the Vue dashboard and clinician feedback (Vitest in `vigilcare-dashboard/`).
| Phase | Feature | Status |
|---|---|---|
@@ -2022,6 +2056,7 @@ Twenty-eight phases from the project roadmap are implemented and verified, inclu
| 16 | Console replay simulator (`VigilCare.Simulator`); scenario JSON schema; CLI commands `replay`, `replay-all`, `validate`, `dry-run`; speed multiplier and optional API polling; eight sample scenarios; `docs/simulator-guide.md` | Done |
| 20 | **Site & Gateway Registry + Clinical Sync Contracts** — `VigilCare.ClinicalContracts` shared class library with sync DTOs (`ClinicalSyncBatchRequest`, `SyncedObservation`, `SyncedAlertEvent`, `GatewayHeartbeatRequest`); `ClinicalSite` and `WardGateway` domain entities with EF Core configurations and migrations; `GatewayApiKeyAuthenticationHandler` (constant-time `X-Api-Key` validation + `X-Gateway-Id` claim) registered alongside JWT bearer; `SitesController` (create, list, get) and `GatewaysController` (register, list, get, heartbeat) with dual auth — JWT + `users:admin` for admin CRUD, gateway API key for heartbeat; `SiteService`, `GatewayRegistryService`; FluentValidation on `CreateSiteRequest`, `RegisterGatewayRequest`, `GatewayHeartbeatRequest`; `GatewayRegistrySeeder` with fixed GUIDs for demo site and gateway; `WardGatewayMetricsCollector` (60s periodic) exports `ward_gateways_offline_gauge` and `ward_gateway_buffer_depth`; `GatewayRegistryTests` (register, heartbeat, API key 401, degraded status, department filter); `ClinicalContractsTests` (JSON round-trip); `run-phase20-verification.sh` | Done |
| 21 | **Ward Gateway Service (Local-First Clinical Path)** — `VigilCare.WardGateway` standalone ASP.NET Core 8 deployable with its own PostgreSQL (`vigilcare_ward`), Redis, and RabbitMQ; domain entities mirror central API (`ReplicaPatient`, `ReplicaEncounter`, `LocalObservation`, `LocalClinicalAlert`, `ReplicaAlertThreshold`); `LocalObservationService` ingests observations locally with plausibility validation and synchronous critical alert creation; `LocalWarningEvaluator` creates warning-range alerts from Redis-cached thresholds; `BufferedSyncWriter` writes observation and alert events to `buffered_sync_items` table for upload when online; `EncounterReplicaSyncService` pulls patient/encounter data from central API on startup; `ThresholdCacheLoader` fetches alert thresholds from central API into local Redis; `CentralReachabilityService` polls central API health every 30s; `GatewayHeartbeatService` reports gateway status and buffer depth to central registry; `SyncUploaderService` batches buffered sync items and uploads to central API using `ClinicalSyncBatchRequest` contracts when online; local RabbitMQ paging (`LocalPagingWorkerService`) and escalation (`LocalEscalationWorkerService`) for ward-level clinician notification; `EncounterReadService` provides ward encounter list and detail views; Docker Compose `ward-gateway` profile with separate PostgreSQL, Redis, and RabbitMQ; health checks (Redis, RabbitMQ, encounter replica readiness); `WardGatewayLocalPathTests` and `WardGatewayPartitionTests` integration tests with Testcontainers; `run-phase21-verification.sh` | Done |
| 22 | **Dashboard Gap Analysis Fixes** — SOFA history chart (`SofaHistory.vue`) with organ-system breakdown; GCS history chart (`GcsHistory.vue`) with component tracking; qSOFA evaluation history (`QsofaHistory.vue`) backed by new `qsofa_evaluations` table and `GET /qsofa/history` API; patient banner (`PatientBanner.vue`) with demographics, age, blood type, allergies, emergency contact; encounter timeline (`EncounterTimeline.vue`) with merged chronological status/observation/alert events; medication administration markers on vital trend charts (`medicationMarkerPlugin.js`); composables `patientFormat.js`, `timelineFormat.js`, `chartMedications.js`; `GET /encounters/{id}/gcs/history` cursor-paginated GCS history endpoint; `QsofaDetector` now persists every evaluation; `GapAnalysisFixTests` integration tests; Vitest tests for all new components; `run-phase22-verification.sh`; `docs/dashboard-gap-analysis.md` | Done |
| 17 | Ward dashboard shell — Vue 3 + Vite + Pinia + Tailwind; virtual ward table (NEWS2-sorted, department filter); patient detail (vitals, scores, alerts, orders, sepsis bundle); alert center (global acknowledge/resolve); API polling; CORS-backed `GET /encounters` ward list | Done |
| 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 |
@@ -2033,12 +2068,14 @@ Twenty-eight phases from the project roadmap are implemented and verified, inclu
| 30 | **FHIR R4 Inbound Facade** — `FhirIngestController` (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`); `FhirMetadataController` (CapabilityStatement); `FhirBundleProcessor` (transaction Bundles in dependency order); `LoincCodeMapper` (19 LOINC + 3 SNOMED CT → internal codes); `FhirUnitConverter` (°F→°C); `ExternalResourceIdentifier` table + `ExternalIdentifierService` for hospital MRN/visit number ↔ internal UUID linking; `FhirApiKeyMiddleware` (`X-Api-Key` auth); `FhirExceptionFilter` (→ OperationOutcome); `PatientFhirMapper`, `EncounterFhirMapper`, `ObservationFhirMapper`, `MedicationAdministrationFhirMapper`, `FhirReferenceResolver`; idempotent patient/encounter upserts (`RegisterOrUpdateByIdentifierAsync`, `OpenOrUpdateByIdentifierAsync`); configurable identifier systems, department codes, encounter class maps (`FhirOptions`); Prometheus `fhir_ingest_total`, `fhir_mapping_errors_total`; Mirth Connect integration guide; `FhirIngestTests`; `run-phase30-verification.sh` | Done |
| 31 | **RBAC + Clinical Audit Logging** — JWT bearer authentication (`AuthService`, `AuthController`); four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) with 17 granular permissions; `AuthorizePermission` attribute on all controller actions; `PermissionAuthorizationHandler` + `PermissionPolicyProvider` resolve `perm:*` policies; `CurrentUserService` extracts identity from JWT claims; `ClinicalUser` entity with BCrypt password hashing; `ClinicalAuditLog` append-only table with before/after JSONB, user identity, IP, and correlation ID; `AuditService` writes log entries on clinical write actions (10 audit actions); `AuditLogsController` admin-only query with filters; `FhirApiKeyOrJwtMiddleware` dual auth for FHIR routes (JWT or X-Api-Key with multi-key rotation); alert `acknowledgedBy` set from authenticated user, not request body; four seeded demo users; frontend `LoginView` + `auth` Pinia store with `localStorage` token persistence; Vue router auth guard; `RbacTests`; `run-phase31-verification.sh` | Done |
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, GCS entry, SOFA panel, scores panel, alert labels).
**Ward dashboard:** backend APIs (`GET /encounters` ward list, `GET /qsofa/current`, `GET /qsofa/history`, `GET /gcs/history`, CORS) and frontend SPA — `EncountersListTests`, `QsofaCurrentTests`, `GapAnalysisFixTests`, `vigilcare-dashboard` Vitest suite (replay scrubbing, feedback store, FeedbackButtons, FeedbackSummary, alert components, charts, ward table, GCS entry/history, SOFA panel/history, qSOFA history, scores panel, alert labels, PatientBanner, EncounterTimeline, medication chart markers).
**Site & Gateway Registry (Phase 20):** Central API manages clinical sites and ward edge nodes (gateways). Gateways authenticate via API key for heartbeat and sync upload. Shared `VigilCare.ClinicalContracts` class library defines sync DTOs consumed by both central API and ward gateway projects. Prometheus fleet health gauges track offline gateways and buffer depth per site.
**Ward Gateway (Phase 21):** `VigilCare.WardGateway` is a separate ASP.NET deployable with its own PostgreSQL (`vigilcare_ward`), Redis, and RabbitMQ — a local-first clinical path for ward edge nodes. Docker Compose services use the `ward-gateway` profile — start with `docker compose --profile ward-gateway up -d`. Observations are ingested locally with threshold evaluation and critical alert creation, then buffered for upload to the central API when the network link is available. Background services replicate encounter/patient data and alert thresholds from central on startup, report heartbeat status, and batch-upload buffered sync items. Local RabbitMQ provides ward-level paging and escalation independent of central connectivity. Integration tests (`WardGatewayLocalPathTests`, `WardGatewayPartitionTests`) validate the local ingest path and network partition/recovery workflow with Testcontainers.
**Dashboard gap analysis (Phase 22):** Addresses P0P1 clinical gaps identified in `docs/dashboard-gap-analysis.md`. SOFA, GCS, and qSOFA now have history charts matching the existing NEWS2 history pattern. Patient detail gains a demographic banner (age, blood type, allergies, emergency contact) and an encounter timeline merging status changes, observation summaries, and alerts into a single chronological view. Vital trend charts overlay medication administration markers so clinicians can correlate drug timing with vital changes. Backend additions: `GET /gcs/history` cursor-paginated endpoint, `qsofa_evaluations` table with `GET /qsofa/history` for evaluation persistence.
**Scoring pipeline (Phases 2526):** GCS components → `gcs_scores` + `gcs.scored` → SOFA CNS organ system; SOFA lab/vital observations → `sofa_scores` with baseline tracking → delta sepsis alerts when organ dysfunction worsens.
**Sepsis-3 refactor (Phases 2729):** SIRS removed; qSOFA repositioned as bedside screening (`QSOFA_SCREEN`); SOFA delta ≥ 2 triggers `SOFA_SEPSIS` → sepsis bundle. Frontend gains GCS entry form and SOFA score panel. Eleven simulator scenarios validate the full clinical pipeline end-to-end.
@@ -85,6 +85,32 @@ public class EncountersListTests : IAsyncLifetime
TriggeredAt = DateTimeOffset.UtcNow
});
db.SofaScores.Add(new SofaScore
{
Id = Guid.NewGuid(), EncounterId = _activeIcuEncounterId, PatientId = patientIcu.Id,
TotalScore = 8, RespiratoryScore = 2, CoagulationScore = 1, LiverScore = 1,
CardiovascularScore = 2, CnsScore = 1, RenalScore = 1,
DeltaFromBaseline = 2, CalculatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
});
db.GcsScores.Add(new GcsScore
{
Id = Guid.NewGuid(), EncounterId = _activeIcuEncounterId, PatientId = patientIcu.Id,
EyeScore = 3, VerbalScore = 4, MotorScore = 5, TotalScore = 12,
Classification = "MODERATE", CalculatedAt = DateTimeOffset.UtcNow,
CreatedAt = DateTimeOffset.UtcNow
});
db.Observations.Add(new Observation
{
Id = Guid.NewGuid(), EncounterId = _activeIcuEncounterId,
ObservationCode = "HEART_RATE", Value = 110m, Unit = "bpm",
Source = ObservationSource.Manual,
RecordedAt = DateTimeOffset.UtcNow.AddHours(-3),
CreatedAt = DateTimeOffset.UtcNow
});
await db.SaveChangesAsync();
}
@@ -110,6 +136,12 @@ public class EncountersListTests : IAsyncLifetime
icu.GetProperty("news2Score").GetInt32().Should().Be(7);
icu.GetProperty("openAlertCount").GetInt32().Should().Be(1);
icu.GetProperty("qsofaScore").GetInt32().Should().Be(0);
icu.GetProperty("sofaScore").GetInt32().Should().Be(8);
icu.GetProperty("sofaDelta").GetInt32().Should().Be(2);
icu.GetProperty("gcsScore").GetInt32().Should().Be(12);
icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE");
icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward");
icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default);
}
[Fact]
@@ -12,4 +12,11 @@ public record WardEncounterSummary(
int QsofaScore,
bool SepsisActive,
SepsisBundleComplianceStatus? SepsisBundleStatus,
int OpenAlertCount);
int OpenAlertCount,
int? SofaScore,
int? SofaDelta,
int? GcsScore,
string? GcsClassification,
DateTimeOffset? LastObservationAt,
string AttendingPhysician,
DateTimeOffset AdmittedAt);
@@ -97,12 +97,38 @@ public class EncounterService : IEncounterService
.GroupBy(b => b.EncounterId)
.ToDictionary(g => g.Key, g => g.First());
var sofaByEncounter = (await _db.SofaScores
.AsNoTracking()
.Where(s => encounterIds.Contains(s.EncounterId))
.OrderByDescending(s => s.CalculatedAt)
.ToListAsync())
.GroupBy(s => s.EncounterId)
.ToDictionary(g => g.Key, g => g.First());
var gcsByEncounter = (await _db.GcsScores
.AsNoTracking()
.Where(s => encounterIds.Contains(s.EncounterId))
.OrderByDescending(s => s.CalculatedAt)
.ToListAsync())
.GroupBy(s => s.EncounterId)
.ToDictionary(g => g.Key, g => g.First());
var lastObservationByEncounter = await _db.Observations
.AsNoTracking()
.Where(o => encounterIds.Contains(o.EncounterId))
.GroupBy(o => o.EncounterId)
.Select(g => new { EncounterId = g.Key, LastAt = g.Max(o => o.RecordedAt) })
.ToDictionaryAsync(x => x.EncounterId, x => x.LastAt);
var summaries = new List<WardEncounterSummary>(encounters.Count);
foreach (var encounter in encounters)
{
news2ByEncounter.TryGetValue(encounter.Id, out var news2);
bundlesByEncounter.TryGetValue(encounter.Id, out var bundle);
openAlertCounts.TryGetValue(encounter.Id, out var openCount);
sofaByEncounter.TryGetValue(encounter.Id, out var sofa);
gcsByEncounter.TryGetValue(encounter.Id, out var gcs);
lastObservationByEncounter.TryGetValue(encounter.Id, out var lastObservationAt);
var qsofaScore = await _qsofa.GetActiveCriteriaCountAsync(encounter.Id);
@@ -120,7 +146,14 @@ public class EncounterService : IEncounterService
qsofaScore,
bundle is not null && bundle.ComplianceStatus != SepsisBundleComplianceStatus.Compliant,
bundle?.ComplianceStatus,
openCount));
openCount,
sofa?.TotalScore,
sofa?.DeltaFromBaseline,
gcs?.TotalScore,
gcs?.Classification,
lastObservationAt,
encounter.AttendingPhysician,
encounter.AdmittedAt));
}
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
@@ -16,9 +16,16 @@ const patients = [
firstName: 'Alice',
lastName: 'A',
mrn: 'M1',
room: '101',
roomBed: '101',
status: 'Active',
news2Score: 3,
sofaScore: 4,
sofaDelta: 0,
gcsScore: 15,
qsofaScore: 0,
attendingPhysician: 'Dr. A',
admittedAt: '2026-06-21T10:00:00Z',
lastObservationAt: '2026-06-23T13:00:00Z',
sepsisActive: false,
openAlertCount: 0,
},
@@ -27,9 +34,16 @@ const patients = [
firstName: 'Bob',
lastName: 'B',
mrn: 'M2',
room: '102',
roomBed: '102',
status: 'Active',
news2Score: 5,
sofaScore: 6,
sofaDelta: 1,
gcsScore: 12,
qsofaScore: 1,
attendingPhysician: 'Dr. B',
admittedAt: '2026-06-22T10:00:00Z',
lastObservationAt: '2026-06-23T10:00:00Z',
sepsisActive: false,
openAlertCount: 1,
},
@@ -38,9 +52,16 @@ const patients = [
firstName: 'Carol',
lastName: 'C',
mrn: 'M3',
room: '103',
roomBed: '103',
status: 'Active',
news2Score: 8,
sofaScore: 10,
sofaDelta: 2,
gcsScore: 8,
qsofaScore: 2,
attendingPhysician: 'Dr. C',
admittedAt: '2026-06-20T10:00:00Z',
lastObservationAt: '2026-06-23T08:00:00Z',
sepsisActive: true,
openAlertCount: 3,
},
@@ -67,4 +88,14 @@ describe('WardTable', () => {
const highRiskRow = wrapper.findAll('tbody tr')[2]
expect(highRiskRow.html()).toContain('text-severity-critical')
})
it('showsExtendedClinicalColumns', () => {
const wrapper = mount(WardTable, { props: { patients } })
const header = wrapper.find('thead').text()
expect(header).toContain('SOFA')
expect(header).toContain('GCS')
expect(header).toContain('Last vitals')
expect(wrapper.text()).toContain('Dr. C')
expect(wrapper.text()).toContain('Δ+2')
})
})
@@ -0,0 +1,41 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
formatLengthOfStay,
observationStaleness,
patientRoom,
stalenessClass,
} from '@/composables/wardFormat'
describe('wardFormat', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-06-23T14:00:00Z'))
})
afterEach(() => {
vi.useRealTimers()
})
it('formatsLengthOfStay', () => {
expect(formatLengthOfStay('2026-06-21T14:00:00Z')).toBe('2d')
expect(formatLengthOfStay('2026-06-23T10:00:00Z')).toBe('4h')
})
it('detectsStaleObservations', () => {
expect(observationStaleness('2026-06-23T13:00:00Z', 'Active')).toBe('fresh')
expect(observationStaleness('2026-06-23T10:00:00Z', 'Active')).toBe('stale')
expect(observationStaleness('2026-06-23T08:00:00Z', 'Active')).toBe('critical')
expect(observationStaleness(null, 'Active')).toBe('missing')
expect(observationStaleness('2026-06-23T08:00:00Z', 'Discharged')).toBeNull()
})
it('mapsStalenessClasses', () => {
expect(stalenessClass('stale')).toContain('amber')
expect(stalenessClass('critical')).toContain('red')
})
it('usesRoomBedWithFallback', () => {
expect(patientRoom({ roomBed: 'ICU-1' })).toBe('ICU-1')
expect(patientRoom({ room: '101' })).toBe('101')
})
})
@@ -1,6 +1,14 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
import {
formatGcsClassification,
formatLastObservation,
formatLengthOfStay,
observationStaleness,
patientRoom,
stalenessClass,
} from '@/composables/wardFormat'
const props = defineProps({ patient: { type: Object, required: true } })
@@ -10,6 +18,10 @@ const riskVariant = computed(() => {
if (score >= 5) return 'warning'
return 'success'
})
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
</script>
<template>
@@ -20,7 +32,7 @@ const riskVariant = computed(() => {
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Room {{ patient.room ?? '—' }}
Room {{ patientRoom(patient) }}
</span>
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
</div>
@@ -28,17 +40,50 @@ const riskVariant = computed(() => {
{{ patient.firstName }} {{ patient.lastName }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</p>
<p v-if="patient.attendingPhysician" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ patient.attendingPhysician }}
</p>
</div>
<Badge :variant="riskVariant">NEWS2 {{ patient.news2Score ?? '—' }}</Badge>
</div>
<dl class="mt-4 grid grid-cols-2 gap-4 border-t border-gray-100 pt-4 dark:border-gray-800">
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">SOFA</dt>
<dd class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ patient.sofaScore ?? '—' }}
<span v-if="patient.sofaDelta != null && patient.sofaDelta > 0" class="text-xs text-amber-600 dark:text-amber-400">
Δ+{{ patient.sofaDelta }}
</span>
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">GCS</dt>
<dd class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ patient.gcsScore ?? '—' }}
<span v-if="patient.gcsClassification" class="text-xs text-gray-500 dark:text-gray-400">
{{ formatGcsClassification(patient.gcsClassification) }}
</span>
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">qSOFA</dt>
<dd class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ patient.qsofaScore ?? '—' }}
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">LOS</dt>
<dd class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
{{ formatLengthOfStay(patient.admittedAt) }}
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">Last vitals</dt>
<dd class="mt-2 text-sm font-medium" :class="stalenessClass(vitalsStaleness)">
{{ formatLastObservation(patient.lastObservationAt) }}
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">Open alerts</dt>
<dd class="mt-2">
@@ -1,6 +1,14 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
import {
formatGcsClassification,
formatLastObservation,
formatLengthOfStay,
observationStaleness,
patientRoom,
stalenessClass,
} from '@/composables/wardFormat'
const props = defineProps({ patient: { type: Object, required: true } })
@@ -10,12 +18,31 @@ const riskVariant = computed(() => {
if (score >= 5) return 'warning'
return 'success'
})
const sofaVariant = computed(() => {
const delta = props.patient.sofaDelta
if (delta != null && delta >= 2) return 'critical'
if (delta != null && delta === 1) return 'warning'
return 'info'
})
const gcsVariant = computed(() => {
const score = props.patient.gcsScore
if (score == null) return 'info'
if (score <= 8) return 'critical'
if (score <= 12) return 'warning'
return 'success'
})
const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status),
)
</script>
<template>
<tr>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ patient.room ?? '—' }}
{{ patientRoom(patient) }}
</td>
<td class="px-4 py-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
@@ -26,9 +53,34 @@ const riskVariant = computed(() => {
<td class="whitespace-nowrap px-4 py-4 text-right">
<Badge :variant="riskVariant">{{ patient.news2Score ?? '—' }}</Badge>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
<div class="flex flex-col items-end gap-1">
<span class="font-medium text-gray-900 dark:text-white">{{ patient.sofaScore ?? '—' }}</span>
<Badge v-if="patient.sofaDelta != null && patient.sofaDelta > 0" :variant="sofaVariant" size="xs">
Δ+{{ patient.sofaDelta }}
</Badge>
</div>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm">
<div class="flex flex-col items-end gap-1">
<Badge :variant="gcsVariant">{{ patient.gcsScore ?? '—' }}</Badge>
<span v-if="patient.gcsClassification" class="text-xs text-gray-500 dark:text-gray-400">
{{ formatGcsClassification(patient.gcsClassification) }}
</span>
</div>
</td>
<td class="whitespace-nowrap px-4 py-4 text-right text-sm text-gray-700 dark:text-gray-300">
{{ patient.qsofaScore ?? '—' }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ patient.attendingPhysician ?? '—' }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ formatLengthOfStay(patient.admittedAt) }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm" :class="stalenessClass(vitalsStaleness)">
{{ formatLastObservation(patient.lastObservationAt) }}
</td>
<td class="whitespace-nowrap px-4 py-4">
<Badge v-if="patient.sepsisActive" variant="critical">Active</Badge>
<span v-else class="text-sm text-gray-400">No</span>
@@ -40,4 +92,4 @@ const riskVariant = computed(() => {
<span v-else class="text-sm text-gray-400">0</span>
</td>
</tr>
</template>
</template>
@@ -30,7 +30,12 @@ function goToPatient(encounterId) {
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Room</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Patient</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">NEWS2</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">SOFA</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">GCS</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">qSOFA</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Attending</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">LOS</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Last vitals</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Sepsis</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Alerts</th>
</tr>
@@ -0,0 +1,56 @@
const STALE_MS = 2 * 60 * 60 * 1000
const CRITICAL_STALE_MS = 4 * 60 * 60 * 1000
export function formatLengthOfStay(admittedAt, asOf = new Date()) {
if (!admittedAt) return '—'
const start = new Date(admittedAt).getTime()
if (Number.isNaN(start)) return '—'
const diffMs = Math.max(0, asOf.getTime() - start)
const days = Math.floor(diffMs / (24 * 60 * 60 * 1000))
if (days >= 1) return `${days}d`
const hours = Math.floor(diffMs / (60 * 60 * 1000))
if (hours >= 1) return `${hours}h`
return '<1h'
}
export function observationStaleness(lastObservationAt, status, asOf = new Date()) {
if (status && status !== 'Active') return null
if (!lastObservationAt) return 'missing'
const ageMs = asOf.getTime() - new Date(lastObservationAt).getTime()
if (ageMs > CRITICAL_STALE_MS) return 'critical'
if (ageMs > STALE_MS) return 'stale'
return 'fresh'
}
export function formatLastObservation(lastObservationAt) {
if (!lastObservationAt) return 'No vitals'
return new Date(lastObservationAt).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function stalenessClass(level) {
if (level === 'critical') return 'text-red-600 dark:text-red-400'
if (level === 'stale') return 'text-amber-600 dark:text-amber-400'
if (level === 'missing') return 'text-gray-400 dark:text-gray-500'
return 'text-gray-700 dark:text-gray-300'
}
export function formatGcsClassification(classification) {
if (!classification) return ''
const map = {
MILD: 'Mild',
MODERATE: 'Mod',
SEVERE: 'Severe',
}
return map[classification] ?? classification
}
export function patientRoom(patient) {
return patient.roomBed ?? patient.room ?? '—'
}