feature: Improve dashboard functionality

This commit is contained in:
voltsrage
2026-06-23 20:19:32 +08:00
parent dd0fd88731
commit 79b6d9d85e
60 changed files with 2857 additions and 164 deletions
@@ -64,7 +64,7 @@ public class AlertLifecycleTests : IAsyncLifetime
body!.RootElement.GetProperty("data").GetProperty("status").GetString()
.Should().Be("Acknowledged");
body.RootElement.GetProperty("data").GetProperty("acknowledgedBy").GetString()
.Should().Be("Test NURSE");
.Should().Be("Test NURSE (NURSE)");
}
[Fact]
@@ -274,8 +274,14 @@ public class GapAnalysisFixTests : IAsyncLifetime
var data = resp.GetProperty("data");
data.GetProperty("totalCount").GetInt32().Should().BeGreaterThanOrEqualTo(1);
data.GetProperty("items").EnumerateArray().Should().Contain(b =>
b.GetProperty("complianceStatus").GetString() == "IN_PROGRESS");
var bundle = data.GetProperty("items").EnumerateArray()
.First(b => b.GetProperty("encounterId").GetGuid() == encounter.Id);
bundle.GetProperty("complianceStatus").GetString().Should().Be("IN_PROGRESS");
bundle.GetProperty("firstName").GetString().Should().Be("List");
bundle.GetProperty("lastName").GetString().Should().Be("Test");
bundle.GetProperty("mrn").GetString().Should().Be("MRN-LIST-001");
bundle.GetProperty("department").GetString().Should().Be("Icu");
bundle.GetProperty("elements").EnumerateArray().Should().HaveCount(4);
}
// -------------------------------------------------------------------------
@@ -0,0 +1,20 @@
public record SepsisBundleElementSummary(
Guid Id,
SepsisBundleElementType ElementType,
SepsisBundleElementStatus Status,
DateTimeOffset? CompletedAt);
public record SepsisBundleSummary(
Guid Id,
Guid EncounterId,
string Mrn,
string FirstName,
string LastName,
string? RoomBed,
Department Department,
string TriggeringAlertType,
DateTimeOffset RecognizedAt,
DateTimeOffset DeadlineAt,
SepsisBundleComplianceStatus ComplianceStatus,
DateTimeOffset? CompletedAt,
IReadOnlyList<SepsisBundleElementSummary> Elements);
+18 -6
View File
@@ -98,10 +98,14 @@ public class AlertService : IAlertService
$"Alert cannot be acknowledged from status '{alert.Status}'.",
"ALERT_NOT_ACKNOWLEDGEABLE");
var roleLabel = _currentUser.Role?.ToDbString() ?? "UNKNOWN";
var userId = _currentUser.UserId;
var acknowledgmentNote = FormatAcknowledgmentNote(roleLabel, displayName, req.Note);
var previousStatus = alert.Status;
alert.Status = AlertStatus.Acknowledged;
alert.AcknowledgedAt = DateTimeOffset.UtcNow;
alert.AcknowledgedBy = displayName;
alert.AcknowledgedBy = $"{displayName} ({roleLabel})";
// Write an outbox event so the Kafka consumer (Phase 6) can cancel the
// pending RabbitMQ escalation timer when it sees this acknowledgment.
@@ -113,9 +117,11 @@ public class AlertService : IAlertService
{
alertId = alert.Id,
encounterId = alert.EncounterId,
acknowledgedBy = displayName,
acknowledgedBy = alert.AcknowledgedBy,
userId,
role = roleLabel,
acknowledgedAt = alert.AcknowledgedAt,
note = req.Note
note = acknowledgmentNote
}),
PartitionKey = alert.EncounterId.ToString(),
CreatedAt = DateTimeOffset.UtcNow
@@ -141,8 +147,8 @@ public class AlertService : IAlertService
"ClinicalAlert",
alert.Id,
previousValue: new { status = previousStatus.ToDbString() },
newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy },
reason: req.Note);
newValue: new { status = alert.Status.ToDbString(), alert.AcknowledgedBy, role = roleLabel, userId },
reason: acknowledgmentNote);
if (alert.AlertType.IsSuppressible())
{
@@ -151,7 +157,7 @@ public class AlertService : IAlertService
"ClinicalAlert",
alert.Id,
newValue: new { alert.AlertType, alert.EncounterId },
reason: req.Note);
reason: acknowledgmentNote);
}
return alert;
@@ -244,4 +250,10 @@ public class AlertService : IAlertService
alert.ResolvedAt = resolve.ResolvedAt;
await _db.SaveChangesAsync(ct);
}
private static string FormatAcknowledgmentNote(string roleLabel, string displayName, string? note)
{
var prefix = $"[{roleLabel}] Acknowledged by {displayName}.";
return string.IsNullOrWhiteSpace(note) ? prefix : $"{prefix} {note.Trim()}";
}
}
@@ -4,6 +4,6 @@ public interface ISepsisBundleService
Guid encounterId, Guid triggeringAlertId, AlertType alertType, CancellationToken ct = default);
Task<SepsisBundle?> GetCurrentByEncounterAsync(Guid encounterId);
Task<SepsisBundle> GetByIdAsync(Guid id);
Task<PagedResult<SepsisBundle>> ListAsync(SepsisBundleComplianceStatus? status, int page, int pageSize);
Task<PagedResult<SepsisBundleSummary>> ListAsync(SepsisBundleComplianceStatus? status, int page, int pageSize);
Task OnOrderResultedAsync(Guid orderId, CancellationToken ct = default);
}
@@ -124,7 +124,7 @@ public class SepsisBundleService : ISepsisBundleService
return bundle;
}
public async Task<PagedResult<SepsisBundle>> ListAsync(
public async Task<PagedResult<SepsisBundleSummary>> ListAsync(
SepsisBundleComplianceStatus? status, int page, int pageSize)
{
pageSize = Math.Clamp(pageSize, 1, 100);
@@ -132,19 +132,39 @@ public class SepsisBundleService : ISepsisBundleService
var query = _db.SepsisBundles
.AsNoTracking()
.Include(b => b.Elements)
.Include(b => b.Encounter)
.ThenInclude(e => e.Patient)
.AsQueryable();
if (status.HasValue)
query = query.Where(b => b.ComplianceStatus == status.Value);
var total = await query.CountAsync();
var items = await query
var bundles = await query
.OrderByDescending(b => b.RecognizedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return new PagedResult<SepsisBundle>(items, page, pageSize, total);
var items = bundles.Select(b => new SepsisBundleSummary(
b.Id,
b.EncounterId,
b.Encounter.Patient.Mrn,
b.Encounter.Patient.FirstName,
b.Encounter.Patient.LastName,
b.Encounter.RoomBed,
b.Encounter.Department,
b.TriggeringAlertType,
b.RecognizedAt,
b.DeadlineAt,
b.ComplianceStatus,
b.CompletedAt,
b.Elements
.Select(e => new SepsisBundleElementSummary(e.Id, e.ElementType, e.Status, e.CompletedAt))
.ToList()))
.ToList();
return new PagedResult<SepsisBundleSummary>(items, page, pageSize, total);
}
public async Task OnOrderResultedAsync(Guid orderId, CancellationToken ct = default)
+243 -66
View File
@@ -2,13 +2,13 @@
## Overview
A production-style clinical backend that models patient encounters, continuous observation ingest, and real-time clinical alerting. The system streams vital signs and lab results through Kafka, fans urgent notifications to clinicians through RabbitMQ, and maintains a searchable CQRS projection in Elasticsearch for patient dashboards and population analytics. Long-term data is archived as Parquet files in an S3-compatible object store — a regulatory requirement in healthcare that has no equivalent in most other domains.
A production-quality clinical backend built with ASP.NET Core 8, PostgreSQL, Apache Kafka, RabbitMQ, Elasticsearch, Redis, and MinIO. The system models patient encounters, continuous observation ingest, and real-time clinical alerting with composite scoring engines (NEWS2, GCS, SOFA), Sepsis-3 two-tier detection (qSOFA screening → SOFA confirmation → treatment bundle), trend analysis, and alert suppression. It streams vital signs and lab results through Kafka, fans urgent notifications to clinicians through RabbitMQ with DLQ-based escalation, and maintains a searchable CQRS projection in Elasticsearch for ward dashboards and population analytics. A Vue 3 ward dashboard provides real-time clinical views. Ward gateway edge nodes buffer observations locally during connectivity loss. A FHIR R4 facade enables EHR integration. JWT-based RBAC with clinical audit logging gates every endpoint. Long-term data is archived as Parquet files in MinIO — a regulatory requirement in healthcare that has no equivalent in most other domains. Twenty-nine phases are implemented and verified.
The domain is deliberately different from the Digital Wallet API. Both projects use Kafka, RabbitMQ, and Elasticsearch, but the trade-off conversations are entirely different. In fintech the core question is "did the money move correctly?" In healthcare the core question is "did the right person get the right alert at the right time?" That distinction — correctness vs timeliness — produces different architectural decisions at every layer.
This project maps to `sd-mid-009` (Outbox Pattern), `sd-mid-013` (CQRS), `sd-mid-043048` (Kafka internals), `sd-senior-008` (Real-Time Event Processing), and `sd-senior-011` (Anomaly Detection in Streams).
**Stack:** .NET 8 Web API, PostgreSQL, Apache Kafka (KRaft), RabbitMQ, Elasticsearch, Redis, MinIO (Parquet archival), Serilog → Seq, Prometheus → Grafana, xUnit, Docker Compose.
**Stack:** .NET 8 Web API, PostgreSQL, Apache Kafka (KRaft), RabbitMQ, Elasticsearch, Redis, MinIO (Parquet archival), Serilog → Seq, Prometheus → Grafana, Vue 3 + Vite + Pinia + Tailwind CSS + Chart.js, Hl7.Fhir.R4 (Firely SDK), JWT + BCrypt, FluentValidation, xUnit + Testcontainers, Docker Compose.
---
@@ -36,14 +36,19 @@ docker compose up -d
- Model the observe-alert-acknowledge lifecycle that sits at the center of any clinical monitoring system
- Demonstrate Kafka's multi-consumer log model in a healthcare context where the same observation event must reach the alert engine, the Elasticsearch projection, and the data lake independently
- Show RabbitMQ's DLQ pattern as a clinical escalation mechanism — if a critical alert is not acknowledged in five minutes, the message routes through a dead-letter queue and re-delivers as an escalation to the on-call physician
- Build a stateful Kafka consumer that detects sepsis early warning signs by maintaining rolling windows of recent observations per patient in Redis
- Build stateful Kafka consumers that detect sepsis (qSOFA/SOFA), compute composite scores (NEWS2, GCS), and track trends by maintaining rolling windows of recent observations per patient in Redis
- Implement a two-tier sepsis detection pathway (Sepsis-3: qSOFA screen → SOFA confirmation → treatment bundle) that demonstrates clinical workflow automation
- Provide a Vue 3 ward dashboard with real-time clinical views, scoring history charts, and clinician feedback collection
- Enable EHR integration via a FHIR R4 inbound facade with LOINC/SNOMED code mapping
- Support edge deployment via ward gateway nodes with offline buffering and central sync
- Secure all endpoints with JWT-based RBAC and maintain an append-only clinical audit trail
- Produce a project that supports senior trade-off conversations in healthcare, medtech, and any domain where real-time alerting and long-term archival coexist
## Non-Goals
- HL7 FHIR compliance (reference the standard; do not implement it)
- ~~HL7 FHIR compliance~~ → **Implemented in Phase 30** — FHIR R4 inbound facade with LOINC/SNOMED code mapping, transaction Bundles, and read/search endpoints; Mirth Connect integration guide
- Integration with real medical devices or lab information systems
- Medication dispensing or pharmacy workflows
- ~~Medication dispensing or pharmacy workflows~~ → **Partially addressed in Phase 15** — medication administration recording with drug-vital correlation annotations on alerts
- Patient billing or insurance claim adjudication
- HIPAA-compliant deployment (model the patterns; don't configure real PHI)
@@ -56,7 +61,7 @@ This is the same architectural question as the Digital Wallet — but the health
**Kafka** is an append-only log. Every observation recorded by a bedside monitor, every lab result that arrives from the lab information system, is written to a Kafka topic and retained. Multiple independent consumer groups read the same observation stream at their own pace:
- The Elasticsearch indexer maintains a searchable patient dashboard
- The sepsis detection engine analyzes rolling windows for SIRS criteria
- The sepsis detection engine analyzes rolling windows for qSOFA/SOFA criteria (Sepsis-3)
- The data lake writer archives observations as Parquet for long-term regulatory retention
- A future billing consumer could derive charges from observation codes without touching the operational database
@@ -247,11 +252,16 @@ open → acknowledged → resolved
| Topic | Producer | Consumers |
|---|---|---|
| `observation.recorded` | Outbox relay | Elasticsearch indexer, Sepsis engine, Data lake writer |
| `alert.generated` | Outbox relay | Elasticsearch indexer, Notification worker, Data lake writer |
| `encounter.status.changed` | Outbox relay | Elasticsearch indexer, Data lake writer |
| `observation.recorded` | Outbox relay | `es-indexer`, `sepsis-engine` (qSOFA), `warning-evaluator`, `news2-scoring`, `gcs-scoring`, `sofa-scoring`, `trend-analyzer`, `data-lake-writer` |
| `alert.generated` | Outbox relay | `es-indexer`, `notification-publisher`, `data-lake-writer` |
| `encounter.status.changed` | Outbox relay | `es-indexer`, `data-lake-writer` |
| `gcs.scored` | Outbox relay (via GcsDetector) | `sofa-scoring` (CNS organ-system re-scoring) |
| `sepsis.bundle.created` | Outbox relay | `es-indexer` |
| `sepsis.bundle.updated` | Outbox relay | `es-indexer` |
**Partition key:** `encounter_id` for `observation.recorded` and `alert.generated`. All events for the same encounter land on the same partition, preserving per-encounter ordering. This is important for the sepsis engine: observations for the same patient must be processed in arrival order.
All topics use 6 partitions. `KAFKA_AUTO_CREATE_TOPICS_ENABLE=false` — topics are provisioned explicitly by `KafkaTopicProvisioner`.
**Partition key:** `encounter_id` for all topics. All events for the same encounter land on the same partition, preserving per-encounter ordering. This is important for the sepsis engine: observations for the same patient must be processed in arrival order.
**Consumer group isolation:** `es-indexer`, `sepsis-engine`, and `data-lake-writer` are separate consumer groups. Each maintains its own committed offset. The sepsis engine processing slowly does not affect the Elasticsearch indexer.
@@ -308,43 +318,50 @@ open → acknowledged → resolved
---
### 7. Sepsis Early Warning Engine
### 7. Sepsis Detection Engine (Sepsis-3: qSOFA Screen → SOFA Confirmation)
**Description:** A Kafka consumer that reads the `observation.recorded` stream and detects SIRS (Systemic Inflammatory Response Syndrome) criteria per patient in near real-time. SIRS is a simplified clinical proxy for sepsis risk — when two or more criteria are met simultaneously, a `SEPSIS_WARNING` alert is generated. State is maintained in Redis as a rolling window of recent observations per encounter.
**Description:** A two-tier sepsis detection pathway following the Sepsis-3 consensus (2016), replacing the original SIRS-based approach. The first tier is a bedside qSOFA screening engine (`SepsisEngineService`) that evaluates three organ-dysfunction criteria per patient in near real-time. The second tier is the SOFA organ-dysfunction scoring engine (`SofaScoringService`) that confirms sepsis and triggers the treatment bundle. State is maintained in Redis as rolling windows of recent observations per encounter.
**SIRS criteria (simplified for this project):**
> **Historical note:** The original PRD specified SIRS criteria (temperature, heart rate, respiratory rate, WBC). Phase 27 replaced SIRS with qSOFA/SOFA per Sepsis-3 consensus — SIRS criteria were too non-specific, triggering bundles for post-surgical inflammation, anxiety, and viral infections. The legacy `SEPSIS_WARNING` alert type is retained `[Obsolete]` for historical queries but can no longer be created.
**Tier 1 — qSOFA screening criteria:**
| Criterion | Observation Code | Trigger |
|---|---|---|
| Fever or hypothermia | `TEMP_C` | > 38.3°C or < 36.0°C |
| Tachycardia | `HEART_RATE` | > 90 bpm |
| Tachypnea | `RESP_RATE` | > 20 breaths/min |
| Abnormal WBC | `WBC_K_UL` | > 12.0 or < 4.0 k/µL |
| Tachypnea | `RESP_RATE` | ≥ 22 breaths/min |
| Hypotension | `SYSTOLIC_BP` | ≤ 100 mmHg |
| Altered mentation | `GCS` / `AVPU` | GCS < 15 or AVPU ≥ 1 |
**Redis state per encounter:**
**Redis state per encounter (qSOFA):**
```
sirs:{encounterId}:TEMP_C → "1" (TTL: 30 minutes)
sirs:{encounterId}:HEART_RATE → "1" (TTL: 30 minutes)
sirs:{encounterId}:RESP_RATE → "1" (TTL: 30 minutes)
sirs:{encounterId}:WBC_K_UL → "1" (TTL: 30 minutes)
qsofa:{encounterId}:RESP_RATE → "1" (TTL: 30 minutes)
qsofa:{encounterId}:SYSTOLIC_BP → "1" (TTL: 30 minutes)
qsofa:{encounterId}:MENTATION → "1" (TTL: 30 minutes)
```
**Detection logic per observation event:**
**Tier 1 detection logic per observation event:**
```
1. Evaluate the incoming observation against SIRS criteria
2. If criterion met: SET sirs:{encounterId}:{code} = "1" EX 1800
3. If criterion not met: DEL sirs:{encounterId}:{code}
4. Count active SIRS keys for this encounter (KEYS pattern or MGET)
5. If count >= 2 and no open SEPSIS_WARNING alert exists for this encounter:
a. Write clinical_alert to PostgreSQL (SEPSIS_WARNING, CRITICAL)
1. Evaluate the incoming observation against qSOFA criteria
2. If criterion met: SET qsofa:{encounterId}:{code} EX 1800
3. If criterion normalized: DEL qsofa:{encounterId}:{code}
4. MGET all three qSOFA keys for this encounter
5. Persist evaluation to qsofa_evaluations table
6. If count >= 2 and no open QSOFA_SCREEN alert exists for this encounter:
a. Write clinical_alert (QSOFA_SCREEN, WARNING) — recommends ordering SOFA labs
b. Write outbox event → Kafka alert.generated
```
**Why Redis here and not PostgreSQL:** The SIRS evaluation runs on every observation event, potentially multiple times per minute per patient. Checking "which SIRS criteria were met in the last 30 minutes" against PostgreSQL on every event would require a query against the observations table with a time range filter per encounter — under load, this creates read pressure that competes with ingest writes. Redis's O(1) key operations with TTL-based expiry are correct and fast. The TTL handles the sliding window automatically: a heart rate measurement that was abnormal 31 minutes ago stops contributing to the SIRS count without any cleanup job.
**Tier 2 — SOFA organ-dysfunction scoring:**
**Idempotency:** If the consumer crashes between detecting SIRS and committing the Kafka offset, it will re-process the same observation on restart. The alert creation query checks for an existing open `SEPSIS_WARNING` alert before inserting — a duplicate is impossible even with at-least-once delivery.
`SofaScoringService` subscribes to `observation.recorded` and `gcs.scored` Kafka topics. It scores six organ systems (respiratory, coagulation, liver, cardiovascular, CNS, renal) from Redis lab cache with carry-forward semantics. When SOFA delta ≥ 2 from baseline, a `SOFA_SEPSIS` (CRITICAL) alert fires and triggers a four-element sepsis treatment bundle via `SepsisAlertHandler``SepsisBundleService`. Delta = 1 creates `SOFA_WARNING`.
**Concepts practiced:** Stateful stream processing with Redis as the state store (sd-senior-011), TTL as a sliding window mechanism, idempotent alert creation, why Kafka consumer + Redis is appropriate here vs a dedicated stream processor like Flink (at the scale of a single hospital, the overhead of a full stream processing framework is not justified — this is a defensible trade-off to articulate in an interview).
**Sepsis bundle compliance (SEP-1):** On `SOFA_SEPSIS`, four treatment orders (blood cultures, serum lactate, broad-spectrum antibiotics, IV fluid resuscitation) are auto-created with a one-hour compliance deadline. As orders are resulted, bundle elements complete. `SepsisBundleMonitorService` marks overdue bundles `NON_COMPLIANT`.
**Why Redis here and not PostgreSQL:** The qSOFA evaluation runs on every observation event, potentially multiple times per minute per patient. Redis's O(1) key operations with TTL-based expiry are correct and fast. The 30-minute TTL is a clinical parameter — a respiratory rate that was abnormal 31 minutes ago stops contributing to the qSOFA count without any cleanup job.
**Idempotency:** Alert creation uses `INSERT WHERE NOT EXISTS` — a duplicate is impossible even with at-least-once Kafka delivery. Only one in-progress sepsis bundle can exist per encounter, enforced by a partial unique index.
**Concepts practiced:** Stateful stream processing with Redis as the state store (sd-senior-011), TTL as a sliding window mechanism, two-tier clinical detection (screen → confirm → treat), idempotent alert creation, why Kafka consumer + Redis is appropriate here vs a dedicated stream processor like Flink (at the scale of a single hospital, the overhead of a full stream processing framework is not justified).
---
@@ -356,10 +373,11 @@ sirs:{encounterId}:WBC_K_UL → "1" (TTL: 30 minutes)
```
clinical.notifications.exchange (direct)
├── alerts.paging.queue (physician paging, prefetch=3)
├── alerts.paging.dlq (unacknowledged pages → escalation)
├── alerts.paging.dlq (unacknowledged pages → escalation, x-message-ttl=300000ms)
├── alerts.escalation.queue (on-call backup paging)
├── notifications.discharge.queue (discharge summary PDF jobs)
── notifications.appointment.queue (appointment reminders)
├── notifications.discharge.queue (discharge summary PDF → MinIO)
── notifications.reconciliation.queue (reconciliation safety findings)
└── notifications.appointment.queue (appointment reminder SMS)
```
**Escalation flow:**
@@ -431,19 +449,37 @@ Each check creates a `reconciliation_alerts` row and publishes a job to RabbitMQ
**Metrics (Prometheus → Grafana):**
| Metric | Description |
|---|---|
| `observations_ingested_total` | Counter, labeled by source and observation_code |
| `observation_ingest_duration_seconds` | Histogram of ingest latency (includes threshold evaluation) |
| `clinical_alerts_total` | Counter, labeled by alert_type and severity |
| `alerts_unacknowledged_gauge` | Gauge — open CRITICAL alerts older than 5 minutes |
| `kafka_consumer_lag` | Per consumer group (es-indexer, sepsis-engine, data-lake-writer) |
| `outbox_pending_events` | Gauge — unprocessed outbox rows |
| `sirs_detections_total` | Counter — how many SEPSIS_WARNING alerts the engine generated |
| `escalations_total` | Counter — how many pages went through DLQ escalation |
| Metric | Type | Labels | Description |
|---|---|---|---|
| `observations_ingested_total` | Counter | `observation_code`, `source` | Per committed observation |
| `observation_ingest_duration_seconds` | Histogram | — | Full ingest transaction to COMMIT |
| `clinical_alerts_total` | Counter | `alert_type`, `severity` | All alert sources (threshold, qSOFA, NEWS2, GCS, SOFA, trend, warning) |
| `alerts_unacknowledged_gauge` | Gauge | — | Open CRITICAL alerts older than 5 minutes |
| `kafka_consumer_lag` | Gauge | `consumer_group` | Per consumer group lag |
| `outbox_pending_events` | Gauge | — | Unprocessed outbox rows |
| `qsofa_detections_total` | Counter | — | Successful qSOFA SCREEN alert inserts |
| `sepsis_bundle_compliance_total` | Counter | `status` | Bundle completion (`COMPLIANT`, `NON_COMPLIANT`) |
| `news2_scores_total` | Counter | `risk_level` | Per persisted NEWS2 score |
| `news2_scoring_duration_seconds` | Histogram | — | Redis update through score persistence |
| `gcs_scores_total` | Counter | `classification` | Per persisted GCS score (`MILD`, `MODERATE`, `SEVERE`) |
| `sofa_scores_total` | Counter | `has_delta_alert` | Per persisted SOFA score |
| `sofa_scoring_duration_seconds` | Histogram | — | Full SOFA compose + persist |
| `trend_alerts_total` | Counter | `observation_code` | Per `RAPID_DETERIORATION` alert |
| `trend_analysis_duration_seconds` | Histogram | — | Per-observation trend evaluation |
| `alert_suppressions_total` | Counter | `alert_type` | Per suppression window set |
| `escalations_total` | Counter | — | DLQ escalation pages |
| `fhir_ingest_total` | Counter | `resource_type`, `outcome` | Per FHIR resource ingest |
| `fhir_read_total` | Counter | `resource_type`, `interaction`, `outcome` | Per FHIR read/search |
| `fhir_mapping_errors_total` | Counter | `resource_type` | FHIR mapping/validation failures |
| `authorization_failures_total` | Counter | `permission`, `role` | RBAC authorization denials |
| `ward_gateways_offline_gauge` | Gauge | `site_code` | Offline/degraded gateways per site |
| `ward_gateway_buffer_depth` | Gauge | `gateway_code`, `department` | Unsynced events per gateway |
| `kafka_poison_pills_skipped_total` | Counter | `consumer_group`, `topic` | Permanently un-processable messages |
**The `alerts_unacknowledged_gauge` panel** is the most clinically significant metric. If this gauge rises, a nurse station monitor or alerting dashboard must surface it immediately. In a real deployment, this panel would be connected to a paging system. In the portfolio, it demonstrates that you understand which metrics have patient safety implications vs which are purely operational.
**Background collectors:** `AlertsUnacknowledgedCollector` (open CRITICAL alerts > 5 min), `OutboxPendingCollector` (unprocessed outbox rows), `KafkaConsumerLagCollector` (four consumer groups), `WardGatewayMetricsCollector` (gateway status/buffer depth) — all poll every 3060 seconds.
**Concepts practiced:** The four golden signals in a clinical context, which metrics are operational (Kafka lag, outbox pending) vs which are patient safety indicators (unacknowledged critical alerts), log enrichment with `correlationId`, `encounterId`, `patientId` on every alert path log line.
---
@@ -467,6 +503,84 @@ Each check creates a `reconciliation_alerts` row and publishes a job to RabbitMQ
---
### 12. Clinical Data Expansion and Warning Alerts (Phases 1011)
**Description:** Expands the clinical data model and alert pipeline. Patient entities gain optional clinical fields (blood type, allergies, emergency contact). Encounters gain room/bed assignment, admission reason, and discharge diagnosis. Five new observation codes (`SYSTOLIC_BP`, `DIASTOLIC_BP`, `LACTATE_MMOL_L`, `AVPU`, `SUPPLEMENTAL_O2`) and `GLUCOSE_MG_DL` join the original six for 12 total seeded thresholds.
The `WarningAlertService` (consumer group `warning-evaluator`) reads `observation.recorded` from Kafka and creates `WARNING`-severity alerts for values that breach warning thresholds but not critical thresholds. Warning alerts are idempotent (`INSERT WHERE NOT EXISTS`) per encounter and alert type while status is `OPEN` or `ACKNOWLEDGED`. Warning alerts are indexed in Elasticsearch but not published to the RabbitMQ paging queue.
An Orders API (`OrdersController`, `OrderService`) supports clinical order management with a status machine (`Pending → InProgress → Resulted`, terminal `Cancelled`). FluentValidation is applied to all request DTOs.
---
### 13. NEWS2 Composite Scoring Engine (Phase 12)
**Description:** The NEWS2 (National Early Warning Score 2) engine evaluates seven vital parameters per encounter 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.
**Parameters:** `RESP_RATE`, `SPO2`, `SYSTOLIC_BP`, `HEART_RATE`, `AVPU`, `TEMP_C`, `SUPPLEMENTAL_O2` — each scored 03 per official lookup tables. Consciousness resolves GCS-first with AVPU fallback. `GET /encounters/:id/news2/current` and `/history` expose score history.
---
### 14. Trend Detection and Alert Suppression (Phase 13)
**Description:** `TrendAnalyzerService` (consumer group `trend-analyzer`) tracks rate-of-change for five vital parameters 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.
Alert suppression windows prevent warning fatigue. Acknowledging a suppressible alert sets a Redis key `suppress:{encounterId}:{alertType}` with a configurable TTL (default 30 min). `WarningEvaluator` and `News2Detector` check suppression before creating new warning alerts. Critical alerts are never suppressed.
---
### 15. Medication Administration and Correlation (Phase 15)
**Description:** Records drug administrations per encounter. `MedicationCorrelationHelper` annotates warning and NEWS2 alert details when a correlated drug was administered within a configurable window (default 90 min). Annotations provide clinical context — e.g. `— note: metoprolol 25mg (PO) administered 45 min ago` — but never suppress alerts.
---
### 16. Glasgow Coma Scale and SOFA Scoring (Phases 2526)
**Description:** GCS scores three components (Eye 14, Verbal 15, Motor 16) tracked in Redis. When all three are present, computes total (315), classification (`MILD`/`MODERATE`/`SEVERE`), creates alerts (≤ 8 `GCS_CRITICAL`, 912 `GCS_WARNING`), and publishes `gcs.scored` via outbox for SOFA CNS re-scoring.
SOFA scoring evaluates six organ systems (respiratory, coagulation, liver, cardiovascular, CNS, renal) with Redis lab cache, carry-forward semantics, MAP derivation, SpO₂/FiO₂ fallback, and vasopressor detection. Baseline established when ≥ 4 organ systems have data. Delta ≥ 2 from baseline triggers `SOFA_SEPSIS` → sepsis bundle.
---
### 17. FHIR R4 Integration (Phase 30)
**Description:** Inbound FHIR R4 facade accepts resources from integration engines (Mirth Connect, Rhapsody). Per-resource endpoints (`POST /fhir/R4/{Patient,Encounter,Observation,MedicationAdministration}`) and transaction Bundles (Patient → Encounter → Observation in dependency order). 19 LOINC codes + 3 SNOMED CT fallbacks mapped to internal observation codes. Fahrenheit-to-Celsius conversion. `ExternalResourceIdentifier` table links hospital MRNs and visit numbers to internal UUIDs for idempotent upserts. Read/search endpoints (`GET /fhir/R4/Patient/{id}`, `GET /fhir/R4/Patient`, `GET /fhir/R4/Encounter/{id}`, `GET /fhir/R4/Encounter`) return FHIR R4 JSON.
---
### 18. RBAC and Clinical Audit Logging (Phase 31)
**Description:** JWT bearer authentication with four clinical roles (`Nurse`, `Physician`, `Admin`, `Integration`) and 17 granular permissions. `AuthorizePermission` attribute on every controller action. `PermissionAuthorizationHandler` resolves role → permission at runtime. `CurrentUserService` extracts authenticated identity from JWT claims.
Append-only `clinical_audit_logs` table records write actions with user identity, entity type/ID, before/after state (JSONB), reason, IP address, and correlation ID. Ten audit actions tracked. FHIR endpoints accept both JWT and `X-Api-Key` authentication for integration engine compatibility.
---
### 19. Site & Gateway Registry and Ward Gateway (Phases 2021)
**Description:** `ClinicalSite` and `WardGateway` entities model ward edge nodes. Dual authentication — JWT + RBAC for admin CRUD, `GatewayApiKeyAuthenticationHandler` for gateway heartbeat and sync. `VigilCare.ClinicalContracts` shared class library defines sync DTOs.
`VigilCare.WardGateway` is a standalone ASP.NET Core deployable with its own PostgreSQL, Redis, and RabbitMQ. Observations are ingested locally with threshold evaluation and critical alert creation, then buffered for upload to central API when the network link recovers. Background services replicate encounter/patient data, report heartbeat status, and batch-upload buffered sync items.
---
### 20. Ward Dashboard (Phases 1719, 22, 28)
**Description:** Vue 3 SPA (`vigilcare-dashboard/`) with Vite, Pinia, Tailwind CSS v4, and Chart.js. Virtual ward table (NEWS2-sorted, department filter), patient detail view (vitals, scores, alerts, orders, sepsis bundle, GCS entry form, SOFA score panel, patient banner with demographics, encounter timeline), and alert center (global acknowledge/resolve).
Chart components: five vital sign trend charts with medication administration markers, NEWS2 history, SOFA history with organ-system breakdown, GCS history with component tracking, qSOFA evaluation history. Local replay scrubbing, alert reasoning with medication context, and clinician feedback mode (six ratings per alert, Feedback Summary with JSON/CSV export).
Phase 22 gap analysis fixes: `SofaHistory.vue`, `GcsHistory.vue`, `QsofaHistory.vue`, `PatientBanner.vue`, `EncounterTimeline.vue`, medication marker Chart.js plugin. Backend additions: `GET /gcs/history`, `GET /qsofa/history` APIs, `qsofa_evaluations` table.
---
### 21. Console Replay Simulator (Phase 16, 29)
**Description:** Standalone `VigilCare.Simulator` .NET console app replays JSON scenario files against the live API with configurable speed. Commands: `replay`, `replay-all`, `validate`, `dry-run`. Optional `--poll` shows alerts, NEWS2, GCS, SOFA, and sepsis bundle state during replay. Eleven sample scenarios including GCS neurological decline, SOFA sepsis progression, and SpO₂/FiO₂ fallback.
---
## Database Schema and Indexing Plan
```sql
@@ -477,6 +591,10 @@ CREATE TABLE patients (
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
gender VARCHAR(10) NOT NULL,
blood_type VARCHAR(5) NULL, -- Phase 10: A+, O-, AB-, etc.
allergies TEXT NULL, -- Phase 10: free-text allergy list
emergency_contact_name VARCHAR(200) NULL, -- Phase 10
emergency_contact_phone VARCHAR(30) NULL, -- Phase 10
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -488,6 +606,9 @@ CREATE TABLE encounters (
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
department VARCHAR(100) NOT NULL,
attending_physician VARCHAR(200) NOT NULL,
room_bed VARCHAR(50) NULL, -- Phase 10: ward/bed assignment
admission_reason TEXT NULL, -- Phase 10
discharge_diagnosis TEXT NULL, -- Phase 10: set on discharge
admitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
discharged_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -495,6 +616,8 @@ CREATE TABLE encounters (
CREATE INDEX idx_encounters_patient ON encounters (patient_id, admitted_at DESC);
CREATE INDEX idx_encounters_active ON encounters (status, admitted_at DESC) WHERE status = 'active';
CREATE UNIQUE INDEX ix_encounters_patient_active_type
ON encounters (patient_id, encounter_type) WHERE status = 'ACTIVE';
CREATE TABLE alert_thresholds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -534,6 +657,7 @@ CREATE TABLE clinical_alerts (
alert_type VARCHAR(50) NOT NULL,
severity VARCHAR(20) NOT NULL,
details TEXT NOT NULL,
observation_code VARCHAR(50) NULL, -- enables direct lookups without LIKE pattern matching
status VARCHAR(20) NOT NULL DEFAULT 'open',
acknowledged_at TIMESTAMPTZ NULL,
acknowledged_by VARCHAR(200) NULL,
@@ -545,6 +669,7 @@ CREATE INDEX idx_alerts_encounter ON clinical_alerts (encounter_id, triggered_at
CREATE INDEX idx_alerts_patient ON clinical_alerts (patient_id, triggered_at DESC);
CREATE INDEX idx_alerts_open ON clinical_alerts (severity, triggered_at DESC)
WHERE status = 'open';
CREATE INDEX idx_alerts_enc_code ON clinical_alerts (encounter_id, observation_code, status);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -554,7 +679,8 @@ CREATE TABLE orders (
ordered_by VARCHAR(200) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
ordered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
resulted_at TIMESTAMPTZ NULL
resulted_at TIMESTAMPTZ NULL,
result_summary TEXT NULL -- free-text result summary
);
CREATE INDEX idx_orders_encounter ON orders (encounter_id, ordered_at DESC);
@@ -564,13 +690,17 @@ CREATE INDEX idx_orders_pending ON orders (status, ordered_at)
CREATE TABLE outbox_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
topic VARCHAR(200) NOT NULL,
partition_key VARCHAR(100) NULL, -- encounterId for per-encounter ordering
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
processed_at TIMESTAMPTZ NULL
processed_at TIMESTAMPTZ NULL,
retry_count INT NOT NULL DEFAULT 0, -- Kafka produce attempt counter
last_error TEXT NULL, -- last failure reason
failed_at TIMESTAMPTZ NULL -- set when retryCount exceeds OutboxMaxRetries
);
CREATE INDEX idx_outbox_pending ON outbox_events (created_at)
WHERE processed_at IS NULL;
WHERE processed_at IS NULL AND failed_at IS NULL;
CREATE TABLE reconciliation_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -583,6 +713,23 @@ CREATE TABLE reconciliation_alerts (
);
```
**Tables added in Phases 1031** (managed by EF Core migrations — see `README.md` Data Models for full column definitions):
| Table | Phase | Purpose |
|---|---|---|
| `news2_scores` | 12 | NEWS2 composite scores with seven component scores and risk level |
| `gcs_scores` | 25 | GCS eye/verbal/motor components, total score, classification |
| `sofa_scores` | 26 | Six organ-system scores, baseline flag, delta from baseline, staleness metadata |
| `qsofa_evaluations` | 22 | Per-evaluation qSOFA record: criteria count, values, screen alert fired |
| `sepsis_bundles` | 14 | Four-element treatment bundles with 1-hour compliance deadline |
| `sepsis_bundle_elements` | 14 | Individual bundle elements linked to clinical orders |
| `medication_administrations` | 15 | Drug administration records per encounter |
| `external_resource_identifiers` | 30 | Links hospital MRNs and visit numbers to internal UUIDs for FHIR |
| `clinical_users` | 31 | Username, BCrypt password hash, display name, role |
| `clinical_audit_logs` | 31 | Append-only audit trail: action, entity, user, before/after JSONB, IP, correlation ID |
| `clinical_sites` | 20 | Hospital sites with site code, name, address |
| `ward_gateways` | 20 | Ward edge nodes with status, buffer depth, heartbeat, sync timestamps |
---
## Design Decisions
@@ -626,23 +773,43 @@ Observations, alerts, and orders belong to an encounter, not directly to a patie
| Alert acknowledgment | All open CRITICAL alerts must be detectable via the reconciliation job within 30 minutes |
| Replay | Deleting and rebuilding the Elasticsearch index from Kafka offset 0 must be demonstrable |
| Retention | Data lake writer must write observations to MinIO; nothing is deleted from the lake |
| Testing | Integration tests: critical value ingest → alert created; SIRS criteria met across 3 observations → sepsis alert; duplicate idempotency key → no duplicate; encounter discharge → RabbitMQ job published |
| Testing | Integration tests: critical value ingest → alert created; qSOFA criteria met → screening alert; SOFA delta ≥ 2 → sepsis bundle; duplicate idempotency key → no duplicate; encounter discharge → RabbitMQ job published |
---
## Build Order
| Phase | Focus |
|---|---|
| 1 | Schema, migrations, patient/encounter CRUD, alert threshold CRUD, seed data |
| 2 | Observation ingest + synchronous critical value detection + alert lifecycle API |
| 3 | Outbox relay + Kafka topics + producer |
| 4 | Elasticsearch CQRS projection + clinical search + analytics endpoints |
| 5 | Sepsis detection engine (Kafka consumer + Redis SIRS state) |
| 6 | RabbitMQ notification workers + DLQ escalation |
| 7 | Reconciliation jobs (three checks) |
| 8 | Prometheus metrics + Grafana dashboards + Seq logging |
| 9 | MinIO data lake writer (Parquet, partitioned) |
| Phase | Focus | Status |
|---|---|---|
| 1 | Schema, migrations, patient/encounter CRUD, alert threshold CRUD, seed data | Done |
| 2 | Observation ingest + synchronous critical value detection + alert lifecycle API | Done |
| 3 | Outbox relay + Kafka topics + producer | Done |
| 4 | Elasticsearch CQRS projection + clinical search + analytics endpoints | Done |
| 5 | Sepsis detection engine (Kafka consumer + Redis qSOFA state) | Done |
| 6 | RabbitMQ notification workers + DLQ escalation | Done |
| 7 | Reconciliation jobs (three checks) | Done |
| 8 | Prometheus metrics + Grafana dashboards + Seq logging | Done |
| 9 | MinIO data lake writer (Parquet, partitioned) | Done |
| 10 | Clinical data model expansion — patient demographics, encounter enrichment, 12 observation codes | Done |
| 11 | Warning alert consumer (`warning-evaluator`) + Orders API + FluentValidation | Done |
| 12 | NEWS2 composite scoring engine (seven vitals → aggregate score → alerts) | Done |
| 13 | Trend detection (rate-of-change alerts) + alert suppression windows | Done |
| 14 | qSOFA bedside screening + sepsis bundle compliance (SEP-1) | Done |
| 15 | Medication administration + drug-vital correlation annotations on alerts | Done |
| 16 | Console replay simulator (scenario JSON files, speed multiplier, API polling) | Done |
| 17 | Ward dashboard shell — Vue 3 + Vite + Pinia + Tailwind; virtual ward table, patient detail, alert center | Done |
| 18 | Clinical review mode — vital trend charts, NEWS2 history, replay controls, alert reasoning | Done |
| 19 | Clinician feedback mode — six ratings per alert, Feedback Summary with export | Done |
| 20 | Site & Gateway Registry + Clinical Sync Contracts (shared class library) | Done |
| 21 | Ward Gateway Service — local-first clinical path with offline buffering and central sync | Done |
| 22 | Dashboard gap analysis fixes — SOFA/GCS/qSOFA history charts, patient banner, encounter timeline, medication markers | Done |
| 25 | Glasgow Coma Scale (GCS) scoring — three components → total → alerts → SOFA CNS | Done |
| 26 | SOFA organ-dysfunction scoring — six organ systems, baseline tracking, delta sepsis alerts | Done |
| 27 | Sepsis-3 clinical refactor — SIRS removed, qSOFA screening, SOFA bundle trigger | Done |
| 28 | Frontend GCS entry form + SOFA score panel + sepsis UI refactor | Done |
| 29 | Simulator scenario expansion + clinical validation (end-to-end qSOFA → SOFA → bundle) | Done |
| 30 | FHIR R4 Inbound Facade — per-resource ingest, transaction Bundles, read/search, LOINC mapping | Done |
| 31 | RBAC + Clinical Audit Logging — JWT auth, four roles, 17 permissions, append-only audit trail | Done |
---
@@ -691,7 +858,7 @@ The split between synchronous (critical) and asynchronous (warning) detection is
5. Introduce the outbox bug deliberately: make two separate commits (one for the observation, one for the outbox event) and observe the data loss when the process crashes between them. Fix it. This step is not optional — seeing the failure mode is the fastest path to internalizing the pattern.
**Why:**
The per-encounter partition key is important for the sepsis engine. If observations from the same patient land on different partitions, they may be processed out of order, and SIRS criteria that arrived simultaneously could be missed. Document this in the code.
The per-encounter partition key is important for the sepsis engine. If observations from the same patient land on different partitions, they may be processed out of order, and qSOFA criteria that arrived simultaneously could be missed. Document this in the code.
---
@@ -710,15 +877,19 @@ The replay is the proof that Elasticsearch is a projection and not a source of t
### Phase 5 — Sepsis Detection Engine
> **Updated for Sepsis-3 (Phase 27 refactor):** The original Phase 5 implemented SIRS-based detection. Phase 27 replaced SIRS with the two-tier qSOFA → SOFA pathway per the 2016 Sepsis-3 consensus. The steps below reflect the current implementation.
**What to do:**
1. Build the `sepsis-engine` consumer group reading `observation.recorded`.
2. Implement the Redis SIRS state as described in the Features section: `SET sirs:{encounterId}:{code} EX 1800` on criterion met, `DEL` on criterion not met.
3. Use `MGET` on all four SIRS keys per encounter after each observation — four O(1) operations, not a scan.
4. On SIRS count >= 2: check for an existing open `SEPSIS_WARNING` alert for this encounter before inserting. The check and insert are one round-trip: `INSERT INTO clinical_alerts ... WHERE NOT EXISTS (SELECT 1 FROM clinical_alerts WHERE encounter_id = ? AND alert_type = 'SEPSIS_WARNING' AND status = 'open')`.
5. Write an integration test: ingest three observations that meet two SIRS criteria for the same encounter within 30 minutes → verify one `SEPSIS_WARNING` alert is created. Ingest a normal temperature immediately after → verify the TTL key is deleted but the alert remains open until acknowledged.
2. Implement the Redis qSOFA state: `SET qsofa:{encounterId}:{code} EX 1800` on criterion met, `DEL` on criterion normalized.
3. Use `MGET` on all three qSOFA keys per encounter after each observation — three O(1) operations, not a scan.
4. Persist every evaluation to `qsofa_evaluations` table with criteria values and screen-alert-fired flag.
5. On qSOFA count >= 2: check for an existing open `QSOFA_SCREEN` alert for this encounter before inserting. The check and insert are one round-trip: `INSERT INTO clinical_alerts ... WHERE NOT EXISTS (SELECT 1 FROM clinical_alerts WHERE encounter_id = ? AND alert_type = 'QSOFA_SCREEN' AND status IN ('open', 'acknowledged'))`.
6. SOFA scoring (`sofa-scoring` consumer) scores six organ systems and triggers `SOFA_SEPSIS` on delta ≥ 2 from baseline → sepsis bundle creation.
7. Write integration tests: ingest observations meeting two qSOFA criteria → verify `QSOFA_SCREEN` alert created. Verify normalization deletes Redis key. Verify SOFA delta ≥ 2 triggers sepsis bundle.
**Why:**
The TTL is doing real work here. Without it, a patient who had a fever yesterday would still have `sirs:{encounterId}:TEMP_C = "1"` in Redis today and could trigger a false sepsis alert from a fast heart rate alone. The 30-minute TTL matches the clinical window for SIRS evaluation. Understand this before the interview — the TTL is not an arbitrary expiry, it is a clinical parameter encoded in the data layer.
The 30-minute TTL matches the clinical window for qSOFA evaluation — a respiratory rate that was abnormal 31 minutes ago stops contributing without any cleanup job. SIRS was removed because it was too non-specific (triggering on post-surgical inflammation, anxiety, viral infections). qSOFA measures organ dysfunction at the bedside; SOFA confirms it with lab values. This two-tier approach prevents false-positive bundle activations.
---
@@ -752,3 +923,9 @@ Check 3 is the one unique to clinical systems. A financial reconciliation job ch
### Phases 8 and 9 — Observability and Data Lake
Follow the same Prometheus/Grafana and MinIO/Parquet approach as described in the Features section. The `alerts_unacknowledged_gauge` panel is the single most important panel in the Grafana dashboard — build it first and make sure it updates in near real-time (poll the database every 30 seconds).
---
### Phases 1031 — Extended Feature Phases
See the [Build Order](#build-order) table for all implemented phases and the [Features](#features) section (items 1221) for detailed descriptions. Per-phase implementation plans and verification guides are in `docs/plans/`. Integration tests and verification scripts cover all phases — see `README.md` for the full test table and script listing.
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
import { useAuthStore } from '@/stores/auth'
const alert = {
id: 'alert-1',
alertType: 'SofaSepsis',
severity: 'Critical',
status: 'Open',
details: 'SOFA delta >= 2',
}
describe('AcknowledgeModal', () => {
beforeEach(() => {
setActivePinia(createPinia())
const auth = useAuthStore()
auth.$patch({
user: {
userId: '11111111-1111-1111-1111-111111111111',
username: 'nurse.demo',
displayName: 'Demo Nurse',
role: 'NURSE',
},
})
})
it('showsAuthenticatedUserAndRoleContext', () => {
const wrapper = mount(AcknowledgeModal, {
props: { open: true, alert },
global: {
stubs: {
Modal: {
props: ['open', 'title'],
template: '<div v-if="open"><slot /></div>',
},
},
},
})
expect(wrapper.text()).toContain('Demo Nurse')
expect(wrapper.text()).toContain('Nurse')
expect(wrapper.text()).toContain('documenting awareness')
expect(wrapper.text()).toContain('[NURSE] Acknowledged by Demo Nurse.')
})
it('emitsOptionalNoteOnConfirm', async () => {
const wrapper = mount(AcknowledgeModal, {
props: { open: true, alert },
global: {
stubs: {
Modal: {
props: ['open', 'title'],
template: '<div v-if="open"><slot /></div>',
},
},
},
})
await wrapper.find('textarea').setValue('Will reassess in 30 minutes')
const ackButton = wrapper.findAll('button').find(button => button.text() === 'Acknowledge')
await ackButton.trigger('click')
expect(wrapper.emitted('confirm')).toEqual([['Will reassess in 30 minutes']])
})
})
@@ -36,6 +36,20 @@ describe('AlertCard', () => {
expect(wrapper.emitted('acknowledge')).toHaveLength(1)
})
it('showsAcknowledgedByWhenPresent', () => {
const wrapper = mount(AlertCard, {
props: {
alert: {
...openAlert,
status: 'Acknowledged',
acknowledgedBy: 'Demo Nurse (NURSE)',
acknowledgedAt: '2026-06-19T12:30:00Z',
},
},
})
expect(wrapper.text()).toContain('Acknowledged by Demo Nurse (Nurse)')
})
it('resolvedAlertHidesActions', () => {
const wrapper = mount(AlertCard, { props: { alert: resolvedAlert } })
const actionButtons = wrapper.findAll('button').filter(b => ['Acknowledge', 'Resolve'].includes(b.text()))
@@ -0,0 +1,35 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
import { useAlertStore } from '@/stores/alerts'
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
describe('CriticalAlertBanner', () => {
it('rendersCriticalAlertsAndDismisses', async () => {
setActivePinia(createPinia())
const alertStore = useAlertStore()
alertStore.bannerAlerts = [{
id: 'alert-1',
alertType: 'SofaSepsis',
severity: 'Critical',
status: 'Open',
details: 'SOFA delta >= 2',
}]
const wrapper = mount(CriticalAlertBanner)
expect(wrapper.text()).toContain('Sepsis Alert (SOFA)')
expect(wrapper.text()).toContain('SOFA delta >= 2')
const dismissButton = wrapper.findAll('button').find(button => button.text() === 'Dismiss')
await dismissButton.trigger('click')
expect(alertStore.bannerAlerts).toEqual([])
})
})
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import DepartmentOverviewView from '@/views/DepartmentOverviewView.vue'
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/composables/usePolling', () => ({
usePolling: (fn) => {
fn()
},
}))
vi.mock('@/api/encounters', () => ({
fetchAllActiveEncounters: vi.fn(() => Promise.resolve([
{
department: 'ICU',
news2Score: 8,
openAlertCount: 2,
sepsisActive: true,
sepsisBundleStatus: 'IN_PROGRESS',
},
{
department: 'GENERAL_MEDICINE',
news2Score: 3,
openAlertCount: 0,
sepsisActive: false,
},
])),
}))
vi.mock('@/api/analytics', () => ({
fetchAlertSummary: vi.fn(() => Promise.resolve({
summary: [{ department: 'ICU', total: 5 }],
})),
}))
describe('DepartmentOverviewView', () => {
it('rendersDepartmentCardsAndTotals', async () => {
setActivePinia(createPinia())
const wrapper = mount(DepartmentOverviewView)
await vi.waitFor(() => expect(wrapper.text()).toContain('1 active patient'))
expect(wrapper.text()).toContain('Department Overview')
expect(wrapper.text()).toContain('Critical (NEWS2 ≥ 7)')
expect(wrapper.text()).toContain('Alert volume')
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SepsisBoardView from '@/views/SepsisBoardView.vue'
const { mockPush } = vi.hoisted(() => ({
mockPush: vi.fn(),
}))
vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('@/composables/usePolling', () => ({
usePolling: () => {},
}))
const bundles = [
{
id: 'b1',
encounterId: 'enc-1',
firstName: 'Alice',
lastName: 'A',
mrn: 'M1',
department: 'Icu',
roomBed: '101',
recognizedAt: '2026-06-23T12:00:00Z',
deadlineAt: '2026-06-23T15:00:00Z',
complianceStatus: 'IN_PROGRESS',
elements: [
{ id: 'e1', elementType: 'BloodCultures', status: 'Completed' },
{ id: 'e2', elementType: 'SerumLactate', status: 'Pending' },
],
},
]
vi.mock('@/api/sepsis', () => ({
fetchSepsisBundles: vi.fn(() => Promise.resolve({ items: bundles, totalCount: 1 })),
}))
describe('SepsisBoardView', () => {
it('rendersBundleBoardWithSummary', async () => {
setActivePinia(createPinia())
const wrapper = mount(SepsisBoardView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Alice A'))
expect(wrapper.text()).toContain('Sepsis Bundle Board')
expect(wrapper.text()).toContain('Serum lactate')
})
})
@@ -68,14 +68,19 @@ const patients = [
]
describe('WardTable', () => {
const sortProps = {
sortField: 'news2Score',
sortDirection: 'desc',
}
it('rendersAllPatientRows', () => {
const wrapper = mount(WardTable, { props: { patients } })
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
expect(wrapper.findAll('tbody tr')).toHaveLength(3)
})
it('emitsClickWithEncounterId', async () => {
mockPush.mockClear()
const wrapper = mount(WardTable, { props: { patients } })
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
await wrapper.findAll('tbody tr')[2].trigger('click')
expect(mockPush).toHaveBeenCalledWith({
name: 'PatientDetail',
@@ -84,18 +89,26 @@ describe('WardTable', () => {
})
it('showsCriticalBadgeForHighNews2', () => {
const wrapper = mount(WardTable, { props: { patients } })
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
const highRiskRow = wrapper.findAll('tbody tr')[2]
expect(highRiskRow.html()).toContain('text-severity-critical')
})
it('showsExtendedClinicalColumns', () => {
const wrapper = mount(WardTable, { props: { patients } })
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
const header = wrapper.find('thead').text()
expect(header).toContain('SOFA')
expect(header).toContain('GCS')
expect(header).toContain('Department')
expect(header).toContain('Last vitals')
expect(wrapper.text()).toContain('Dr. C')
expect(wrapper.text()).toContain('Δ+2')
})
it('emitsSortWhenHeaderClicked', async () => {
const wrapper = mount(WardTable, { props: { patients, ...sortProps } })
const roomHeader = wrapper.findAll('thead button').find(button => button.text().includes('Room'))
await roomHeader.trigger('click')
expect(wrapper.emitted('sort')).toEqual([['roomBed']])
})
})
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import {
formatAcknowledgedByDisplay,
previewAcknowledgmentNote,
roleAcknowledgmentMessage,
} from '@/composables/alertAcknowledge'
describe('alertAcknowledge', () => {
it('buildsRoleAwareNotePreview', () => {
expect(previewAcknowledgmentNote('NURSE', 'Demo Nurse', 'Escalating to physician'))
.toBe('[NURSE] Acknowledged by Demo Nurse. Escalating to physician')
})
it('usesRoleSpecificAcknowledgmentMessage', () => {
expect(roleAcknowledgmentMessage('PHYSICIAN')).toContain('physician')
})
it('formatsAcknowledgedByDisplay', () => {
expect(formatAcknowledgedByDisplay('Demo Nurse (NURSE)')).toBe('Demo Nurse (Nurse)')
})
})
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { detectNewCriticalAlerts, isNotifiableCriticalAlert } from '@/composables/criticalAlertDetect'
const criticalOpen = {
id: 'a1',
severity: 'Critical',
status: 'Open',
alertType: 'SofaSepsis',
}
const criticalAcknowledged = {
id: 'a2',
severity: 'Critical',
status: 'Acknowledged',
alertType: 'News2Emergency',
}
const warningOpen = {
id: 'a3',
severity: 'Warning',
status: 'Open',
alertType: 'News2Warning',
}
describe('criticalAlertDetect', () => {
it('identifiesNotifiableCriticalAlerts', () => {
expect(isNotifiableCriticalAlert(criticalOpen)).toBe(true)
expect(isNotifiableCriticalAlert(criticalAcknowledged)).toBe(false)
expect(isNotifiableCriticalAlert(warningOpen)).toBe(false)
})
it('seedsSeenIdsOnFirstPoll', () => {
const result = detectNewCriticalAlerts([criticalOpen, warningOpen], [], false)
expect(result.newAlerts).toEqual([])
expect(result.nextSeenIds).toEqual(['a1'])
expect(result.seeded).toBe(true)
})
it('detectsNewCriticalAlertsAfterSeed', () => {
const seeded = detectNewCriticalAlerts([criticalOpen], [], false)
const next = detectNewCriticalAlerts(
[criticalOpen, { ...criticalOpen, id: 'a4', alertType: 'GcsCritical' }],
seeded.nextSeenIds,
seeded.seeded,
)
expect(next.newAlerts.map(alert => alert.id)).toEqual(['a4'])
})
})
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import {
aggregateByDepartment,
news2AcuityTier,
normalizeDepartmentKey,
summarizeDepartments,
} from '@/composables/departmentFormat'
const encounters = [
{
department: 'ICU',
news2Score: 8,
openAlertCount: 2,
sepsisActive: true,
sepsisBundleStatus: 'IN_PROGRESS',
},
{
department: 'Icu',
news2Score: 5,
openAlertCount: 1,
sepsisActive: false,
sepsisBundleStatus: null,
},
{
department: 'GENERAL_MEDICINE',
news2Score: 2,
openAlertCount: 0,
sepsisActive: false,
sepsisBundleStatus: null,
},
{
department: 'Surgery',
news2Score: 6,
openAlertCount: 3,
sepsisActive: true,
sepsisBundleStatus: 'IN_PROGRESS',
},
]
describe('departmentFormat', () => {
it('normalizesDepartmentKeys', () => {
expect(normalizeDepartmentKey('Icu')).toBe('ICU')
expect(normalizeDepartmentKey('GeneralMedicine')).toBe('GENERAL_MEDICINE')
})
it('classifiesNews2Acuity', () => {
expect(news2AcuityTier(2)).toBe('low')
expect(news2AcuityTier(5)).toBe('medium')
expect(news2AcuityTier(7)).toBe('high')
})
it('aggregatesByDepartment', () => {
const departments = aggregateByDepartment(encounters, [
{ department: 'ICU', total: 10 },
{ department: 'SURGERY', total: 4 },
])
const icu = departments.find(d => d.key === 'ICU')
const surgery = departments.find(d => d.key === 'SURGERY')
const general = departments.find(d => d.key === 'GENERAL_MEDICINE')
expect(icu.patientCount).toBe(2)
expect(icu.acuity.high).toBe(1)
expect(icu.acuity.medium).toBe(1)
expect(icu.activeBundleCount).toBe(1)
expect(icu.openAlertCount).toBe(3)
expect(icu.averageNews2).toBe(6.5)
expect(icu.alertVolume).toBe(10)
expect(surgery.patientCount).toBe(1)
expect(surgery.activeBundleCount).toBe(1)
expect(surgery.alertVolume).toBe(4)
expect(general.patientCount).toBe(1)
expect(general.acuity.low).toBe(1)
})
it('summarizesTotals', () => {
const departments = aggregateByDepartment(encounters)
expect(summarizeDepartments(departments)).toEqual({
patientCount: 4,
criticalCount: 1,
activeBundleCount: 2,
openAlertCount: 6,
})
})
})
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import {
bundleUrgency,
formatCountdown,
sortBundlesByUrgency,
summarizeBundles,
} from '@/composables/sepsisFormat'
const baseBundle = {
complianceStatus: 'IN_PROGRESS',
deadlineAt: '2026-06-23T15:00:00Z',
}
describe('sepsisFormat', () => {
const now = new Date('2026-06-23T14:00:00Z').getTime()
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(now)
})
afterEach(() => {
vi.useRealTimers()
})
it('classifiesUrgency', () => {
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T15:00:00Z' }, now)).toBe('on_track')
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T14:20:00Z' }, now)).toBe('at_risk')
expect(bundleUrgency({ ...baseBundle, deadlineAt: '2026-06-23T13:00:00Z' }, now)).toBe('overdue')
expect(bundleUrgency({ complianceStatus: 'NON_COMPLIANT', deadlineAt: '2026-06-23T15:00:00Z' }, now)).toBe('overdue')
})
it('formatsCountdown', () => {
expect(formatCountdown('2026-06-23T14:45:00Z', now)).toBe('45:00')
expect(formatCountdown('2026-06-23T13:00:00Z', now)).toBe('0:00')
})
it('sortsByUrgency', () => {
const bundles = [
{ id: 'a', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T15:00:00Z' },
{ id: 'b', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T13:00:00Z' },
{ id: 'c', complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T14:20:00Z' },
]
const sorted = sortBundlesByUrgency(bundles, now)
expect(sorted.map(b => b.id)).toEqual(['b', 'c', 'a'])
})
it('summarizesBundles', () => {
const bundles = [
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T15:00:00Z' },
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T14:20:00Z' },
{ complianceStatus: 'IN_PROGRESS', deadlineAt: '2026-06-23T13:00:00Z' },
]
expect(summarizeBundles(bundles, now)).toEqual({ on_track: 1, at_risk: 1, overdue: 1 })
})
})
@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
describe('alert store notifications', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('queuesBannerAlertsForNewCriticalItems', () => {
const store = useAlertStore()
const critical = {
id: 'alert-1',
severity: 'Critical',
status: 'Open',
alertType: 'SofaSepsis',
}
expect(store.applyPollResults([critical])).toEqual([])
expect(store.bannerAlerts).toEqual([])
const next = {
id: 'alert-2',
severity: 'Critical',
status: 'Open',
alertType: 'GcsCritical',
}
const newAlerts = store.applyPollResults([critical, next])
expect(newAlerts.map(alert => alert.id)).toEqual(['alert-2'])
expect(store.bannerAlerts.map(alert => alert.id)).toEqual(['alert-2'])
})
it('dismissesBannerAlert', () => {
const store = useAlertStore()
store.bannerAlerts = [{ id: 'alert-1' }]
store.dismissBannerAlert('alert-1')
expect(store.bannerAlerts).toEqual([])
})
})
@@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSettingsStore } from '@/stores/settings'
import { useWardStore } from '@/stores/ward'
describe('ward store sort', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
it('persistsSortPreference', () => {
const settings = useSettingsStore()
const ward = useWardStore()
ward.setSort('name')
expect(settings.wardSortField).toBe('name')
expect(settings.wardSortDirection).toBe('asc')
expect(localStorage.getItem('wardSortField')).toBe('name')
ward.setSort('name')
expect(settings.wardSortDirection).toBe('desc')
expect(localStorage.getItem('wardSortDirection')).toBe('desc')
})
})
describe('ward store filters', () => {
beforeEach(() => {
localStorage.clear()
setActivePinia(createPinia())
})
it('filtersEncountersForDisplay', () => {
const ward = useWardStore()
ward.encounters = [
{ encounterId: '1', firstName: 'Alice', lastName: 'A', mrn: 'M1', news2Score: 8, openAlertCount: 1 },
{ encounterId: '2', firstName: 'Bob', lastName: 'B', mrn: 'M2', news2Score: 3, openAlertCount: 0 },
]
ward.toggleFilter('critical')
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1'])
ward.toggleFilter('hasAlerts')
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1'])
ward.clearFilters()
expect(ward.displayEncounters.map(e => e.encounterId)).toEqual(['1', '2'])
})
})
@@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest'
import { filterEncounters, matchesPatientSearch } from '@/composables/wardFilter'
const patients = [
{
encounterId: '1',
firstName: 'Alice',
lastName: 'Anderson',
mrn: 'MRN-100',
news2Score: 8,
openAlertCount: 2,
sepsisActive: true,
sepsisBundleStatus: 'IN_PROGRESS',
},
{
encounterId: '2',
firstName: 'Bob',
lastName: 'Baker',
mrn: 'MRN-200',
news2Score: 4,
openAlertCount: 0,
sepsisActive: false,
},
{
encounterId: '3',
firstName: 'Carol',
lastName: 'Clark',
mrn: 'MRN-300',
news2Score: 7,
openAlertCount: 1,
sepsisActive: false,
sepsisBundleStatus: 'IN_PROGRESS',
},
]
describe('wardFilter', () => {
it('matchesNameOrMrn', () => {
expect(matchesPatientSearch(patients[0], 'alice')).toBe(true)
expect(matchesPatientSearch(patients[0], 'MRN-100')).toBe(true)
expect(matchesPatientSearch(patients[0], 'anderson')).toBe(true)
expect(matchesPatientSearch(patients[0], 'xyz')).toBe(false)
})
it('filtersBySearch', () => {
const result = filterEncounters(patients, { search: 'baker' })
expect(result.map(p => p.encounterId)).toEqual(['2'])
})
it('filtersByHasAlerts', () => {
const result = filterEncounters(patients, { hasAlerts: true })
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
})
it('filtersBySepsisActive', () => {
const result = filterEncounters(patients, { sepsisActive: true })
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
})
it('filtersByCriticalNews2', () => {
const result = filterEncounters(patients, { critical: true })
expect(result.map(p => p.encounterId)).toEqual(['1', '3'])
})
it('combinesSearchAndFilters', () => {
const result = filterEncounters(patients, {
search: 'carol',
hasAlerts: true,
critical: true,
})
expect(result.map(p => p.encounterId)).toEqual(['3'])
})
})
@@ -0,0 +1,78 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
defaultSortDirection,
sortEncounters,
} from '@/composables/wardSort'
const patients = [
{
encounterId: '1',
firstName: 'Bob',
lastName: 'B',
roomBed: '202',
department: 'SURGERY',
news2Score: 5,
qsofaScore: 1,
sepsisActive: false,
openAlertCount: 1,
},
{
encounterId: '2',
firstName: 'Alice',
lastName: 'A',
roomBed: '101',
department: 'ICU',
news2Score: 8,
qsofaScore: 2,
sepsisActive: true,
sepsisBundleStatus: 'IN_PROGRESS',
openAlertCount: 3,
},
{
encounterId: '3',
firstName: 'Carol',
lastName: 'C',
roomBed: '103',
department: 'GENERAL_MEDICINE',
news2Score: 2,
qsofaScore: 0,
sepsisActive: false,
openAlertCount: 0,
},
]
describe('wardSort', () => {
beforeEach(() => {
localStorage.clear()
})
it('defaultsTextFieldsToAscending', () => {
expect(defaultSortDirection('name')).toBe('asc')
expect(defaultSortDirection('news2Score')).toBe('desc')
})
it('sortsByNews2DescendingByDefault', () => {
const sorted = sortEncounters(patients, 'news2Score', 'desc')
expect(sorted.map(p => p.encounterId)).toEqual(['2', '1', '3'])
})
it('sortsByPatientNameAscending', () => {
const sorted = sortEncounters(patients, 'name', 'asc')
expect(sorted.map(p => p.firstName)).toEqual(['Alice', 'Bob', 'Carol'])
})
it('sortsByRoomBedAscending', () => {
const sorted = sortEncounters(patients, 'roomBed', 'asc')
expect(sorted.map(p => p.roomBed)).toEqual(['101', '103', '202'])
})
it('sortsByOpenAlertCountDescending', () => {
const sorted = sortEncounters(patients, 'openAlertCount', 'desc')
expect(sorted.map(p => p.encounterId)).toEqual(['2', '1', '3'])
})
it('sortsBySepsisStatusDescending', () => {
const sorted = sortEncounters(patients, 'sepsis', 'desc')
expect(sorted[0].encounterId).toBe('2')
})
})
+20
View File
@@ -12,6 +12,26 @@ export function fetchAllAlerts(status) {
return api.get(`/api/v1/alerts?${params}`)
}
export async function fetchAllOpenAlerts() {
const items = []
let page = 1
let totalCount = 0
do {
const params = new URLSearchParams({
status: 'OPEN',
page: String(page),
pageSize: '100',
})
const data = await api.get(`/api/v1/alerts?${params}`)
items.push(...(data.items ?? []))
totalCount = data.totalCount ?? items.length
page += 1
} while (items.length < totalCount)
return items
}
export function acknowledgeAlert(alertId, note) {
return api.post(`/api/v1/alerts/${alertId}/acknowledge`, { note })
}
+11
View File
@@ -0,0 +1,11 @@
import { api } from './client'
export function fetchAlertSummary({ severity, department, from, to } = {}) {
const params = new URLSearchParams()
if (severity) params.set('severity', severity)
if (department) params.set('department', department)
if (from) params.set('from', from)
if (to) params.set('to', to)
const qs = params.toString()
return api.get(`/api/v1/analytics/alerts/summary${qs ? `?${qs}` : ''}`)
}
+21 -2
View File
@@ -1,11 +1,30 @@
import { api } from './client'
export function fetchActiveEncounters(department) {
const params = new URLSearchParams({ status: 'ACTIVE' })
export function fetchActiveEncounters(department, { page = 1, pageSize = 20 } = {}) {
const params = new URLSearchParams({
status: 'ACTIVE',
page: String(page),
pageSize: String(pageSize),
})
if (department) params.set('department', department)
return api.get(`/api/v1/encounters?${params}`)
}
export async function fetchAllActiveEncounters(department) {
const items = []
let page = 1
let totalCount = 0
do {
const data = await fetchActiveEncounters(department, { page, pageSize: 100 })
items.push(...(data.items ?? []))
totalCount = data.totalCount ?? items.length
page += 1
} while (items.length < totalCount)
return items
}
export function fetchEncounter(id) {
return api.get(`/api/v1/encounters/${id}`)
}
+10
View File
@@ -0,0 +1,10 @@
import { api } from './client'
export function fetchSepsisBundles({ status = 'IN_PROGRESS', page = 1, pageSize = 100 } = {}) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
})
if (status) params.set('status', status)
return api.get(`/api/v1/sepsis-bundles?${params}`)
}
@@ -1,19 +1,45 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import Modal from '@/components/ui/Modal.vue'
import Button from '@/components/ui/Button.vue'
import Badge from '@/components/ui/Badge.vue'
import { useAuthStore } from '@/stores/auth'
import { alertTypeLabel } from '@/api/normalize'
import {
formatRoleLabel,
previewAcknowledgmentNote,
roleAcknowledgmentMessage,
} from '@/composables/alertAcknowledge'
defineProps({
const props = defineProps({
open: { type: Boolean, default: false },
alert: { type: Object, default: null },
})
const emit = defineEmits(['confirm', 'close'])
const authStore = useAuthStore()
const { displayName, role } = storeToRefs(authStore)
const note = ref('')
watch(() => props.open, (isOpen) => {
if (isOpen) note.value = ''
})
const roleLabel = computed(() => formatRoleLabel(role.value))
const roleMessage = computed(() => roleAcknowledgmentMessage(role.value))
const notePreview = computed(() =>
previewAcknowledgmentNote(role.value, displayName.value, note.value),
)
function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
function onConfirm() {
emit('confirm', note.value.trim())
}
</script>
<template>
@@ -28,12 +54,36 @@ function severityVariant(severity) {
<p v-if="alert.details" class="text-sm text-gray-600 dark:text-gray-400">
{{ alert.details }}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
Confirm that you have reviewed this alert.
<div class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ displayName }}
<span class="text-gray-500 dark:text-gray-400">· {{ roleLabel }}</span>
</p>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
{{ roleMessage }}
</p>
</div>
<label class="block">
<span class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
Optional note
</span>
<textarea
v-model="note"
rows="3"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
placeholder="Add clinical context (optional)"
/>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
Audit record: {{ notePreview }}
</p>
<div class="flex justify-end gap-2">
<Button variant="ghost" @click="emit('close')">Cancel</Button>
<Button variant="primary" @click="emit('confirm')">Acknowledge</Button>
<Button variant="primary" @click="onConfirm">Acknowledge</Button>
</div>
</div>
</Modal>
@@ -5,6 +5,7 @@ import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
import { alertTypeLabel } from '@/api/normalize'
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
const props = defineProps({
alert: { type: Object, required: true },
@@ -62,6 +63,13 @@ function formatTime(iso) {
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
{{ formatTime(alert.triggeredAt) }}
</p>
<p
v-if="alert.acknowledgedBy"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
<span v-if="alert.acknowledgedAt">at {{ formatTime(alert.acknowledgedAt) }}</span>
</p>
</div>
<div v-if="showActions(alert.status)" class="flex w-full shrink-0 gap-2 sm:w-auto">
@@ -0,0 +1,102 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useAlertStore } from '@/stores/alerts'
import { useSettingsStore } from '@/stores/settings'
import { alertTypeLabel } from '@/api/normalize'
import {
formatAlertPatientLabel,
requestNotificationPermission,
stopCriticalTitleFlash,
} from '@/composables/useAlertNotification'
import Button from '@/components/ui/Button.vue'
const router = useRouter()
const alertStore = useAlertStore()
const settingsStore = useSettingsStore()
const { bannerAlerts } = storeToRefs(alertStore)
const { alertSoundMuted } = storeToRefs(settingsStore)
const showNotificationPrompt = computed(() => {
if (!('Notification' in window)) return false
return Notification.permission === 'default'
})
function dismissAll() {
alertStore.dismissAllBannerAlerts()
stopCriticalTitleFlash()
}
function dismissOne(alertId) {
alertStore.dismissBannerAlert(alertId)
if (bannerAlerts.value.length === 0) {
stopCriticalTitleFlash()
}
}
function openAlertCenter() {
router.push({ name: 'AlertCenter' })
}
async function enableNotifications() {
await requestNotificationPermission()
}
</script>
<template>
<div
v-if="bannerAlerts.length > 0"
class="border-b border-red-700 bg-red-600 text-white"
role="alert"
aria-live="assertive"
>
<div class="mx-auto flex max-w-7xl flex-col gap-4 px-4 py-4 lg:px-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold uppercase tracking-wide">Critical alert</p>
<p class="text-sm text-red-100">
{{ bannerAlerts.length }} new critical alert{{ bannerAlerts.length === 1 ? '' : 's' }} require attention
</p>
</div>
<div class="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-700" @click="settingsStore.toggleAlertSoundMute()">
{{ alertSoundMuted ? 'Unmute sound' : 'Mute sound' }}
</Button>
<Button
v-if="showNotificationPrompt"
size="sm"
variant="ghost"
class="!text-white hover:!bg-red-700"
@click="enableNotifications"
>
Enable notifications
</Button>
<Button size="sm" variant="secondary" @click="openAlertCenter">
Open Alert Center
</Button>
<Button size="sm" variant="secondary" @click="dismissAll">
Dismiss all
</Button>
</div>
</div>
<ul class="space-y-2">
<li
v-for="alert in bannerAlerts"
:key="alert.id"
class="flex flex-wrap items-start justify-between gap-4 rounded-lg bg-red-700/60 px-4 py-3"
>
<div class="min-w-0">
<p class="font-medium">{{ alertTypeLabel(alert.alertType) }}</p>
<p class="text-sm text-red-100">{{ formatAlertPatientLabel(alert) }}</p>
<p v-if="alert.details" class="mt-1 text-sm text-red-50">{{ alert.details }}</p>
</div>
<Button size="sm" variant="ghost" class="!text-white hover:!bg-red-800" @click="dismissOne(alert.id)">
Dismiss
</Button>
</li>
</ul>
</div>
</div>
</template>
@@ -0,0 +1,46 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
acuity: {
type: Object,
required: true,
},
})
const segments = computed(() => {
const total = props.acuity.low + props.acuity.medium + props.acuity.high
if (total === 0) {
return [
{ key: 'empty', label: 'No patients', pct: 100, className: 'bg-gray-200 dark:bg-gray-700' },
]
}
return [
{ key: 'low', label: 'Low', count: props.acuity.low, pct: (props.acuity.low / total) * 100, className: 'bg-green-500' },
{ key: 'medium', label: 'Medium', count: props.acuity.medium, pct: (props.acuity.medium / total) * 100, className: 'bg-amber-500' },
{ key: 'high', label: 'High', count: props.acuity.high, pct: (props.acuity.high / total) * 100, className: 'bg-red-500' },
].filter(segment => segment.count > 0)
})
</script>
<template>
<div>
<div class="flex h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
<div
v-for="segment in segments"
:key="segment.key"
class="h-full transition-all"
:class="segment.className"
:style="{ width: `${segment.pct}%` }"
:title="segment.label"
/>
</div>
<div class="mt-2 flex flex-wrap gap-3 text-xs text-gray-500 dark:text-gray-400">
<span v-for="segment in segments" :key="`${segment.key}-legend`">
<span class="mr-1 inline-block h-2 w-2 rounded-full" :class="segment.className" />
{{ segment.label }}: {{ segment.count ?? 0 }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,85 @@
<script setup>
import { computed } from 'vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import AcuityBar from '@/components/departments/AcuityBar.vue'
const props = defineProps({
department: { type: Object, required: true },
})
const emit = defineEmits(['select'])
const hasPatients = computed(() => props.department.patientCount > 0)
function onSelect() {
if (!hasPatients.value) return
emit('select', props.department.filterValue)
}
</script>
<template>
<button
type="button"
class="w-full text-left transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="hasPatients ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default opacity-80'"
:disabled="!hasPatients"
@click="onSelect"
>
<Card padding="lg" class="h-full">
<div class="mb-4 flex items-start justify-between gap-4">
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ department.label }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ department.patientCount }} active patient{{ department.patientCount === 1 ? '' : 's' }}
</p>
</div>
<Badge v-if="department.acuity.high > 0" variant="critical">
{{ department.acuity.high }} critical
</Badge>
</div>
<div class="space-y-4">
<div>
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
NEWS2 acuity
</p>
<AcuityBar :acuity="department.acuity" />
</div>
<dl class="grid grid-cols-2 gap-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Avg NEWS2</dt>
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
{{ department.averageNews2 ?? '—' }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Open alerts</dt>
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
{{ department.openAlertCount }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Sepsis bundles</dt>
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
{{ department.activeBundleCount }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Alert volume</dt>
<dd class="mt-1 font-semibold text-gray-900 dark:text-white">
{{ department.alertVolume }}
</dd>
</div>
</dl>
</div>
<p v-if="hasPatients" class="mt-4 text-xs font-medium text-blue-600 dark:text-blue-400">
View patients in Virtual Ward
</p>
</Card>
</button>
</template>
@@ -3,11 +3,14 @@ import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useWardStore } from '@/stores/ward'
import { useSettingsStore } from '@/stores/settings'
import { useDarkMode } from '@/composables/useDarkMode'
const route = useRoute()
const wardStore = useWardStore()
const settingsStore = useSettingsStore()
const { department } = storeToRefs(wardStore)
const { alertSoundMuted } = storeToRefs(settingsStore)
const { darkMode, toggle } = useDarkMode()
const pageTitle = computed(() => route.meta.title ?? 'VigilCare')
@@ -51,6 +54,50 @@ function onDepartmentChange(event) {
</select>
</label>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
:aria-label="alertSoundMuted ? 'Unmute critical alert sound' : 'Mute critical alert sound'"
@click="settingsStore.toggleAlertSoundMute()"
>
<svg
v-if="alertSoundMuted"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"
/>
</svg>
<svg
v-else
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.536 8.464a5 5 0 010 7.072M12 6a7 7 0 010 12M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"
/>
</svg>
</button>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-600 transition duration-200 hover:bg-gray-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:text-gray-300 dark:hover:bg-gray-800"
@@ -2,6 +2,12 @@
import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue'
import MobileNav from './MobileNav.vue'
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
import { useSettingsStore } from '@/stores/settings'
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
const settingsStore = useSettingsStore()
useCriticalAlertPolling(settingsStore.pollInterval)
</script>
<template>
@@ -9,6 +15,7 @@ import MobileNav from './MobileNav.vue'
<AppSidebar />
<div class="flex min-w-0 flex-1 flex-col">
<AppHeader />
<CriticalAlertBanner />
<main class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8">
<div class="mx-auto w-full max-w-7xl">
<slot />
@@ -5,6 +5,8 @@ const route = useRoute()
const links = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward' },
{ to: '/departments', label: 'Departments', icon: 'departments' },
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis' },
{ to: '/alerts', label: 'Alert Center', icon: 'alerts' },
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback' },
]
@@ -60,6 +62,36 @@ function linkClasses(path) {
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
<svg
v-else-if="link.icon === 'sepsis'"
class="h-6 w-6 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg
v-else-if="link.icon === 'departments'"
class="h-6 w-6 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<svg
v-else
class="h-6 w-6 shrink-0"
@@ -1,13 +1,14 @@
<script setup>
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { ref, computed } from 'vue'
import { useAlertStore } from '@/stores/alerts'
import { usePolling } from '@/composables/usePolling'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import AcknowledgeModal from '@/components/alerts/AcknowledgeModal.vue'
import { alertTypeLabel } from '@/api/normalize'
import { formatAcknowledgedByDisplay } from '@/composables/alertAcknowledge'
const props = defineProps({
encounterId: { type: String, required: true },
@@ -18,6 +19,7 @@ const emit = defineEmits(['select'])
const alertStore = useAlertStore()
const { alerts, loading } = storeToRefs(alertStore)
const confirmingAlert = ref(null)
function loadEncounterAlerts() {
return alertStore.loadAlerts(props.encounterId)
@@ -33,8 +35,10 @@ function severityVariant(severity) {
return severity === 'Critical' ? 'critical' : 'warning'
}
async function acknowledge(alertId) {
await alertStore.acknowledge(alertId)
async function handleAcknowledge(note) {
if (!confirmingAlert.value) return
await alertStore.acknowledge(confirmingAlert.value.id, note)
confirmingAlert.value = null
await loadEncounterAlerts()
}
@@ -72,13 +76,19 @@ async function resolve(alertId) {
<p v-if="alert.details" class="mt-2 truncate text-xs text-gray-500 dark:text-gray-400">
{{ alert.details }}
</p>
<p
v-if="alert.acknowledgedBy"
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
>
Acknowledged by {{ formatAcknowledgedByDisplay(alert.acknowledgedBy) }}
</p>
</div>
<div class="flex shrink-0 gap-2">
<Button
v-if="alert.status === 'Open' || alert.status === 'Escalated'"
size="sm"
variant="secondary"
@click.stop="acknowledge(alert.id)"
@click.stop="confirmingAlert = alert"
>
Ack
</Button>
@@ -93,5 +103,12 @@ async function resolve(alertId) {
</div>
</li>
</ul>
<AcknowledgeModal
:open="!!confirmingAlert"
:alert="confirmingAlert"
@confirm="handleAcknowledge"
@close="confirmingAlert = null"
/>
</Card>
</template>
@@ -0,0 +1,64 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
import { bundleElementLabel } from '@/api/normalize'
import {
bundleUrgency,
formatCountdown,
formatDepartment,
outstandingElements,
urgencyLabel,
urgencyVariant,
} from '@/composables/sepsisFormat'
const props = defineProps({
bundle: { type: Object, required: true },
now: { type: Number, required: true },
})
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
const outstanding = computed(() => outstandingElements(props.bundle))
</script>
<template>
<article
class="cursor-pointer rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition duration-200 hover:border-gray-300 hover:shadow-md active:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600 dark:active:bg-gray-800"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<h3 class="truncate text-base font-semibold text-gray-900 dark:text-white">
{{ bundle.firstName }} {{ bundle.lastName }}
</h3>
<p class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ formatDepartment(bundle.department) }}
<span v-if="bundle.roomBed"> · {{ bundle.roomBed }}</span>
</p>
</div>
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</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">Time remaining</dt>
<dd
class="mt-2 font-mono text-sm font-semibold"
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
>
{{ formatCountdown(bundle.deadlineAt, now) }}
</dd>
</div>
<div>
<dt class="text-xs text-gray-500 dark:text-gray-400">Outstanding</dt>
<dd class="mt-2 text-sm text-gray-900 dark:text-white">
<span v-if="outstanding.length === 0">None</span>
<ul v-else class="space-y-1">
<li v-for="element in outstanding" :key="element.id" class="text-xs">
{{ bundleElementLabel(element.elementType) }}
</li>
</ul>
</dd>
</div>
</dl>
</article>
</template>
@@ -0,0 +1,65 @@
<script setup>
import { computed } from 'vue'
import Badge from '@/components/ui/Badge.vue'
import { bundleElementLabel } from '@/api/normalize'
import {
bundleUrgency,
complianceStatusLabel,
formatCountdown,
formatDepartment,
outstandingElements,
urgencyLabel,
urgencyVariant,
} from '@/composables/sepsisFormat'
const props = defineProps({
bundle: { type: Object, required: true },
now: { type: Number, required: true },
})
const urgency = computed(() => bundleUrgency(props.bundle, props.now))
const outstanding = computed(() => outstandingElements(props.bundle))
</script>
<template>
<tr>
<td class="px-4 py-4">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ bundle.firstName }} {{ bundle.lastName }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ bundle.mrn }}</div>
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ formatDepartment(bundle.department) }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ bundle.roomBed ?? '—' }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ new Date(bundle.recognizedAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ new Date(bundle.deadlineAt).toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-right">
<span
class="font-mono text-sm font-semibold"
:class="urgency === 'overdue' ? 'text-red-600 dark:text-red-400' : 'text-gray-900 dark:text-white'"
>
{{ formatCountdown(bundle.deadlineAt, now) }}
</span>
</td>
<td class="whitespace-nowrap px-4 py-4">
<Badge :variant="urgencyVariant(urgency)">{{ urgencyLabel(urgency) }}</Badge>
</td>
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
<span class="text-gray-500 dark:text-gray-400">{{ complianceStatusLabel(bundle.complianceStatus) }}</span>
<ul v-if="outstanding.length" class="mt-2 space-y-1">
<li v-for="element in outstanding" :key="element.id" class="text-xs text-amber-700 dark:text-amber-300">
{{ bundleElementLabel(element.elementType) }}
</li>
</ul>
<span v-else class="mt-1 block text-xs text-green-700 dark:text-green-300">All elements complete</span>
</td>
</tr>
</template>
@@ -0,0 +1,55 @@
<script setup>
import { useRouter } from 'vue-router'
import SepsisBundleRow from './SepsisBundleRow.vue'
import SepsisBundleCard from './SepsisBundleCard.vue'
defineProps({
bundles: { type: Array, required: true },
now: { type: Number, required: true },
})
const router = useRouter()
function goToPatient(encounterId) {
router.push({ name: 'PatientDetail', params: { encounterId } })
}
</script>
<template>
<div class="space-y-4 md:hidden">
<SepsisBundleCard
v-for="bundle in bundles"
:key="bundle.id"
:bundle="bundle"
:now="now"
@click="goToPatient(bundle.encounterId)"
/>
</div>
<div class="hidden overflow-x-auto rounded-lg border border-gray-200 md:block dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
<tr>
<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-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Department</th>
<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">Started</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Deadline</th>
<th class="px-4 py-4 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Remaining</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Status</th>
<th class="px-4 py-4 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">Elements</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<SepsisBundleRow
v-for="bundle in bundles"
:key="bundle.id"
:bundle="bundle"
:now="now"
class="cursor-pointer transition duration-200 hover:bg-gray-50 dark:hover:bg-gray-800"
@click="goToPatient(bundle.encounterId)"
/>
</tbody>
</table>
</div>
</template>
@@ -9,6 +9,7 @@ import {
patientRoom,
stalenessClass,
} from '@/composables/wardFormat'
import { formatDepartment } from '@/composables/sepsisFormat'
const props = defineProps({ patient: { type: Object, required: true } })
@@ -50,6 +51,9 @@ const vitalsStaleness = computed(() =>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
</td>
<td class="whitespace-nowrap px-4 py-4 text-sm text-gray-700 dark:text-gray-300">
{{ formatDepartment(patient.department) }}
</td>
<td class="whitespace-nowrap px-4 py-4 text-right">
<Badge :variant="riskVariant">{{ patient.news2Score ?? '—' }}</Badge>
</td>
@@ -0,0 +1,57 @@
<script setup>
defineProps({
label: { type: String, required: true },
field: { type: String, required: true },
activeField: { type: String, required: true },
direction: { type: String, required: true },
align: {
type: String,
default: 'left',
validator: value => ['left', 'right'].includes(value),
},
})
const emit = defineEmits(['sort'])
</script>
<template>
<th
class="px-4 py-4 text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
:class="align === 'right' ? 'text-right' : 'text-left'"
>
<button
type="button"
class="inline-flex items-center gap-1 transition duration-200 hover:text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:hover:text-gray-200"
:class="[
align === 'right' ? 'ml-auto' : '',
activeField === field ? 'text-gray-900 dark:text-white' : '',
]"
@click="emit('sort', field)"
>
<span>{{ label }}</span>
<svg
v-if="activeField === field"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
v-if="direction === 'asc'"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 15l7-7 7 7"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
</th>
</template>
@@ -2,8 +2,15 @@
import { useRouter } from 'vue-router'
import PatientRow from './PatientRow.vue'
import PatientCard from './PatientCard.vue'
import SortableHeader from './SortableHeader.vue'
defineProps({ patients: { type: Array, required: true } })
defineProps({
patients: { type: Array, required: true },
sortField: { type: String, required: true },
sortDirection: { type: String, required: true },
})
const emit = defineEmits(['sort'])
const router = useRouter()
function goToPatient(encounterId) {
@@ -27,17 +34,72 @@ function goToPatient(encounterId) {
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="sticky top-0 bg-gray-50 dark:bg-gray-800">
<tr>
<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>
<SortableHeader
label="Room"
field="roomBed"
:active-field="sortField"
:direction="sortDirection"
@sort="emit('sort', $event)"
/>
<SortableHeader
label="Patient"
field="name"
:active-field="sortField"
:direction="sortDirection"
@sort="emit('sort', $event)"
/>
<SortableHeader
label="Department"
field="department"
:active-field="sortField"
:direction="sortDirection"
@sort="emit('sort', $event)"
/>
<SortableHeader
label="NEWS2"
field="news2Score"
:active-field="sortField"
:direction="sortDirection"
align="right"
@sort="emit('sort', $event)"
/>
<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>
<SortableHeader
label="qSOFA"
field="qsofaScore"
:active-field="sortField"
:direction="sortDirection"
align="right"
@sort="emit('sort', $event)"
/>
<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>
<SortableHeader
label="Sepsis"
field="sepsis"
:active-field="sortField"
:direction="sortDirection"
@sort="emit('sort', $event)"
/>
<SortableHeader
label="Alerts"
field="openAlertCount"
:active-field="sortField"
:direction="sortDirection"
@sort="emit('sort', $event)"
/>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
@@ -0,0 +1,50 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useWardStore } from '@/stores/ward'
import Button from '@/components/ui/Button.vue'
const wardStore = useWardStore()
const { searchInput, filters, hasActiveFilters } = storeToRefs(wardStore)
const filterOptions = [
{ key: 'hasAlerts', label: 'Has alerts' },
{ key: 'sepsisActive', label: 'Sepsis active' },
{ key: 'critical', label: 'Critical (NEWS2 ≥ 7)' },
]
</script>
<template>
<div class="mb-4 space-y-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<label class="block">
<span class="sr-only">Search patients</span>
<input
:value="searchInput"
type="search"
placeholder="Search by name or MRN…"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
@input="wardStore.setSearchInput($event.target.value)"
>
</label>
<div class="flex flex-wrap items-center gap-2">
<Button
v-for="option in filterOptions"
:key="option.key"
size="sm"
:variant="filters[option.key] ? 'primary' : 'secondary'"
@click="wardStore.toggleFilter(option.key)"
>
{{ option.label }}
</Button>
<Button
v-if="hasActiveFilters"
size="sm"
variant="ghost"
@click="wardStore.clearFilters()"
>
Clear filters
</Button>
</div>
</div>
</template>
@@ -0,0 +1,47 @@
export function formatRoleLabel(role) {
const map = {
NURSE: 'Nurse',
PHYSICIAN: 'Physician',
ADMIN: 'Administrator',
INTEGRATION: 'Integration',
}
return map[role] ?? role?.replace(/_/g, ' ') ?? 'Clinician'
}
export function roleAcknowledgmentMessage(role) {
const messages = {
NURSE: 'You are acknowledging as a nurse — documenting awareness of this alert.',
PHYSICIAN: 'You are acknowledging as a physician — confirming clinical assessment.',
ADMIN: 'You are acknowledging as an administrator — documenting review of this alert.',
INTEGRATION: 'You are acknowledging as an integration account.',
}
return messages[role] ?? 'You are acknowledging this alert in your current role.'
}
export function previewAcknowledgmentNote(role, displayName, userNote = '') {
const roleLabel = role ?? 'UNKNOWN'
const prefix = `[${roleLabel}] Acknowledged by ${displayName}.`
const trimmed = userNote.trim()
return trimmed ? `${prefix} ${trimmed}` : prefix
}
export function parseAcknowledgedBy(acknowledgedBy) {
if (!acknowledgedBy) return { name: null, role: null }
const match = acknowledgedBy.match(/^(.+)\s+\(([A-Z_]+)\)$/)
if (match) {
return {
name: match[1].trim(),
role: match[2],
}
}
return { name: acknowledgedBy, role: null }
}
export function formatAcknowledgedByDisplay(acknowledgedBy) {
const { name, role } = parseAcknowledgedBy(acknowledgedBy)
if (!name) return '—'
if (!role) return name
return `${name} (${formatRoleLabel(role)})`
}
@@ -0,0 +1,19 @@
export function isNotifiableCriticalAlert(alert) {
return (
alert.severity === 'Critical'
&& (alert.status === 'Open' || alert.status === 'Escalated')
)
}
export function detectNewCriticalAlerts(alerts, lastSeenIds, seeded = false) {
const criticalOpen = alerts.filter(isNotifiableCriticalAlert)
const criticalIds = criticalOpen.map(alert => alert.id)
if (!seeded) {
return { newAlerts: [], nextSeenIds: criticalIds, seeded: true }
}
const seen = new Set(lastSeenIds)
const newAlerts = criticalOpen.filter(alert => !seen.has(alert.id))
return { newAlerts, nextSeenIds: criticalIds, seeded: true }
}
@@ -0,0 +1,128 @@
import { formatDepartment } from '@/composables/sepsisFormat'
export const KNOWN_DEPARTMENTS = ['ICU', 'GENERAL_MEDICINE', 'SURGERY']
const DEPARTMENT_KEY_MAP = {
ICU: 'ICU',
Icu: 'ICU',
GENERAL_MEDICINE: 'GENERAL_MEDICINE',
GeneralMedicine: 'GENERAL_MEDICINE',
SURGERY: 'SURGERY',
Surgery: 'SURGERY',
EMERGENCY: 'EMERGENCY',
Emergency: 'EMERGENCY',
CARDIOLOGY: 'CARDIOLOGY',
Cardiology: 'CARDIOLOGY',
PEDIATRICS: 'PEDIATRICS',
Pediatrics: 'PEDIATRICS',
}
export function normalizeDepartmentKey(department) {
if (!department) return 'UNKNOWN'
return DEPARTMENT_KEY_MAP[department] ?? department
}
export function departmentFilterValue(key) {
if (KNOWN_DEPARTMENTS.includes(key)) return key
return key
}
export function news2AcuityTier(score) {
const value = score ?? 0
if (value >= 7) return 'high'
if (value >= 5) return 'medium'
return 'low'
}
function emptyBucket(key) {
return {
key,
label: formatDepartment(key),
filterValue: departmentFilterValue(key),
patientCount: 0,
acuity: { low: 0, medium: 0, high: 0 },
activeBundleCount: 0,
openAlertCount: 0,
news2Sum: 0,
news2Count: 0,
alertVolume: 0,
}
}
function isActiveBundle(encounter) {
return Boolean(encounter.sepsisActive || encounter.sepsisBundleStatus === 'IN_PROGRESS')
}
export function aggregateByDepartment(encounters, alertSummaryRows = []) {
const alertByDept = new Map()
for (const row of alertSummaryRows) {
const key = normalizeDepartmentKey(row.department)
alertByDept.set(key, (alertByDept.get(key) ?? 0) + Number(row.total ?? 0))
}
const buckets = new Map()
for (const key of KNOWN_DEPARTMENTS) {
buckets.set(key, emptyBucket(key))
}
function ensure(key) {
if (!buckets.has(key)) {
buckets.set(key, emptyBucket(key))
}
const bucket = buckets.get(key)
bucket.alertVolume = alertByDept.get(key) ?? bucket.alertVolume
return bucket
}
for (const encounter of encounters) {
const key = normalizeDepartmentKey(encounter.department)
const bucket = ensure(key)
bucket.patientCount += 1
const tier = news2AcuityTier(encounter.news2Score)
bucket.acuity[tier] += 1
if (isActiveBundle(encounter)) bucket.activeBundleCount += 1
bucket.openAlertCount += encounter.openAlertCount ?? 0
if (encounter.news2Score != null) {
bucket.news2Sum += encounter.news2Score
bucket.news2Count += 1
}
}
for (const [key, total] of alertByDept) {
ensure(key).alertVolume = total
}
return [...buckets.values()]
.map(bucket => ({
...bucket,
averageNews2:
bucket.news2Count > 0
? Math.round((bucket.news2Sum / bucket.news2Count) * 10) / 10
: null,
}))
.sort((a, b) => {
const knownA = KNOWN_DEPARTMENTS.indexOf(a.key)
const knownB = KNOWN_DEPARTMENTS.indexOf(b.key)
if (knownA !== -1 || knownB !== -1) {
if (knownA === -1) return 1
if (knownB === -1) return -1
return knownA - knownB
}
return b.patientCount - a.patientCount
})
}
export function summarizeDepartments(departments) {
return departments.reduce(
(totals, dept) => ({
patientCount: totals.patientCount + dept.patientCount,
criticalCount: totals.criticalCount + dept.acuity.high,
activeBundleCount: totals.activeBundleCount + dept.activeBundleCount,
openAlertCount: totals.openAlertCount + dept.openAlertCount,
}),
{ patientCount: 0, criticalCount: 0, activeBundleCount: 0, openAlertCount: 0 },
)
}
@@ -0,0 +1,87 @@
const AT_RISK_MS = 30 * 60 * 1000
export function remainingMs(deadlineAt, now = Date.now()) {
if (!deadlineAt) return 0
return new Date(deadlineAt).getTime() - now
}
export function formatCountdown(deadlineAt, now = Date.now()) {
const ms = Math.max(0, remainingMs(deadlineAt, now))
const mins = Math.floor(ms / 60_000)
const secs = Math.floor((ms % 60_000) / 1000)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
export function bundleUrgency(bundle, now = Date.now()) {
const status = bundle.complianceStatus
if (status === 'NON_COMPLIANT') return 'overdue'
if (status === 'COMPLIANT') return 'compliant'
if (remainingMs(bundle.deadlineAt, now) <= 0) return 'overdue'
if (remainingMs(bundle.deadlineAt, now) <= AT_RISK_MS) return 'at_risk'
return 'on_track'
}
export function urgencyVariant(urgency) {
if (urgency === 'overdue') return 'critical'
if (urgency === 'at_risk') return 'warning'
if (urgency === 'compliant') return 'success'
return 'success'
}
export function urgencyLabel(urgency) {
const map = {
on_track: 'On track',
at_risk: 'At risk',
overdue: 'Overdue',
compliant: 'Compliant',
}
return map[urgency] ?? urgency
}
export function sortBundlesByUrgency(bundles, now = Date.now()) {
const order = { overdue: 0, at_risk: 1, on_track: 2, compliant: 3 }
return [...bundles].sort((a, b) => {
const ua = bundleUrgency(a, now)
const ub = bundleUrgency(b, now)
if (order[ua] !== order[ub]) return order[ua] - order[ub]
return remainingMs(a.deadlineAt, now) - remainingMs(b.deadlineAt, now)
})
}
export function completedElements(bundle) {
return (bundle.elements ?? []).filter(e => e.status === 'Completed')
}
export function outstandingElements(bundle) {
return (bundle.elements ?? []).filter(e => e.status !== 'Completed')
}
export function formatDepartment(department) {
const map = {
ICU: 'ICU',
Icu: 'ICU',
GENERAL_MEDICINE: 'General Medicine',
GeneralMedicine: 'General Medicine',
SURGERY: 'Surgery',
Surgery: 'Surgery',
}
return map[department] ?? department?.replace(/_/g, ' ') ?? '—'
}
export function complianceStatusLabel(status) {
const map = {
IN_PROGRESS: 'In progress',
COMPLIANT: 'Compliant',
NON_COMPLIANT: 'Non-compliant',
}
return map[status] ?? status
}
export function summarizeBundles(bundles, now = Date.now()) {
const counts = { on_track: 0, at_risk: 0, overdue: 0 }
for (const bundle of bundles) {
const urgency = bundleUrgency(bundle, now)
if (urgency in counts) counts[urgency]++
}
return counts
}
@@ -0,0 +1,117 @@
import { onBeforeUnmount } from 'vue'
import { useWardStore } from '@/stores/ward'
import { useSettingsStore } from '@/stores/settings'
import { alertTypeLabel } from '@/api/normalize'
const DEFAULT_TITLE = 'VigilCare'
let titleFlashTimer = null
let titleFlashOriginal = DEFAULT_TITLE
export function playCriticalTone() {
try {
const ctx = new AudioContext()
const playBeep = (startTime) => {
const oscillator = ctx.createOscillator()
const gain = ctx.createGain()
oscillator.type = 'square'
oscillator.frequency.value = 880
gain.gain.setValueAtTime(0.12, startTime)
gain.gain.exponentialRampToValueAtTime(0.001, startTime + 0.35)
oscillator.connect(gain)
gain.connect(ctx.destination)
oscillator.start(startTime)
oscillator.stop(startTime + 0.35)
}
playBeep(ctx.currentTime)
playBeep(ctx.currentTime + 0.45)
window.setTimeout(() => ctx.close(), 1000)
} catch {
// Autoplay may be blocked until user interaction.
}
}
export function flashCriticalTitle() {
if (titleFlashTimer) return
titleFlashOriginal = document.title || DEFAULT_TITLE
let showAlert = true
titleFlashTimer = window.setInterval(() => {
document.title = showAlert ? '⚠ CRITICAL ALERT — VigilCare' : titleFlashOriginal
showAlert = !showAlert
}, 1000)
}
export function stopCriticalTitleFlash() {
if (titleFlashTimer) {
clearInterval(titleFlashTimer)
titleFlashTimer = null
}
if (document.title.includes('CRITICAL ALERT')) {
document.title = titleFlashOriginal || DEFAULT_TITLE
}
}
export async function requestNotificationPermission() {
if (!('Notification' in window)) return 'unsupported'
if (Notification.permission === 'granted') return 'granted'
if (Notification.permission === 'denied') return 'denied'
return Notification.requestPermission()
}
export function formatAlertPatientLabel(alert) {
const wardStore = useWardStore()
const encounter = wardStore.encounters.find(
item => item.encounterId === alert.encounterId,
)
if (encounter) {
return `${encounter.firstName} ${encounter.lastName} (${encounter.mrn})`
}
const patient = alert.encounter?.patient
if (patient?.firstName || patient?.lastName) {
const mrn = patient.mrn ? ` (${patient.mrn})` : ''
return `${patient.firstName ?? ''} ${patient.lastName ?? ''}${mrn}`.trim()
}
return alert.details ?? 'Critical patient alert'
}
export function showBrowserNotification(alert) {
if (!('Notification' in window) || Notification.permission !== 'granted') return
const patientLabel = formatAlertPatientLabel(alert)
const body = alert.details
? `${patientLabel}${alert.details}`
: patientLabel
new Notification(`Critical: ${alertTypeLabel(alert.alertType)}`, {
body,
tag: alert.id,
})
}
export function useAlertNotification() {
const settings = useSettingsStore()
function notifyNewCriticalAlerts(alerts) {
if (alerts.length === 0) return
if (!settings.alertSoundMuted) {
playCriticalTone()
}
flashCriticalTitle()
for (const alert of alerts) {
showBrowserNotification(alert)
}
}
onBeforeUnmount(stopCriticalTitleFlash)
return {
notifyNewCriticalAlerts,
requestNotificationPermission,
stopCriticalTitleFlash,
playCriticalTone,
}
}
@@ -0,0 +1,21 @@
import { useAuthStore } from '@/stores/auth'
import { useAlertStore } from '@/stores/alerts'
import { usePolling } from '@/composables/usePolling'
import { useAlertNotification } from '@/composables/useAlertNotification'
export function useCriticalAlertPolling(intervalMs = 10_000) {
const authStore = useAuthStore()
const alertStore = useAlertStore()
const { notifyNewCriticalAlerts } = useAlertNotification()
async function poll() {
if (!authStore.isAuthenticated) {
alertStore.dismissAllBannerAlerts()
return
}
const newAlerts = await alertStore.pollOpenAlerts()
notifyNewCriticalAlerts(newAlerts)
}
usePolling(poll, intervalMs)
}
@@ -0,0 +1,38 @@
export function matchesPatientSearch(encounter, query) {
const trimmed = query.trim()
if (!trimmed) return true
const needle = trimmed.toLowerCase()
const mrn = (encounter.mrn ?? '').toLowerCase()
const firstName = (encounter.firstName ?? '').toLowerCase()
const lastName = (encounter.lastName ?? '').toLowerCase()
const fullName = `${firstName} ${lastName}`.trim()
return (
mrn.includes(needle)
|| firstName.includes(needle)
|| lastName.includes(needle)
|| fullName.includes(needle)
)
}
function isSepsisActive(encounter) {
return Boolean(encounter.sepsisActive || encounter.sepsisBundleStatus === 'IN_PROGRESS')
}
export function filterEncounters(
encounters,
{ search = '', hasAlerts = false, sepsisActive = false, critical = false } = {},
) {
return encounters.filter(encounter => {
if (!matchesPatientSearch(encounter, search)) return false
if (hasAlerts && !(encounter.openAlertCount > 0)) return false
if (sepsisActive && !isSepsisActive(encounter)) return false
if (critical && (encounter.news2Score ?? 0) < 7) return false
return true
})
}
export function hasWardFilters({ search = '', hasAlerts, sepsisActive, critical } = {}) {
return Boolean(search.trim() || hasAlerts || sepsisActive || critical)
}
@@ -0,0 +1,64 @@
export const DEFAULT_WARD_SORT_FIELD = 'news2Score'
export const DEFAULT_WARD_SORT_DIRECTION = 'desc'
export const WARD_SORT_FIELDS = [
{ key: 'roomBed', label: 'Room' },
{ key: 'name', label: 'Patient' },
{ key: 'department', label: 'Department' },
{ key: 'news2Score', label: 'NEWS2' },
{ key: 'qsofaScore', label: 'qSOFA' },
{ key: 'sepsis', label: 'Sepsis' },
{ key: 'openAlertCount', label: 'Alerts' },
]
const SEPSIS_RANK = {
IN_PROGRESS: 3,
NON_COMPLIANT: 2,
COMPLIANT: 1,
}
export function defaultSortDirection(field) {
if (field === 'name' || field === 'roomBed' || field === 'department') return 'asc'
return 'desc'
}
function getSortValue(encounter, field) {
switch (field) {
case 'name':
return `${encounter.lastName ?? ''} ${encounter.firstName ?? ''}`.trim().toLowerCase()
case 'roomBed':
return (encounter.roomBed ?? encounter.room ?? '').toLowerCase()
case 'department':
return (encounter.department ?? '').toLowerCase()
case 'news2Score':
return encounter.news2Score ?? -1
case 'qsofaScore':
return encounter.qsofaScore ?? -1
case 'openAlertCount':
return encounter.openAlertCount ?? 0
case 'sepsis':
if (encounter.sepsisActive) return 4
return SEPSIS_RANK[encounter.sepsisBundleStatus] ?? 0
default:
return encounter.news2Score ?? -1
}
}
function compareSortValues(a, b) {
if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })
}
if (a < b) return -1
if (a > b) return 1
return 0
}
export function sortEncounters(encounters, field, direction) {
const multiplier = direction === 'asc' ? 1 : -1
return [...encounters].sort((left, right) => {
const primary = compareSortValues(getSortValue(left, field), getSortValue(right, field)) * multiplier
if (primary !== 0) return primary
return (right.news2Score ?? 0) - (left.news2Score ?? 0)
})
}
+12
View File
@@ -18,6 +18,12 @@ const routes = [
component: () => import('@/views/WardDashboard.vue'),
meta: { title: 'Virtual Ward', layout: 'default' },
},
{
path: '/departments',
name: 'DepartmentOverview',
component: () => import('@/views/DepartmentOverviewView.vue'),
meta: { title: 'Department Overview', layout: 'default' },
},
{
path: '/patients/:encounterId',
name: 'PatientDetail',
@@ -30,6 +36,12 @@ const routes = [
component: () => import('@/views/AlertCenter.vue'),
meta: { title: 'Alert Center', layout: 'default' },
},
{
path: '/sepsis',
name: 'SepsisBoard',
component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default' },
},
{
path: '/feedback',
name: 'FeedbackSummary',
+73 -3
View File
@@ -1,15 +1,42 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as alertsApi from '@/api/alerts'
import { detectNewCriticalAlerts } from '@/composables/criticalAlertDetect'
export const useAlertStore = defineStore('alerts', () => {
const alerts = ref([])
const loading = ref(false)
const statusFilter = ref(null)
const lastSeenAlertIds = ref([])
const bannerAlerts = ref([])
const pollSeeded = ref(false)
const openAlerts = computed(() => alerts.value.filter(a => a.status === 'Open'))
const criticalAlerts = computed(() => alerts.value.filter(a => a.severity === 'Critical'))
function mergeBannerAlerts(newAlerts) {
const existingIds = new Set(bannerAlerts.value.map(alert => alert.id))
const merged = [...bannerAlerts.value]
for (const alert of newAlerts) {
if (!existingIds.has(alert.id)) merged.push(alert)
}
bannerAlerts.value = merged
}
function applyPollResults(items) {
const { newAlerts, nextSeenIds, seeded } = detectNewCriticalAlerts(
items,
lastSeenAlertIds.value,
pollSeeded.value,
)
lastSeenAlertIds.value = nextSeenIds
pollSeeded.value = seeded
if (newAlerts.length > 0) {
mergeBannerAlerts(newAlerts)
}
return newAlerts
}
async function loadAlerts(encounterId, status) {
loading.value = true
try {
@@ -35,17 +62,60 @@ export const useAlertStore = defineStore('alerts', () => {
}
}
async function pollOpenAlerts() {
try {
const items = await alertsApi.fetchAllOpenAlerts()
alerts.value = items
return applyPollResults(items)
} catch (e) {
console.error('Failed to poll alerts', e)
return []
}
}
function dismissBannerAlert(alertId) {
bannerAlerts.value = bannerAlerts.value.filter(alert => alert.id !== alertId)
}
function dismissAllBannerAlerts() {
bannerAlerts.value = []
}
async function acknowledge(alertId, note) {
await alertsApi.acknowledgeAlert(alertId, note)
const updated = await alertsApi.acknowledgeAlert(alertId, note || undefined)
const alert = alerts.value.find(a => a.id === alertId)
if (alert) alert.status = 'Acknowledged'
if (alert) {
alert.status = updated.status ?? 'Acknowledged'
alert.acknowledgedBy = updated.acknowledgedBy ?? alert.acknowledgedBy
alert.acknowledgedAt = updated.acknowledgedAt ?? alert.acknowledgedAt
}
dismissBannerAlert(alertId)
return updated
}
async function resolve(alertId) {
await alertsApi.resolveAlert(alertId)
const alert = alerts.value.find(a => a.id === alertId)
if (alert) alert.status = 'Resolved'
dismissBannerAlert(alertId)
}
return { alerts, loading, statusFilter, openAlerts, criticalAlerts, loadAlerts, loadGlobalAlerts, acknowledge, resolve }
return {
alerts,
loading,
statusFilter,
lastSeenAlertIds,
bannerAlerts,
pollSeeded,
openAlerts,
criticalAlerts,
loadAlerts,
loadGlobalAlerts,
pollOpenAlerts,
dismissBannerAlert,
dismissAllBannerAlerts,
acknowledge,
resolve,
applyPollResults,
}
})
+3
View File
@@ -10,6 +10,9 @@ export const useAuthStore = defineStore('auth', {
getters: {
isAuthenticated: (state) => !!state.token,
role: (state) => state.user?.role ?? null,
displayName: (state) =>
state.user?.displayName ?? state.user?.username ?? 'Unknown user',
userId: (state) => state.user?.userId ?? null,
},
actions: {
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchAllActiveEncounters } from '@/api/encounters'
import { fetchAlertSummary } from '@/api/analytics'
import { aggregateByDepartment, summarizeDepartments } from '@/composables/departmentFormat'
export const useDepartmentsStore = defineStore('departments', () => {
const encounters = ref([])
const alertSummary = ref([])
const loading = ref(false)
const error = ref(null)
const departments = computed(() =>
aggregateByDepartment(encounters.value, alertSummary.value),
)
const totals = computed(() => summarizeDepartments(departments.value))
async function load() {
loading.value = true
error.value = null
try {
const [encounterItems, alertData] = await Promise.all([
fetchAllActiveEncounters(),
fetchAlertSummary(),
])
encounters.value = encounterItems
alertSummary.value = alertData.summary ?? []
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
return { encounters, alertSummary, departments, totals, loading, error, load }
})
+35
View File
@@ -0,0 +1,35 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { fetchSepsisBundles } from '@/api/sepsis'
import { sortBundlesByUrgency, summarizeBundles } from '@/composables/sepsisFormat'
export const useSepsisStore = defineStore('sepsis', () => {
const bundles = ref([])
const loading = ref(false)
const error = ref(null)
const now = ref(Date.now())
const sortedBundles = computed(() => sortBundlesByUrgency(bundles.value, now.value))
const summary = computed(() => summarizeBundles(bundles.value, now.value))
async function loadBundles(status = 'IN_PROGRESS') {
loading.value = true
error.value = null
try {
const data = await fetchSepsisBundles({ status, pageSize: 100 })
bundles.value = data.items ?? []
now.value = Date.now()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
}
function tick() {
now.value = Date.now()
}
return { bundles, loading, error, sortedBundles, summary, now, loadBundles, tick }
})
+34 -2
View File
@@ -1,10 +1,17 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import {
DEFAULT_WARD_SORT_DIRECTION,
DEFAULT_WARD_SORT_FIELD,
defaultSortDirection,
} from '@/composables/wardSort'
export const useSettingsStore = defineStore('settings', () => {
const darkMode = ref(localStorage.getItem('darkMode') === 'true')
const pollInterval = ref(10_000)
const clinicianId = ref(localStorage.getItem('clinicianId') ?? 'DR-DEMO')
const wardSortField = ref(localStorage.getItem('wardSortField') ?? DEFAULT_WARD_SORT_FIELD)
const wardSortDirection = ref(localStorage.getItem('wardSortDirection') ?? DEFAULT_WARD_SORT_DIRECTION)
const alertSoundMuted = ref(localStorage.getItem('alertSoundMuted') === 'true')
watch(darkMode, (val) => {
localStorage.setItem('darkMode', val)
@@ -15,5 +22,30 @@ export const useSettingsStore = defineStore('settings', () => {
darkMode.value = !darkMode.value
}
return { darkMode, pollInterval, clinicianId, toggleDarkMode }
function toggleAlertSoundMute() {
alertSoundMuted.value = !alertSoundMuted.value
localStorage.setItem('alertSoundMuted', String(alertSoundMuted.value))
}
function setWardSort(field) {
if (wardSortField.value === field) {
wardSortDirection.value = wardSortDirection.value === 'desc' ? 'asc' : 'desc'
} else {
wardSortField.value = field
wardSortDirection.value = defaultSortDirection(field)
}
localStorage.setItem('wardSortField', wardSortField.value)
localStorage.setItem('wardSortDirection', wardSortDirection.value)
}
return {
darkMode,
pollInterval,
wardSortField,
wardSortDirection,
alertSoundMuted,
toggleDarkMode,
toggleAlertSoundMute,
setWardSort,
}
})
+72 -5
View File
@@ -1,20 +1,49 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { refDebounced } from '@vueuse/core'
import { fetchActiveEncounters } from '@/api/encounters'
import { fetchCurrentNews2 } from '@/api/clinical'
import { filterEncounters, hasWardFilters } from '@/composables/wardFilter'
import { sortEncounters } from '@/composables/wardSort'
import { useSettingsStore } from '@/stores/settings'
export const useWardStore = defineStore('ward', () => {
const encounters = ref([])
const loading = ref(false)
const error = ref(null)
const department = ref(null)
const searchInput = ref('')
const debouncedSearch = refDebounced(searchInput, 300)
const filters = ref({
hasAlerts: false,
sepsisActive: false,
critical: false,
})
const settings = useSettingsStore()
const sortedByRisk = computed(() =>
[...encounters.value].sort((a, b) => (b.news2Score ?? 0) - (a.news2Score ?? 0))
const filteredEncounters = computed(() =>
filterEncounters(encounters.value, {
search: debouncedSearch.value,
...filters.value,
}),
)
const displayEncounters = computed(() =>
sortEncounters(
filteredEncounters.value,
settings.wardSortField,
settings.wardSortDirection,
),
)
const hasActiveFilters = computed(() =>
hasWardFilters({
search: debouncedSearch.value,
...filters.value,
}),
)
const criticalCount = computed(() =>
encounters.value.filter(e => (e.news2Score ?? 0) >= 7).length
encounters.value.filter(e => (e.news2Score ?? 0) >= 7).length,
)
async function loadEncounters() {
@@ -35,5 +64,43 @@ export const useWardStore = defineStore('ward', () => {
loadEncounters()
}
return { encounters, loading, error, department, sortedByRisk, criticalCount, loadEncounters, setDepartment }
function setSort(field) {
settings.setWardSort(field)
}
function setSearchInput(value) {
searchInput.value = value
}
function toggleFilter(key) {
filters.value[key] = !filters.value[key]
}
function clearFilters() {
searchInput.value = ''
filters.value = {
hasAlerts: false,
sepsisActive: false,
critical: false,
}
}
return {
encounters,
loading,
error,
department,
searchInput,
filters,
filteredEncounters,
displayEncounters,
hasActiveFilters,
criticalCount,
loadEncounters,
setDepartment,
setSort,
setSearchInput,
toggleFilter,
clearFilters,
}
})
@@ -19,9 +19,9 @@ watch(activeFilter, (status) => {
alertStore.loadGlobalAlerts(alertStatusToApiFilter(status))
}, { immediate: true })
async function handleAcknowledge() {
async function handleAcknowledge(note) {
if (!confirmingAlert.value) return
await alertStore.acknowledge(confirmingAlert.value.id)
await alertStore.acknowledge(confirmingAlert.value.id, note)
confirmingAlert.value = null
alertStore.loadGlobalAlerts(alertStatusToApiFilter(activeFilter.value))
}
@@ -0,0 +1,80 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useDepartmentsStore } from '@/stores/departments'
import { usePolling } from '@/composables/usePolling'
import DepartmentCard from '@/components/departments/DepartmentCard.vue'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
const router = useRouter()
const departmentsStore = useDepartmentsStore()
const { departments, totals, loading, error } = storeToRefs(departmentsStore)
usePolling(() => departmentsStore.load(), 10_000)
function onSelectDepartment(filterValue) {
router.push({ name: 'WardDashboard', query: { department: filterValue } })
}
</script>
<template>
<div class="space-y-8">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Department Overview</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Unit-level snapshot across active patients, acuity, alerts, and sepsis bundles.
</p>
</div>
<div class="flex flex-wrap gap-2">
<Badge v-if="totals.criticalCount > 0" variant="critical">
{{ totals.criticalCount }} critical
</Badge>
<Badge v-if="totals.openAlertCount > 0" variant="warning">
{{ totals.openAlertCount }} open alerts
</Badge>
</div>
</div>
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<div class="text-center">
<div class="text-3xl font-bold dark:text-white">{{ totals.patientCount }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Active patients</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-red-600">{{ totals.criticalCount }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Critical (NEWS2 7)</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-amber-600">{{ totals.openAlertCount }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Open alerts</div>
</div>
</Card>
<Card>
<div class="text-center">
<div class="text-3xl font-bold text-blue-600">{{ totals.activeBundleCount }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">Active sepsis bundles</div>
</div>
</Card>
</div>
<Skeleton v-if="loading && departments.length === 0" :rows="3" />
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-3">
<DepartmentCard
v-for="department in departments"
:key="department.key"
:department="department"
@select="onSelectDepartment"
/>
</div>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup>
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { storeToRefs } from 'pinia'
import { useSepsisStore } from '@/stores/sepsis'
import { usePolling } from '@/composables/usePolling'
import SepsisBundleTable from '@/components/sepsis/SepsisBundleTable.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import Badge from '@/components/ui/Badge.vue'
const sepsisStore = useSepsisStore()
const { sortedBundles, loading, error, summary, now } = storeToRefs(sepsisStore)
usePolling(() => sepsisStore.loadBundles(), 10_000)
let countdownTimer = null
onMounted(() => {
sepsisStore.loadBundles()
countdownTimer = setInterval(() => sepsisStore.tick(), 1000)
})
onBeforeUnmount(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
const summaryLine = computed(() => {
const total = sortedBundles.value.length
if (total === 0) return 'No active sepsis bundles'
const { on_track: onTrack, at_risk: atRisk, overdue } = summary.value
return `${total} active bundle${total === 1 ? '' : 's'}${onTrack} on track, ${atRisk} at risk, ${overdue} overdue`
})
</script>
<template>
<div>
<div class="mb-4 flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-xl font-bold dark:text-white">Sepsis Bundle Board</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">{{ summaryLine }}</p>
</div>
<div class="flex flex-wrap gap-2">
<Badge v-if="summary.overdue > 0" variant="critical">{{ summary.overdue }} overdue</Badge>
<Badge v-if="summary.at_risk > 0" variant="warning">{{ summary.at_risk }} at risk</Badge>
</div>
</div>
<p v-if="error" class="mb-4 text-sm text-red-600 dark:text-red-400">{{ error }}</p>
<Skeleton v-if="loading && sortedBundles.length === 0" :rows="4" />
<EmptyState v-else-if="sortedBundles.length === 0" message="No active sepsis bundles" />
<SepsisBundleTable
v-else
:bundles="sortedBundles"
:now="now"
/>
</div>
</template>
@@ -1,14 +1,32 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useRoute } from 'vue-router'
import { useWardStore } from '@/stores/ward'
import { useSettingsStore } from '@/stores/settings'
import { usePolling } from '@/composables/usePolling'
import { WARD_SORT_FIELDS } from '@/composables/wardSort'
import WardToolbar from '@/components/ward/WardToolbar.vue'
import WardTable from '@/components/ward/WardTable.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import Badge from '@/components/ui/Badge.vue'
const route = useRoute()
const wardStore = useWardStore()
const { sortedByRisk, loading, error, criticalCount, department } = storeToRefs(wardStore)
const settingsStore = useSettingsStore()
const {
encounters,
displayEncounters,
loading,
error,
criticalCount,
department,
} = storeToRefs(wardStore)
const { wardSortField, wardSortDirection } = storeToRefs(settingsStore)
if (route.query.department) {
wardStore.setDepartment(String(route.query.department))
}
usePolling(() => wardStore.loadEncounters(), 10_000)
@@ -22,6 +40,10 @@ const departments = [
function onDepartmentChange(event) {
wardStore.setDepartment(event.target.value || null)
}
function onMobileSortChange(event) {
wardStore.setSort(event.target.value)
}
</script>
<template>
@@ -48,8 +70,38 @@ function onDepartmentChange(event) {
</select>
</label>
<Skeleton v-if="loading && sortedByRisk.length === 0" :rows="5" />
<EmptyState v-else-if="sortedByRisk.length === 0" message="No active patients" />
<WardTable v-else :patients="sortedByRisk" />
<WardToolbar />
<label class="mb-4 block md:hidden">
<span class="mb-2 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Sort by
</span>
<select
:value="wardSortField"
class="w-full rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm text-gray-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
@change="onMobileSortChange"
>
<option v-for="option in WARD_SORT_FIELDS" :key="option.key" :value="option.key">
{{ option.label }}
</option>
</select>
</label>
<Skeleton v-if="loading && encounters.length === 0" :rows="5" />
<EmptyState
v-else-if="encounters.length === 0"
message="No active patients"
/>
<EmptyState
v-else-if="displayEncounters.length === 0"
message="No patients match your search or filters"
/>
<WardTable
v-else
:patients="displayEncounters"
:sort-field="wardSortField"
:sort-direction="wardSortDirection"
@sort="wardStore.setSort"
/>
</div>
</template>