fix:
No SOFA trend chart or organ-system timeline No GCS trend chart or component history No qSOFA history view
This commit is contained in:
@@ -283,7 +283,7 @@ public class GapAnalysisFixTests : IAsyncLifetime
|
|||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QsofaHistory_ReturnsAlertRecords()
|
public async Task QsofaHistory_ReturnsEvaluationRecords()
|
||||||
{
|
{
|
||||||
using var scope = _fixture.Services.CreateScope();
|
using var scope = _fixture.Services.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
@@ -304,20 +304,28 @@ public class GapAnalysisFixTests : IAsyncLifetime
|
|||||||
db.Patients.Add(patient);
|
db.Patients.Add(patient);
|
||||||
db.Encounters.Add(encounter);
|
db.Encounters.Add(encounter);
|
||||||
|
|
||||||
db.ClinicalAlerts.Add(new ClinicalAlert
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
|
Id = Guid.NewGuid(),
|
||||||
AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning,
|
EncounterId = encounter.Id,
|
||||||
Details = "qSOFA >= 2", Status = AlertStatus.Open,
|
PatientId = patient.Id,
|
||||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-30)
|
ActiveCriteria = 1,
|
||||||
|
RespRate = 24m,
|
||||||
|
EvaluatedAt = now.AddMinutes(-30),
|
||||||
|
CreatedAt = now,
|
||||||
});
|
});
|
||||||
db.ClinicalAlerts.Add(new ClinicalAlert
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), EncounterId = encounter.Id, PatientId = patient.Id,
|
Id = Guid.NewGuid(),
|
||||||
AlertType = AlertType.QsofaScreen, Severity = AlertSeverity.Warning,
|
EncounterId = encounter.Id,
|
||||||
Details = "qSOFA >= 2", Status = AlertStatus.Resolved,
|
PatientId = patient.Id,
|
||||||
TriggeredAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
ActiveCriteria = 2,
|
||||||
ResolvedAt = DateTimeOffset.UtcNow
|
RespRate = 24m,
|
||||||
|
SystolicBp = 95m,
|
||||||
|
ScreenAlertFired = true,
|
||||||
|
EvaluatedAt = now.AddMinutes(-10),
|
||||||
|
CreatedAt = now,
|
||||||
});
|
});
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -325,7 +333,12 @@ public class GapAnalysisFixTests : IAsyncLifetime
|
|||||||
$"/api/v1/encounters/{encounter.Id}/qsofa/history?limit=10");
|
$"/api/v1/encounters/{encounter.Id}/qsofa/history?limit=10");
|
||||||
|
|
||||||
var data = resp.GetProperty("data");
|
var data = resp.GetProperty("data");
|
||||||
data.GetProperty("items").GetArrayLength().Should().Be(2);
|
var items = data.GetProperty("items");
|
||||||
|
items.GetArrayLength().Should().Be(2);
|
||||||
|
|
||||||
|
var latest = items[0];
|
||||||
|
latest.GetProperty("activeCriteria").GetInt32().Should().Be(2);
|
||||||
|
latest.GetProperty("screenAlertFired").GetBoolean().Should().BeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -2,15 +2,23 @@ using FluentAssertions;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
[Collection("Integration")]
|
[Collection("Integration")]
|
||||||
public class GcsScoringTests : IAsyncLifetime
|
public class GcsScoringTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private readonly ApiFixture _fixture;
|
private readonly ApiFixture _fixture;
|
||||||
|
private readonly HttpClient _client;
|
||||||
private Guid _encounterId;
|
private Guid _encounterId;
|
||||||
private Guid _patientId;
|
private Guid _patientId;
|
||||||
|
|
||||||
public GcsScoringTests(ApiFixture fixture) => _fixture = fixture;
|
public GcsScoringTests(ApiFixture fixture)
|
||||||
|
{
|
||||||
|
_fixture = fixture;
|
||||||
|
_client = fixture.CreateClient();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task InitializeAsync()
|
public async Task InitializeAsync()
|
||||||
{
|
{
|
||||||
@@ -194,4 +202,58 @@ public class GcsScoringTests : IAsyncLifetime
|
|||||||
var score = await db.News2Scores.SingleAsync();
|
var score = await db.News2Scores.SingleAsync();
|
||||||
score.ConsciousnessScore.Should().Be(0);
|
score.ConsciousnessScore.Should().Be(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GcsHistory_ReturnsPaginatedScores()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
db.GcsScores.AddRange(
|
||||||
|
new GcsScore
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = _encounterId,
|
||||||
|
PatientId = _patientId,
|
||||||
|
EyeScore = 4,
|
||||||
|
VerbalScore = 5,
|
||||||
|
MotorScore = 6,
|
||||||
|
TotalScore = 15,
|
||||||
|
Classification = "MILD",
|
||||||
|
CalculatedAt = now.AddHours(-2),
|
||||||
|
CreatedAt = now,
|
||||||
|
},
|
||||||
|
new GcsScore
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = _encounterId,
|
||||||
|
PatientId = _patientId,
|
||||||
|
EyeScore = 2,
|
||||||
|
VerbalScore = 3,
|
||||||
|
MotorScore = 3,
|
||||||
|
TotalScore = 8,
|
||||||
|
Classification = "SEVERE",
|
||||||
|
CalculatedAt = now,
|
||||||
|
CreatedAt = now,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var resp = await _client.GetAsync(
|
||||||
|
$"/api/v1/encounters/{_encounterId}/gcs/history?limit=10");
|
||||||
|
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||||
|
|
||||||
|
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
var items = body.GetProperty("data").GetProperty("items");
|
||||||
|
items.GetArrayLength().Should().Be(2);
|
||||||
|
|
||||||
|
var latest = items[0];
|
||||||
|
latest.GetProperty("totalScore").GetInt32().Should().Be(8);
|
||||||
|
latest.GetProperty("eyeScore").GetInt32().Should().Be(2);
|
||||||
|
latest.GetProperty("verbalScore").GetInt32().Should().Be(3);
|
||||||
|
latest.GetProperty("motorScore").GetInt32().Should().Be(3);
|
||||||
|
|
||||||
|
var earliest = items[1];
|
||||||
|
earliest.GetProperty("totalScore").GetInt32().Should().Be(15);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ public static class DbResetHelper
|
|||||||
DELETE FROM clinical_alerts;
|
DELETE FROM clinical_alerts;
|
||||||
DELETE FROM gcs_scores;
|
DELETE FROM gcs_scores;
|
||||||
DELETE FROM sofa_scores;
|
DELETE FROM sofa_scores;
|
||||||
|
DELETE FROM qsofa_evaluations;
|
||||||
DELETE FROM news2_scores;
|
DELETE FROM news2_scores;
|
||||||
DELETE FROM observations;
|
DELETE FROM observations;
|
||||||
DELETE FROM external_resource_identifiers;
|
DELETE FROM external_resource_identifiers;
|
||||||
|
|||||||
@@ -162,4 +162,25 @@ public class QsofaDetectorTests : IAsyncLifetime
|
|||||||
exists.Should().BeFalse("non-qSOFA codes must not create Redis state");
|
exists.Should().BeFalse("non-qSOFA codes must not create Redis state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CriteriaChange_PersistsEvaluationHistory()
|
||||||
|
{
|
||||||
|
using var scope = _fixture.Services.CreateScope();
|
||||||
|
var detector = scope.ServiceProvider.GetRequiredService<QsofaDetector>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
await detector.ProcessObservationAsync(_encounterId, _patientId, "RESP_RATE", 24m);
|
||||||
|
await detector.ProcessObservationAsync(_encounterId, _patientId, "SYSTOLIC_BP", 95m);
|
||||||
|
|
||||||
|
var evaluations = await db.QsofaEvaluations
|
||||||
|
.Where(e => e.EncounterId == _encounterId)
|
||||||
|
.OrderBy(e => e.EvaluatedAt)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
evaluations.Should().HaveCount(2);
|
||||||
|
evaluations[0].ActiveCriteria.Should().Be(1);
|
||||||
|
evaluations[1].ActiveCriteria.Should().Be(2);
|
||||||
|
evaluations[1].ScreenAlertFired.Should().BeTrue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,8 +27,33 @@ public class GcsController : ControllerBase
|
|||||||
if (score is null)
|
if (score is null)
|
||||||
return Ok(ApiResponse<GcsScoreResponse?>.Ok(null));
|
return Ok(ApiResponse<GcsScoreResponse?>.Ok(null));
|
||||||
|
|
||||||
return Ok(ApiResponse<GcsScoreResponse>.Ok(new GcsScoreResponse(
|
return Ok(ApiResponse<GcsScoreResponse>.Ok(MapResponse(score)));
|
||||||
score.EyeScore, score.VerbalScore, score.MotorScore,
|
|
||||||
score.TotalScore, score.Classification, score.CalculatedAt)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns cursor-paginated GCS score history for an encounter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="encounterId">Encounter id.</param>
|
||||||
|
/// <param name="limit">Maximum items per page.</param>
|
||||||
|
/// <param name="cursor">Opaque cursor from a previous page.</param>
|
||||||
|
/// <returns>A page of GCS scores with an optional next cursor.</returns>
|
||||||
|
[HttpGet("history")]
|
||||||
|
[ProducesResponseType(typeof(ApiResponse<object>), StatusCodes.Status200OK)]
|
||||||
|
public async Task<IActionResult> History(
|
||||||
|
Guid encounterId,
|
||||||
|
[FromQuery] int limit = 20,
|
||||||
|
[FromQuery] string? cursor = null)
|
||||||
|
{
|
||||||
|
var page = await _gcs.GetHistoryAsync(encounterId, limit, cursor);
|
||||||
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
|
{
|
||||||
|
items = page.Items.Select(MapResponse),
|
||||||
|
nextCursor = page.NextCursor,
|
||||||
|
hasMore = page.HasMore
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static GcsScoreResponse MapResponse(GcsScore score) =>
|
||||||
|
new(score.EyeScore, score.VerbalScore, score.MotorScore,
|
||||||
|
score.TotalScore, score.Classification, score.CalculatedAt);
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,11 @@ public class QsofaController : ControllerBase
|
|||||||
var page = await _qsofa.GetHistoryAsync(encounterId, limit, cursor);
|
var page = await _qsofa.GetHistoryAsync(encounterId, limit, cursor);
|
||||||
return Ok(ApiResponse<object>.Ok(new
|
return Ok(ApiResponse<object>.Ok(new
|
||||||
{
|
{
|
||||||
items = page.Items,
|
items = page.Items.Select(e => new QsofaHistoryResponse(
|
||||||
|
e.ActiveCriteria,
|
||||||
|
QsofaCalculator.ParseCriteriaState(e),
|
||||||
|
e.ScreenAlertFired,
|
||||||
|
e.EvaluatedAt)),
|
||||||
nextCursor = page.NextCursor,
|
nextCursor = page.NextCursor,
|
||||||
hasMore = page.HasMore
|
hasMore = page.HasMore
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class AppDbContext : DbContext
|
|||||||
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
|
public DbSet<MedicationAdministration> MedicationAdministrations => Set<MedicationAdministration>();
|
||||||
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
|
public DbSet<GcsScore> GcsScores => Set<GcsScore>();
|
||||||
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
|
public DbSet<SofaScore> SofaScores => Set<SofaScore>();
|
||||||
|
public DbSet<QsofaEvaluation> QsofaEvaluations => Set<QsofaEvaluation>();
|
||||||
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
|
public DbSet<ExternalResourceIdentifier> ExternalResourceIdentifiers => Set<ExternalResourceIdentifier>();
|
||||||
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
|
public DbSet<ClinicalUser> ClinicalUsers => Set<ClinicalUser>();
|
||||||
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
|
public DbSet<ClinicalAuditLog> ClinicalAuditLogs => Set<ClinicalAuditLog>();
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|
||||||
|
public class QsofaEvaluationConfiguration : IEntityTypeConfiguration<QsofaEvaluation>
|
||||||
|
{
|
||||||
|
public void Configure(EntityTypeBuilder<QsofaEvaluation> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("qsofa_evaluations", t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_qsofa_evaluations_active_criteria",
|
||||||
|
"active_criteria >= 0 AND active_criteria <= 3");
|
||||||
|
});
|
||||||
|
builder.HasKey(e => e.Id);
|
||||||
|
builder.Property(e => e.Id).HasColumnName("id").HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
builder.Property(e => e.EncounterId).HasColumnName("encounter_id").IsRequired();
|
||||||
|
builder.Property(e => e.PatientId).HasColumnName("patient_id").IsRequired();
|
||||||
|
builder.Property(e => e.ActiveCriteria).HasColumnName("active_criteria").IsRequired();
|
||||||
|
builder.Property(e => e.RespRate).HasColumnName("resp_rate");
|
||||||
|
builder.Property(e => e.SystolicBp).HasColumnName("systolic_bp");
|
||||||
|
builder.Property(e => e.Avpu).HasColumnName("avpu");
|
||||||
|
builder.Property(e => e.ScreenAlertFired).HasColumnName("screen_alert_fired").IsRequired();
|
||||||
|
builder.Property(e => e.EvaluatedAt).HasColumnName("evaluated_at").IsRequired();
|
||||||
|
builder.Property(e => e.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||||
|
|
||||||
|
builder.HasOne(e => e.Encounter)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.EncounterId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
builder.HasIndex(e => new { e.EncounterId, e.EvaluatedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
public class QsofaEvaluation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
public Guid EncounterId { get; set; }
|
||||||
|
public Guid PatientId { get; set; }
|
||||||
|
public int ActiveCriteria { get; set; }
|
||||||
|
public decimal? RespRate { get; set; }
|
||||||
|
public decimal? SystolicBp { get; set; }
|
||||||
|
public decimal? Avpu { get; set; }
|
||||||
|
public bool ScreenAlertFired { get; set; }
|
||||||
|
public DateTimeOffset EvaluatedAt { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
public Encounter Encounter { get; set; } = null!;
|
||||||
|
}
|
||||||
+1705
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace VigilCareClinicalAPI.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddQsofaEvaluations : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "qsofa_evaluations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false, defaultValueSql: "gen_random_uuid()"),
|
||||||
|
encounter_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
patient_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
active_criteria = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
resp_rate = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
systolic_bp = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
avpu = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
screen_alert_fired = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
evaluated_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_qsofa_evaluations", x => x.id);
|
||||||
|
table.CheckConstraint("chk_qsofa_evaluations_active_criteria", "active_criteria >= 0 AND active_criteria <= 3");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_qsofa_evaluations_encounters_encounter_id",
|
||||||
|
column: x => x.encounter_id,
|
||||||
|
principalTable: "encounters",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_qsofa_evaluations_encounter_id_evaluated_at",
|
||||||
|
table: "qsofa_evaluations",
|
||||||
|
columns: new[] { "encounter_id", "evaluated_at" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "qsofa_evaluations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1122,6 +1122,60 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.ToTable("phi_access_logs", (string)null);
|
b.ToTable("phi_access_logs", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("QsofaEvaluation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("id")
|
||||||
|
.HasDefaultValueSql("gen_random_uuid()");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveCriteria")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("active_criteria");
|
||||||
|
|
||||||
|
b.Property<decimal?>("Avpu")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("avpu");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("EncounterId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("encounter_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("EvaluatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("evaluated_at");
|
||||||
|
|
||||||
|
b.Property<Guid>("PatientId")
|
||||||
|
.HasColumnType("uuid")
|
||||||
|
.HasColumnName("patient_id");
|
||||||
|
|
||||||
|
b.Property<decimal?>("RespRate")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("resp_rate");
|
||||||
|
|
||||||
|
b.Property<bool>("ScreenAlertFired")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("screen_alert_fired");
|
||||||
|
|
||||||
|
b.Property<decimal?>("SystolicBp")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("systolic_bp");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("EncounterId", "EvaluatedAt");
|
||||||
|
|
||||||
|
b.ToTable("qsofa_evaluations", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("chk_qsofa_evaluations_active_criteria", "active_criteria >= 0 AND active_criteria <= 3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1527,6 +1581,17 @@ namespace VigilCareClinicalAPI.Migrations
|
|||||||
b.Navigation("Encounter");
|
b.Navigation("Encounter");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("QsofaEvaluation", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Encounter", "Encounter")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EncounterId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Encounter");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("ReconciliationAlert", b =>
|
modelBuilder.Entity("ReconciliationAlert", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Encounter", "Encounter")
|
b.HasOne("Encounter", "Encounter")
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
public record GcsScoreCursor(DateTimeOffset CalculatedAt, Guid Id)
|
||||||
|
{
|
||||||
|
public string Encode()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(this);
|
||||||
|
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static GcsScoreCursor? Decode(string? encoded)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(encoded)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||||
|
return JsonSerializer.Deserialize<GcsScoreCursor>(json);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
public record QsofaEvaluationCursor(DateTimeOffset EvaluatedAt, Guid Id)
|
||||||
|
{
|
||||||
|
public string Encode()
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(this);
|
||||||
|
return Convert.ToBase64String(Encoding.UTF8.GetBytes(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static QsofaEvaluationCursor? Decode(string? encoded)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(encoded)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
|
||||||
|
return JsonSerializer.Deserialize<QsofaEvaluationCursor>(json);
|
||||||
|
}
|
||||||
|
catch { return null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public record QsofaHistoryResponse(
|
||||||
|
int ActiveCriteria,
|
||||||
|
QsofaCriteriaState Criteria,
|
||||||
|
bool ScreenAlertFired,
|
||||||
|
DateTimeOffset EvaluatedAt);
|
||||||
@@ -34,4 +34,16 @@ public static class QsofaCalculator
|
|||||||
|
|
||||||
public static int CountActiveCriteria(RedisValue[] values) =>
|
public static int CountActiveCriteria(RedisValue[] values) =>
|
||||||
values.Count(v => v.HasValue);
|
values.Count(v => v.HasValue);
|
||||||
|
|
||||||
|
public static QsofaCriteriaState ParseCriteriaState(RedisValue[] values) =>
|
||||||
|
new(
|
||||||
|
ParseOptionalDecimal(values.ElementAtOrDefault(0)),
|
||||||
|
ParseOptionalDecimal(values.ElementAtOrDefault(1)),
|
||||||
|
ParseOptionalDecimal(values.ElementAtOrDefault(2)));
|
||||||
|
|
||||||
|
public static QsofaCriteriaState ParseCriteriaState(QsofaEvaluation evaluation) =>
|
||||||
|
new(evaluation.RespRate, evaluation.SystolicBp, evaluation.Avpu);
|
||||||
|
|
||||||
|
private static decimal? ParseOptionalDecimal(RedisValue value) =>
|
||||||
|
value.HasValue ? decimal.Parse(value.ToString()!) : null;
|
||||||
}
|
}
|
||||||
@@ -83,13 +83,64 @@ public class QsofaDetector
|
|||||||
var values = await cache.StringGetAsync(allKeys);
|
var values = await cache.StringGetAsync(allKeys);
|
||||||
var activeCount = QsofaCalculator.CountActiveCriteria(values);
|
var activeCount = QsofaCalculator.CountActiveCriteria(values);
|
||||||
|
|
||||||
|
var created = activeCount >= 2
|
||||||
|
&& await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
|
||||||
|
|
||||||
|
await PersistEvaluationIfChangedAsync(
|
||||||
|
encounterId, patientId, activeCount, values, created, ct);
|
||||||
|
|
||||||
if (activeCount < 2)
|
if (activeCount < 2)
|
||||||
return QsofaResult.InsufficientCriteria(activeCount);
|
return QsofaResult.InsufficientCriteria(activeCount);
|
||||||
|
|
||||||
var created = await TryCreateScreenAlertAsync(encounterId, patientId, activeCount, values, ct);
|
|
||||||
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
|
return created ? QsofaResult.AlertCreated : QsofaResult.AlertAlreadyOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task PersistEvaluationIfChangedAsync(
|
||||||
|
Guid encounterId,
|
||||||
|
Guid patientId,
|
||||||
|
int activeCount,
|
||||||
|
RedisValue[] values,
|
||||||
|
bool screenAlertFired,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
|
||||||
|
var last = await db.QsofaEvaluations
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.EncounterId == encounterId)
|
||||||
|
.OrderByDescending(e => e.EvaluatedAt)
|
||||||
|
.ThenByDescending(e => e.Id)
|
||||||
|
.Select(e => new { e.ActiveCriteria, e.ScreenAlertFired })
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
|
if (last is not null
|
||||||
|
&& last.ActiveCriteria == activeCount
|
||||||
|
&& !screenAlertFired)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var criteria = QsofaCalculator.ParseCriteriaState(values);
|
||||||
|
var evaluatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
db.QsofaEvaluations.Add(new QsofaEvaluation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
EncounterId = encounterId,
|
||||||
|
PatientId = patientId,
|
||||||
|
ActiveCriteria = activeCount,
|
||||||
|
RespRate = criteria.RespRate,
|
||||||
|
SystolicBp = criteria.SystolicBp,
|
||||||
|
Avpu = criteria.Avpu,
|
||||||
|
ScreenAlertFired = screenAlertFired,
|
||||||
|
EvaluatedAt = evaluatedAt,
|
||||||
|
CreatedAt = evaluatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<bool> TryCreateScreenAlertAsync(
|
private async Task<bool> TryCreateScreenAlertAsync(
|
||||||
Guid encounterId,
|
Guid encounterId,
|
||||||
Guid patientId,
|
Guid patientId,
|
||||||
|
|||||||
@@ -14,4 +14,37 @@ public class GcsService : IGcsService
|
|||||||
.OrderByDescending(s => s.CalculatedAt)
|
.OrderByDescending(s => s.CalculatedAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CursorPage<GcsScore>> GetHistoryAsync(
|
||||||
|
Guid encounterId, int limit, string? cursorToken)
|
||||||
|
{
|
||||||
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
var cursor = GcsScoreCursor.Decode(cursorToken);
|
||||||
|
|
||||||
|
var query = _db.GcsScores
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(s => s.EncounterId == encounterId);
|
||||||
|
|
||||||
|
if (cursor is not null)
|
||||||
|
{
|
||||||
|
query = query.Where(s =>
|
||||||
|
s.CalculatedAt < cursor.CalculatedAt ||
|
||||||
|
(s.CalculatedAt == cursor.CalculatedAt && s.Id.CompareTo(cursor.Id) < 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(s => s.CalculatedAt)
|
||||||
|
.ThenByDescending(s => s.Id)
|
||||||
|
.Take(limit + 1)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var hasMore = items.Count > limit;
|
||||||
|
if (hasMore) items.RemoveAt(limit);
|
||||||
|
|
||||||
|
var nextCursor = hasMore
|
||||||
|
? new GcsScoreCursor(items[^1].CalculatedAt, items[^1].Id).Encode()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new CursorPage<GcsScore>(items, nextCursor, hasMore);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
public interface IGcsService
|
public interface IGcsService
|
||||||
{
|
{
|
||||||
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
|
Task<GcsScore?> GetCurrentAsync(Guid encounterId);
|
||||||
|
Task<CursorPage<GcsScore>> GetHistoryAsync(Guid encounterId, int limit, string? cursorToken);
|
||||||
}
|
}
|
||||||
@@ -2,5 +2,5 @@ public interface IQsofaService
|
|||||||
{
|
{
|
||||||
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
|
Task<QsofaCurrentResponse> GetCurrentAsync(Guid encounterId);
|
||||||
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
|
Task<int> GetActiveCriteriaCountAsync(Guid encounterId);
|
||||||
Task<CursorPage<ClinicalAlert>> GetHistoryAsync(Guid encounterId, int limit, string? cursor);
|
Task<CursorPage<QsofaEvaluation>> GetHistoryAsync(Guid encounterId, int limit, string? cursor);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,28 +26,37 @@ public class QsofaService : IQsofaService
|
|||||||
return QsofaCalculator.CountActiveCriteria(values);
|
return QsofaCalculator.CountActiveCriteria(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CursorPage<ClinicalAlert>> GetHistoryAsync(
|
public async Task<CursorPage<QsofaEvaluation>> GetHistoryAsync(
|
||||||
Guid encounterId, int limit, string? cursor)
|
Guid encounterId, int limit, string? cursorToken)
|
||||||
{
|
{
|
||||||
limit = Math.Clamp(limit, 1, 100);
|
limit = Math.Clamp(limit, 1, 100);
|
||||||
|
var cursor = QsofaEvaluationCursor.Decode(cursorToken);
|
||||||
|
|
||||||
var query = _db.ClinicalAlerts
|
var query = _db.QsofaEvaluations
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.EncounterId == encounterId
|
.Where(e => e.EncounterId == encounterId);
|
||||||
#pragma warning disable CS0618 // Include legacy QSOFA_WARNING rows in history
|
|
||||||
&& (a.AlertType == AlertType.QsofaScreen || a.AlertType == AlertType.QsofaWarning));
|
if (cursor is not null)
|
||||||
#pragma warning restore CS0618
|
{
|
||||||
|
query = query.Where(e =>
|
||||||
|
e.EvaluatedAt < cursor.EvaluatedAt ||
|
||||||
|
(e.EvaluatedAt == cursor.EvaluatedAt && e.Id.CompareTo(cursor.Id) < 0));
|
||||||
|
}
|
||||||
|
|
||||||
var items = await query
|
var items = await query
|
||||||
.OrderByDescending(a => a.TriggeredAt)
|
.OrderByDescending(e => e.EvaluatedAt)
|
||||||
.ThenByDescending(a => a.Id)
|
.ThenByDescending(e => e.Id)
|
||||||
.Take(limit + 1)
|
.Take(limit + 1)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var hasMore = items.Count > limit;
|
var hasMore = items.Count > limit;
|
||||||
if (hasMore) items.RemoveAt(limit);
|
if (hasMore) items.RemoveAt(limit);
|
||||||
|
|
||||||
return new CursorPage<ClinicalAlert>(items, null, hasMore);
|
var nextCursor = hasMore
|
||||||
|
? new QsofaEvaluationCursor(items[^1].EvaluatedAt, items[^1].Id).Encode()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return new CursorPage<QsofaEvaluation>(items, nextCursor, hasMore);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task EnsureEncounterExistsAsync(Guid encounterId)
|
private async Task EnsureEncounterExistsAsync(Guid encounterId)
|
||||||
@@ -67,12 +76,6 @@ public class QsofaService : IQsofaService
|
|||||||
{
|
{
|
||||||
return new QsofaCurrentResponse(
|
return new QsofaCurrentResponse(
|
||||||
QsofaCalculator.CountActiveCriteria(values),
|
QsofaCalculator.CountActiveCriteria(values),
|
||||||
new QsofaCriteriaState(
|
QsofaCalculator.ParseCriteriaState(values));
|
||||||
ParseOptionalDecimal(values[0]),
|
|
||||||
ParseOptionalDecimal(values[1]),
|
|
||||||
ParseOptionalDecimal(values[2])));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static decimal? ParseOptionalDecimal(RedisValue value) =>
|
|
||||||
value.HasValue ? decimal.Parse(value.ToString()) : null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import GcsHistory from '@/components/charts/GcsHistory.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-chartjs', () => ({
|
||||||
|
Line: {
|
||||||
|
name: 'Line',
|
||||||
|
template: '<div class="mock-chart" />',
|
||||||
|
props: ['data', 'options'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('GcsHistory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
eyeScore: 4,
|
||||||
|
verbalScore: 5,
|
||||||
|
motorScore: 6,
|
||||||
|
totalScore: 15,
|
||||||
|
classification: 'MILD',
|
||||||
|
calculatedAt: '2026-06-23T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eyeScore: 2,
|
||||||
|
verbalScore: 3,
|
||||||
|
motorScore: 3,
|
||||||
|
totalScore: 8,
|
||||||
|
classification: 'SEVERE',
|
||||||
|
calculatedAt: '2026-06-23T14:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('rendersTitleAndChart', () => {
|
||||||
|
const wrapper = mount(GcsHistory, { props: { history } })
|
||||||
|
expect(wrapper.text()).toContain('GCS Over Time')
|
||||||
|
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortsHistoryChronologically', () => {
|
||||||
|
const wrapper = mount(GcsHistory, { props: { history: [...history].reverse() } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Line' })
|
||||||
|
const totals = chart.props('data').datasets.find(d => d.label === 'GCS Total').data
|
||||||
|
expect(totals).toEqual([15, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includesComponentDatasets', () => {
|
||||||
|
const wrapper = mount(GcsHistory, { props: { history } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Line' })
|
||||||
|
const labels = chart.props('data').datasets.map(d => d.label)
|
||||||
|
expect(labels).toContain('Eye')
|
||||||
|
expect(labels).toContain('Verbal')
|
||||||
|
expect(labels).toContain('Motor')
|
||||||
|
expect(labels).toContain('GCS Total')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-chartjs', () => ({
|
||||||
|
Line: {
|
||||||
|
name: 'Line',
|
||||||
|
template: '<div class="mock-chart" />',
|
||||||
|
props: ['data', 'options'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('QsofaHistory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
activeCriteria: 1,
|
||||||
|
criteria: { respRate: 24, systolicBp: null, avpu: null },
|
||||||
|
screenAlertFired: false,
|
||||||
|
evaluatedAt: '2026-06-23T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
activeCriteria: 2,
|
||||||
|
criteria: { respRate: 24, systolicBp: 95, avpu: null },
|
||||||
|
screenAlertFired: true,
|
||||||
|
evaluatedAt: '2026-06-23T14:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('rendersTitleAndChart', () => {
|
||||||
|
const wrapper = mount(QsofaHistory, { props: { history } })
|
||||||
|
expect(wrapper.text()).toContain('qSOFA Screen Over Time')
|
||||||
|
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortsHistoryChronologically', () => {
|
||||||
|
const wrapper = mount(QsofaHistory, { props: { history: [...history].reverse() } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Line' })
|
||||||
|
const counts = chart.props('data').datasets.find(d => d.label === 'Active criteria').data
|
||||||
|
expect(counts).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marksAlertFiredPoints', () => {
|
||||||
|
const wrapper = mount(QsofaHistory, { props: { history } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Line' })
|
||||||
|
const total = chart.props('data').datasets.find(d => d.label === 'Active criteria')
|
||||||
|
expect(total.pointStyle).toEqual(['circle', 'star'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import SofaHistory from '@/components/charts/SofaHistory.vue'
|
||||||
|
|
||||||
|
vi.mock('vue-chartjs', () => ({
|
||||||
|
Chart: {
|
||||||
|
name: 'Chart',
|
||||||
|
template: '<div class="mock-chart" />',
|
||||||
|
props: ['type', 'data', 'options'],
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('SofaHistory', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({ matches: false })
|
||||||
|
})
|
||||||
|
const history = [
|
||||||
|
{
|
||||||
|
totalScore: 4,
|
||||||
|
respiratoryScore: 1,
|
||||||
|
coagulationScore: 1,
|
||||||
|
liverScore: 0,
|
||||||
|
cardiovascularScore: 1,
|
||||||
|
cnsScore: 1,
|
||||||
|
renalScore: 0,
|
||||||
|
calculatedAt: '2026-06-23T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
totalScore: 8,
|
||||||
|
respiratoryScore: 2,
|
||||||
|
coagulationScore: 1,
|
||||||
|
liverScore: 1,
|
||||||
|
cardiovascularScore: 2,
|
||||||
|
cnsScore: 1,
|
||||||
|
renalScore: 1,
|
||||||
|
calculatedAt: '2026-06-23T14:00:00Z',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
it('rendersTitleAndChart', () => {
|
||||||
|
const wrapper = mount(SofaHistory, { props: { history } })
|
||||||
|
expect(wrapper.text()).toContain('SOFA Score Over Time')
|
||||||
|
expect(wrapper.find('.mock-chart').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortsHistoryChronologically', () => {
|
||||||
|
const wrapper = mount(SofaHistory, { props: { history: [...history].reverse() } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Chart' })
|
||||||
|
const totals = chart.props('data').datasets.find(d => d.label === 'SOFA Total').data
|
||||||
|
expect(totals).toEqual([4, 8])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includesOrganSystemDatasets', () => {
|
||||||
|
const wrapper = mount(SofaHistory, { props: { history } })
|
||||||
|
const chart = wrapper.findComponent({ name: 'Chart' })
|
||||||
|
const labels = chart.props('data').datasets.map(d => d.label)
|
||||||
|
expect(labels).toContain('Respiratory')
|
||||||
|
expect(labels).toContain('Renal')
|
||||||
|
expect(labels).toContain('SOFA Total')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -66,6 +66,45 @@ export async function fetchNews2History(encounterId, { limit = 100 } = {}) {
|
|||||||
return all
|
return all
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchGcsHistory(encounterId, { limit = 100 } = {}) {
|
||||||
|
const all = []
|
||||||
|
let cursor = null
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (cursor) params.set('cursor', cursor)
|
||||||
|
const page = await api.get(`/api/v1/encounters/${encounterId}/gcs/history?${params}`)
|
||||||
|
all.push(...(page.items ?? []))
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null
|
||||||
|
} while (cursor)
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQsofaHistory(encounterId, { limit = 100 } = {}) {
|
||||||
|
const all = []
|
||||||
|
let cursor = null
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (cursor) params.set('cursor', cursor)
|
||||||
|
const page = await api.get(`/api/v1/encounters/${encounterId}/qsofa/history?${params}`)
|
||||||
|
all.push(...(page.items ?? []))
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null
|
||||||
|
} while (cursor)
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSofaHistory(encounterId, { limit = 100 } = {}) {
|
||||||
|
const all = []
|
||||||
|
let cursor = null
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ limit: String(limit) })
|
||||||
|
if (cursor) params.set('cursor', cursor)
|
||||||
|
const page = await api.get(`/api/v1/encounters/${encounterId}/sofa/history?${params}`)
|
||||||
|
all.push(...(page.items ?? []))
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null
|
||||||
|
} while (cursor)
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchMedications(encounterId, { pageSize = 50, since } = {}) {
|
export async function fetchMedications(encounterId, { pageSize = 50, since } = {}) {
|
||||||
const all = []
|
const all = []
|
||||||
let page = 1
|
let page = 1
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, shallowRef, markRaw } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
|
||||||
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
|
||||||
|
const COMPONENT_DATASETS = [
|
||||||
|
{ label: 'Eye', key: 'eyeScore', color: '#3b82f6' },
|
||||||
|
{ label: 'Verbal', key: 'verbalScore', color: '#8b5cf6' },
|
||||||
|
{ label: 'Motor', key: 'motorScore', color: '#06b6d4' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function gcsRiskBorder(score) {
|
||||||
|
if (score <= 8) return '#dc2626'
|
||||||
|
if (score <= 12) return '#f59e0b'
|
||||||
|
return '#22c55e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function gcsSeverityLabel(score) {
|
||||||
|
if (score <= 8) return 'Severe (3–8)'
|
||||||
|
if (score <= 12) return 'Moderate (9–12)'
|
||||||
|
return 'Mild (13–15)'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedHistory = computed(() =>
|
||||||
|
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const sorted = sortedHistory.value
|
||||||
|
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||||
|
|
||||||
|
const componentDatasets = COMPONENT_DATASETS.map(({ label, key, color }) => ({
|
||||||
|
label,
|
||||||
|
data: sorted.map(h => h[key] ?? 0),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderDash: [4, 3],
|
||||||
|
pointRadius: 2,
|
||||||
|
tension: 0.2,
|
||||||
|
order: 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'GCS Total',
|
||||||
|
data: sorted.map(h => h.totalScore),
|
||||||
|
borderColor: '#111827',
|
||||||
|
backgroundColor: sorted.map(h => {
|
||||||
|
if (h.totalScore <= 8) return 'rgba(220, 38, 38, 0.15)'
|
||||||
|
if (h.totalScore <= 12) return 'rgba(245, 158, 11, 0.15)'
|
||||||
|
return 'rgba(34, 197, 94, 0.15)'
|
||||||
|
}),
|
||||||
|
pointBackgroundColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
|
||||||
|
pointBorderColor: sorted.map(h => gcsRiskBorder(h.totalScore)),
|
||||||
|
pointRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
...componentDatasets,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = shallowRef(markRaw({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||||
|
},
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
min: 3,
|
||||||
|
max: 15,
|
||||||
|
title: { display: true, text: 'GCS Score' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { boxWidth: 12, font: { size: 11 } },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
footer(items) {
|
||||||
|
const total = items.find(i => i.dataset.label === 'GCS Total')
|
||||||
|
if (!total) return ''
|
||||||
|
return gcsSeverityLabel(total.parsed.y)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">GCS Over Time</h3>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Total score with Eye, Verbal, and Motor components.
|
||||||
|
<span class="text-green-600 dark:text-green-400">Mild 13–15</span>,
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">Moderate 9–12</span>,
|
||||||
|
<span class="text-red-600 dark:text-red-400">Severe 3–8</span>.
|
||||||
|
</p>
|
||||||
|
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, shallowRef, markRaw } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
|
||||||
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
|
||||||
|
const CRITERION_LABELS = [
|
||||||
|
{ label: 'Resp rate', key: 'respRate', color: '#3b82f6' },
|
||||||
|
{ label: 'Systolic BP', key: 'systolicBp', color: '#8b5cf6' },
|
||||||
|
{ label: 'Altered mentation', key: 'avpu', color: '#ec4899' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function criteriaColor(count) {
|
||||||
|
if (count >= 2) return '#dc2626'
|
||||||
|
if (count === 1) return '#f59e0b'
|
||||||
|
return '#22c55e'
|
||||||
|
}
|
||||||
|
|
||||||
|
function criteriaLabel(count) {
|
||||||
|
if (count >= 2) return 'Screen positive (≥2) — consider SOFA labs'
|
||||||
|
if (count === 1) return '1 criterion met'
|
||||||
|
return 'Screen negative'
|
||||||
|
}
|
||||||
|
|
||||||
|
function criterionMet(value) {
|
||||||
|
return value != null ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedHistory = computed(() =>
|
||||||
|
[...props.history].sort((a, b) => new Date(a.evaluatedAt) - new Date(b.evaluatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const sorted = sortedHistory.value
|
||||||
|
const labels = sorted.map(h => formatTime(h.evaluatedAt))
|
||||||
|
|
||||||
|
const criterionDatasets = CRITERION_LABELS.map(({ label, key, color }) => ({
|
||||||
|
label,
|
||||||
|
data: sorted.map(h => criterionMet(h.criteria?.[key])),
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: `${color}33`,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderDash: [3, 3],
|
||||||
|
pointRadius: 0,
|
||||||
|
stepped: true,
|
||||||
|
yAxisID: 'criteria',
|
||||||
|
order: 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Active criteria',
|
||||||
|
data: sorted.map(h => h.activeCriteria),
|
||||||
|
borderColor: '#111827',
|
||||||
|
backgroundColor: sorted.map(h => {
|
||||||
|
if (h.activeCriteria >= 2) return 'rgba(220, 38, 38, 0.2)'
|
||||||
|
if (h.activeCriteria === 1) return 'rgba(245, 158, 11, 0.2)'
|
||||||
|
return 'rgba(34, 197, 94, 0.15)'
|
||||||
|
}),
|
||||||
|
pointBackgroundColor: sorted.map(h => criteriaColor(h.activeCriteria)),
|
||||||
|
pointBorderColor: sorted.map(h => criteriaColor(h.activeCriteria)),
|
||||||
|
pointRadius: sorted.map(h => (h.screenAlertFired ? 7 : 4)),
|
||||||
|
pointStyle: sorted.map(h => (h.screenAlertFired ? 'star' : 'circle')),
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: true,
|
||||||
|
tension: 0.2,
|
||||||
|
yAxisID: 'count',
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
...criterionDatasets,
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = shallowRef(markRaw({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||||
|
},
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
count: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'left',
|
||||||
|
min: 0,
|
||||||
|
max: 3,
|
||||||
|
ticks: { stepSize: 1 },
|
||||||
|
title: { display: true, text: 'Criteria count' },
|
||||||
|
},
|
||||||
|
criteria: {
|
||||||
|
type: 'linear',
|
||||||
|
position: 'right',
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { boxWidth: 12, font: { size: 11 } },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
footer(items) {
|
||||||
|
const total = items.find(i => i.dataset.label === 'Active criteria')
|
||||||
|
if (!total) return ''
|
||||||
|
const idx = total.dataIndex
|
||||||
|
const entry = sortedHistory.value[idx]
|
||||||
|
const lines = [criteriaLabel(total.parsed.y)]
|
||||||
|
if (entry?.screenAlertFired) lines.push('qSOFA screen alert fired')
|
||||||
|
return lines.join(' · ')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">qSOFA Screen Over Time</h3>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Criteria count (0–3); star markers indicate when a screen alert fired.
|
||||||
|
<span class="text-green-600 dark:text-green-400">0</span>,
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">1</span>,
|
||||||
|
<span class="text-red-600 dark:text-red-400">≥2</span>.
|
||||||
|
</p>
|
||||||
|
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, shallowRef, markRaw } from 'vue'
|
||||||
|
import { Chart } from 'vue-chartjs'
|
||||||
|
import { Chart as ChartJS, registerables } from 'chart.js'
|
||||||
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
|
||||||
|
ChartJS.register(...registerables)
|
||||||
|
|
||||||
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
|
||||||
|
const ORGAN_DATASETS = [
|
||||||
|
{ label: 'Respiratory', key: 'respiratoryScore', color: '#3b82f6' },
|
||||||
|
{ label: 'Coagulation', key: 'coagulationScore', color: '#8b5cf6' },
|
||||||
|
{ label: 'Liver', key: 'liverScore', color: '#f59e0b' },
|
||||||
|
{ label: 'Cardiovascular', key: 'cardiovascularScore', color: '#ef4444' },
|
||||||
|
{ label: 'CNS', key: 'cnsScore', color: '#ec4899' },
|
||||||
|
{ label: 'Renal', key: 'renalScore', color: '#06b6d4' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function sofaRiskBorder(score) {
|
||||||
|
if (score >= 10) return '#dc2626'
|
||||||
|
if (score >= 6) return '#f59e0b'
|
||||||
|
return '#22c55e'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedHistory = computed(() =>
|
||||||
|
[...props.history].sort((a, b) => new Date(a.calculatedAt) - new Date(b.calculatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const sorted = sortedHistory.value
|
||||||
|
const labels = sorted.map(h => formatTime(h.calculatedAt))
|
||||||
|
|
||||||
|
const organDatasets = ORGAN_DATASETS.map(({ label, key, color }) => ({
|
||||||
|
type: 'line',
|
||||||
|
label,
|
||||||
|
data: sorted.map(h => h[key] ?? 0),
|
||||||
|
backgroundColor: `${color}66`,
|
||||||
|
borderColor: color,
|
||||||
|
borderWidth: 1,
|
||||||
|
fill: true,
|
||||||
|
stack: 'organs',
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.2,
|
||||||
|
order: 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
...organDatasets,
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: 'SOFA Total',
|
||||||
|
data: sorted.map(h => h.totalScore),
|
||||||
|
borderColor: '#111827',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
pointBackgroundColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||||
|
pointBorderColor: sorted.map(h => sofaRiskBorder(h.totalScore)),
|
||||||
|
pointRadius: 4,
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = shallowRef(markRaw({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||||
|
},
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: {
|
||||||
|
stacked: true,
|
||||||
|
min: 0,
|
||||||
|
max: 24,
|
||||||
|
title: { display: true, text: 'SOFA Score' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: true,
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { boxWidth: 12, font: { size: 11 } },
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
footer(items) {
|
||||||
|
const total = items.find(i => i.dataset.label === 'SOFA Total')
|
||||||
|
if (!total) return ''
|
||||||
|
const score = total.parsed.y
|
||||||
|
if (score >= 10) return 'Risk: High (≥10)'
|
||||||
|
if (score >= 6) return 'Risk: Moderate (6–9)'
|
||||||
|
return 'Risk: Low (0–5)'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full min-w-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||||
|
<h3 class="mb-1 text-sm font-medium text-gray-700 dark:text-gray-300">SOFA Score Over Time</h3>
|
||||||
|
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Stacked areas show per-organ contributions; line shows total score.
|
||||||
|
<span class="text-green-600 dark:text-green-400">0–5</span>,
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">6–9</span>,
|
||||||
|
<span class="text-red-600 dark:text-red-400">10+</span>.
|
||||||
|
</p>
|
||||||
|
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||||
|
<Chart type="line" :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -16,6 +16,9 @@ import OrdersPanel from '@/components/patient/OrdersPanel.vue'
|
|||||||
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
|
import SepsisBundlePanel from '@/components/patient/SepsisBundlePanel.vue'
|
||||||
import TrendsGrid from '@/components/charts/TrendsGrid.vue'
|
import TrendsGrid from '@/components/charts/TrendsGrid.vue'
|
||||||
import News2History from '@/components/charts/News2History.vue'
|
import News2History from '@/components/charts/News2History.vue'
|
||||||
|
import GcsHistory from '@/components/charts/GcsHistory.vue'
|
||||||
|
import QsofaHistory from '@/components/charts/QsofaHistory.vue'
|
||||||
|
import SofaHistory from '@/components/charts/SofaHistory.vue'
|
||||||
import ReplayControls from '@/components/replay/ReplayControls.vue'
|
import ReplayControls from '@/components/replay/ReplayControls.vue'
|
||||||
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
@@ -47,6 +50,9 @@ const encounter = ref(null)
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const observations = ref([])
|
const observations = ref([])
|
||||||
const news2History = ref([])
|
const news2History = ref([])
|
||||||
|
const gcsHistory = ref([])
|
||||||
|
const qsofaHistory = ref([])
|
||||||
|
const sofaHistory = ref([])
|
||||||
const medications = ref([])
|
const medications = ref([])
|
||||||
const sepsisBundle = ref(null)
|
const sepsisBundle = ref(null)
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
@@ -65,10 +71,25 @@ const replayNews2History = computed(() =>
|
|||||||
news2History.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
news2History.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const replayGcsHistory = computed(() =>
|
||||||
|
gcsHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const replayQsofaHistory = computed(() =>
|
||||||
|
qsofaHistory.value.filter(h => isAtOrBefore(h.evaluatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const replaySofaHistory = computed(() =>
|
||||||
|
sofaHistory.value.filter(h => isAtOrBefore(h.calculatedAt)),
|
||||||
|
)
|
||||||
|
|
||||||
function collectScenarioTimes() {
|
function collectScenarioTimes() {
|
||||||
return [
|
return [
|
||||||
...observations.value.map(o => new Date(o.recordedAt).getTime()),
|
...observations.value.map(o => new Date(o.recordedAt).getTime()),
|
||||||
...news2History.value.map(h => new Date(h.calculatedAt).getTime()),
|
...news2History.value.map(h => new Date(h.calculatedAt).getTime()),
|
||||||
|
...gcsHistory.value.map(h => new Date(h.calculatedAt).getTime()),
|
||||||
|
...qsofaHistory.value.map(h => new Date(h.evaluatedAt).getTime()),
|
||||||
|
...sofaHistory.value.map(h => new Date(h.calculatedAt).getTime()),
|
||||||
...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
|
...alerts.value.map(a => new Date(a.triggeredAt).getTime()),
|
||||||
].filter(Number.isFinite)
|
].filter(Number.isFinite)
|
||||||
}
|
}
|
||||||
@@ -88,10 +109,13 @@ async function loadAll() {
|
|||||||
const id = route.params.encounterId
|
const id = route.params.encounterId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [enc, obs, history, meds, bundle, ord] = await Promise.all([
|
const [enc, obs, history, gcsHist, qsofaHist, sofaHist, meds, bundle, ord] = await Promise.all([
|
||||||
encountersApi.fetchEncounter(id),
|
encountersApi.fetchEncounter(id),
|
||||||
encountersApi.fetchObservations(id),
|
encountersApi.fetchObservations(id),
|
||||||
clinicalApi.fetchNews2History(id).catch(() => []),
|
clinicalApi.fetchNews2History(id).catch(() => []),
|
||||||
|
clinicalApi.fetchGcsHistory(id).catch(() => []),
|
||||||
|
clinicalApi.fetchQsofaHistory(id).catch(() => []),
|
||||||
|
clinicalApi.fetchSofaHistory(id).catch(() => []),
|
||||||
clinicalApi.fetchMedications(id).catch(() => []),
|
clinicalApi.fetchMedications(id).catch(() => []),
|
||||||
clinicalApi.fetchSepsisBundle(id),
|
clinicalApi.fetchSepsisBundle(id),
|
||||||
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
||||||
@@ -100,6 +124,9 @@ async function loadAll() {
|
|||||||
encounter.value = enc
|
encounter.value = enc
|
||||||
observations.value = obs
|
observations.value = obs
|
||||||
news2History.value = history
|
news2History.value = history
|
||||||
|
gcsHistory.value = gcsHist
|
||||||
|
qsofaHistory.value = qsofaHist
|
||||||
|
sofaHistory.value = sofaHist
|
||||||
medications.value = meds
|
medications.value = meds
|
||||||
sepsisBundle.value = bundle
|
sepsisBundle.value = bundle
|
||||||
orders.value = ord.items ?? ord
|
orders.value = ord.items ?? ord
|
||||||
@@ -147,7 +174,7 @@ watch(openAlerts, (list) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch([observations, news2History, alerts], syncReplayBounds, { deep: true })
|
watch([observations, news2History, gcsHistory, qsofaHistory, sofaHistory, alerts], syncReplayBounds, { deep: true })
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
stopPlayback()
|
stopPlayback()
|
||||||
@@ -168,7 +195,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid min-w-0 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div class="space-y-4">
|
||||||
<ScoresPanel />
|
<ScoresPanel />
|
||||||
|
<GcsHistory v-if="replayGcsHistory.length" :history="replayGcsHistory" />
|
||||||
|
</div>
|
||||||
<VitalsPanel :observations="replayObservations" />
|
<VitalsPanel :observations="replayObservations" />
|
||||||
<AlertsList
|
<AlertsList
|
||||||
:encounter-id="route.params.encounterId"
|
:encounter-id="route.params.encounterId"
|
||||||
@@ -184,7 +214,10 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||||
|
<div class="space-y-4">
|
||||||
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
<SofaScorePanel :encounter-id="route.params.encounterId" />
|
||||||
|
<SofaHistory v-if="replaySofaHistory.length" :history="replaySofaHistory" />
|
||||||
|
</div>
|
||||||
<OrdersPanel :orders="orders" />
|
<OrdersPanel :orders="orders" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -197,6 +230,7 @@ onBeforeUnmount(() => {
|
|||||||
<div id="clinical-review" class="w-full min-w-0 space-y-8">
|
<div id="clinical-review" class="w-full min-w-0 space-y-8">
|
||||||
<TrendsGrid :observations="replayObservations" />
|
<TrendsGrid :observations="replayObservations" />
|
||||||
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
<News2History v-if="replayNews2History.length" :history="replayNews2History" />
|
||||||
|
<QsofaHistory v-if="replayQsofaHistory.length" :history="replayQsofaHistory" />
|
||||||
<ReplayControls
|
<ReplayControls
|
||||||
:alerts="openAlerts"
|
:alerts="openAlerts"
|
||||||
:is-paused="isPaused"
|
:is-paused="isPaused"
|
||||||
|
|||||||
Reference in New Issue
Block a user