feature: Simulation Control Center (Dashboard)
CI / backend (push) Successful in 8m43s
CI / frontend (push) Successful in 2m0s

This commit is contained in:
voltsrage
2026-08-06 02:22:55 +08:00
parent 24f45851e9
commit 1d28880920
91 changed files with 1943 additions and 25 deletions
@@ -142,6 +142,7 @@ public class EncountersListTests : IAsyncLifetime
icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE"); icu.GetProperty("gcsClassification").GetString().Should().Be("MODERATE");
icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward"); icu.GetProperty("attendingPhysician").GetString().Should().Be("Dr. Ward");
icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default); icu.GetProperty("lastObservationAt").GetDateTimeOffset().Should().NotBe(default);
icu.GetProperty("isSimulated").GetBoolean().Should().BeFalse();
} }
[Fact] [Fact]
@@ -19,4 +19,5 @@ public record WardEncounterSummary(
string? GcsClassification, string? GcsClassification,
DateTimeOffset? LastObservationAt, DateTimeOffset? LastObservationAt,
string AttendingPhysician, string AttendingPhysician,
DateTimeOffset AdmittedAt); DateTimeOffset AdmittedAt,
bool IsSimulated = false);
@@ -153,7 +153,8 @@ public class EncounterService : IEncounterService
gcs?.Classification, gcs?.Classification,
lastObservationAt, lastObservationAt,
encounter.AttendingPhysician, encounter.AttendingPhysician,
encounter.AdmittedAt)); encounter.AdmittedAt,
encounter.Patient.IsSimulated));
} }
return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total); return new PagedResult<WardEncounterSummary>(summaries, page, pageSize, total);
@@ -0,0 +1,2 @@
2dcd301eaabc7f81c1959c1adbb82b85204e1298 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz","integrity":"sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==","time":1785953912200,"size":571304,"metadata":{"time":1785953910664,"url":"https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"ed80e4dd0876eb8251989b454a19ded6\"","last-modified":"Sat, 13 Jun 2026 08:32:26 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
a685b54110c7e12febc5aa1baf5334b7fbb9bd5d {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz","integrity":"sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==","time":1785953911384,"size":32601,"metadata":{"time":1785953910992,"url":"https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"09853320d153c1e10f72563196274767\"","last-modified":"Thu, 02 Apr 2026 16:43:47 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
4e2fb49cf08db34c1a637a9f636d1d27ce4dd609 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz","integrity":"sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==","time":1785953912063,"size":1277372,"metadata":{"time":1785953910791,"url":"https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"6ffe96a217c6bef180cec697051b79d6\"","last-modified":"Fri, 12 Jun 2026 17:59:12 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
dc3f116e2fc0e24497fb31ef9dcf885e7783c990 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz","integrity":"sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==","time":1785953911295,"size":46389,"metadata":{"time":1785953911062,"url":"https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"62617ff0f132aa342fc74aa08d2d0d2e\"","last-modified":"Mon, 13 Apr 2026 20:41:46 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
93797821fbaaac28556311c548104081b86f3518 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz","integrity":"sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==","time":1785953911772,"size":427320,"metadata":{"time":1785953910956,"url":"https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"758a7d062fc209b0090ab072f4eee573\"","last-modified":"Tue, 05 May 2026 21:02:49 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
e67aed607cd01e02c6ebedcf28d21279491314cb {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/semver/-/semver-7.8.4.tgz","integrity":"sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==","time":1785953911977,"size":29325,"metadata":{"time":1785953910571,"url":"https://registry.npmjs.org/semver/-/semver-7.8.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"715397a31bd5c36494a3ed2a26bb88cb\"","last-modified":"Tue, 09 Jun 2026 23:50:05 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
6f1e2f039374ccb6ac28b858ec5c5618f121e92c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz","integrity":"sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==","time":1785953911816,"size":67009,"metadata":{"time":1785953910583,"url":"https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"0b30e6ab9d64834c6bb441d62e617709\"","last-modified":"Tue, 17 Jun 2025 17:54:01 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
673d2f7f8226db25658360b0bb849a5ee603316c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/glob/-/glob-10.5.0.tgz","integrity":"sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==","time":1785953912026,"size":74227,"metadata":{"time":1785953910963,"url":"https://registry.npmjs.org/glob/-/glob-10.5.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"35a8b606f227a3c84a8af5f3ebc03679\"","last-modified":"Tue, 18 Nov 2025 01:34:52 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
d1d5fe102df1c35571fbb714c7ab02a01cec6ba5 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz","integrity":"sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==","time":1785953912465,"size":3699964,"metadata":{"time":1785953910818,"url":"https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"cd82608915af34d4260109fc1ad6f7b8\"","last-modified":"Mon, 09 Mar 2026 04:23:34 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
c011da6f5a5f0bfe35f00a5177c4326ee481cfaa {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz","integrity":"sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==","time":1785953911307,"size":17360,"metadata":{"time":1785953911069,"url":"https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"8926ea0bf6fa92f4c76f5a9f8c801a3f\"","last-modified":"Tue, 31 Mar 2026 10:24:54 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,3 @@
497b3f086f894cbc8d2c76bb974449be9e3c2ff4 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","integrity":"sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==","time":1785953912118,"size":543193,"metadata":{"time":1785953911134,"url":"https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"366ac74512ac7cdc06673bd7362a157f\"","last-modified":"Mon, 01 Jun 2026 10:19:33 GMT"},"options":{"compress":true}}}
ee9f9593cef8c446efbf7c8aa2c1b6c271b08575 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","integrity":"sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==","time":1785953912122,"size":543193,"metadata":{"time":1785953911109,"url":"https://registry.npmjs.org/vite/-/vite-7.3.5.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"366ac74512ac7cdc06673bd7362a157f\"","last-modified":"Mon, 01 Jun 2026 10:19:33 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
ea25a12abc7092163c99cc73e4168e53f741e14b {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz","integrity":"sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==","time":1785953911429,"size":9355,"metadata":{"time":1785953910761,"url":"https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"e0aae139db7a2b9ce9182e755fb7e054\"","last-modified":"Sat, 04 May 2024 01:10:39 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
2c5baf041c1d5cb99a633042b26ba55b2d984ab3 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz","integrity":"sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==","time":1785953912409,"size":335129,"metadata":{"time":1785953910626,"url":"https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"d56e8d61c0afbadb89f9ab8f60870573\"","last-modified":"Mon, 01 Jun 2026 09:39:05 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
28e162e7303833ccf27101cccde30bdb0bffdf10 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz","integrity":"sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==","time":1785953911896,"size":438527,"metadata":{"time":1785953910674,"url":"https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"3581d99c820bf686b8c59131895ac7cf\"","last-modified":"Mon, 25 May 2026 11:16:21 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
19838e61dfa5067eb59d05b305614d96bbd1cf89 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz","integrity":"sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==","time":1785953912512,"size":4746453,"metadata":{"time":1785953910925,"url":"https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"0cb4b5c1243dce10cc2efa4dd4c5bc59\"","last-modified":"Thu, 02 Apr 2026 16:43:43 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
be1ff74de135f502f26d7a49bda384aa28ff8776 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz","integrity":"sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==","time":1785953911322,"size":3205,"metadata":{"time":1785953910747,"url":"https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"966c194bda253ce0a4f6a8301b6828ee\"","last-modified":"Mon, 08 Jul 2024 12:57:25 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
05fe4f0bf572f7d20fdc303b6b064bccabbc6c3c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz","integrity":"sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==","time":1785953912687,"size":8873207,"metadata":{"time":1785953910656,"url":"https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"76d28d6bf8f31554d587cd494cd8045e\"","last-modified":"Wed, 27 May 2026 11:47:02 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
7c3a3473b73bd8f3dfb90d58b6bd3e27c8d1ec32 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz","integrity":"sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==","time":1785953911912,"size":881262,"metadata":{"time":1785953910876,"url":"https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"a806648ad3d705c9f54816b2f4771223\"","last-modified":"Sat, 13 Jun 2026 08:31:58 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
94da227b56946879a36cc2227fd492f384ea94d0 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz","integrity":"sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==","time":1785953912291,"size":728769,"metadata":{"time":1785953910555,"url":"https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"2b85be9f8c2af7046d1181f3d873eda4\"","last-modified":"Sat, 12 Apr 2025 07:09:23 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
06f0683b26e0225edaae1d86d37467b03088b647 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz","integrity":"sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==","time":1785953912001,"size":174936,"metadata":{"time":1785953910523,"url":"https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"dd35902285a469bef4b6341d2d899174\"","last-modified":"Wed, 27 May 2026 11:49:17 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
f2c512257fcfeae452af0e1a76c161ac93881296 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/vite/-/vite-8.0.16.tgz","integrity":"sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==","time":1785953912135,"size":538260,"metadata":{"time":1785953910603,"url":"https://registry.npmjs.org/vite/-/vite-8.0.16.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"1c1ff0e0d13d58d1692d08a5ecc097e2\"","last-modified":"Mon, 01 Jun 2026 09:50:47 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
95545cc9f8501d418b345c7683775f3ff8c1a1c1 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz","integrity":"sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==","time":1785953911310,"size":9681,"metadata":{"time":1785953910996,"url":"https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"ab7e2817e97f21770839622bfc33fc36\"","last-modified":"Fri, 27 Feb 2026 23:16:49 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
309290abb0f4672b228fcc24f5000f83b4b09379 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz","integrity":"sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==","time":1785953911966,"size":982610,"metadata":{"time":1785953910836,"url":"https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"ef2a6aa27e252652e8e20b9df1f5c864\"","last-modified":"Sat, 13 Jun 2026 08:32:02 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
86545026ef44375b1a05070f481eb2f0e28927ea {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz","integrity":"sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==","time":1785953912158,"size":196338,"metadata":{"time":1785953910951,"url":"https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"25ec268915df89e2c9147cc847cfa0ae\"","last-modified":"Thu, 27 Feb 2025 17:54:02 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
fcdf5dec3ff20265222963c02402cccf93cb436e {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz","integrity":"sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==","time":1785953911336,"size":5240,"metadata":{"time":1785953910578,"url":"https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"525ca79ade2ccfef596412f4ffa432c3\"","last-modified":"Tue, 16 Dec 2025 15:17:59 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
a631269bc2d1a106a8dccf568adece63694153ef {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/which/-/which-2.0.2.tgz","integrity":"sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==","time":1785953911289,"size":4496,"metadata":{"time":1785953910542,"url":"https://registry.npmjs.org/which/-/which-2.0.2.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"9556a736013ff5223cc9731d9f32b55f\"","last-modified":"Mon, 18 Nov 2019 22:26:18 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
24be7f0adf6dd77562f46dea0028063dd47e1c3e {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz","integrity":"sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==","time":1785953911308,"size":53370,"metadata":{"time":1785953911059,"url":"https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"49edc750f1e546f945131df311d72e52\"","last-modified":"Wed, 17 Jun 2026 16:56:26 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
b1906c92556ec267f05fac0dbe5dfc0eced263de {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz","integrity":"sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==","time":1785953911308,"size":39414,"metadata":{"time":1785953911103,"url":"https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, must-revalidate, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"31c7953660299e731ee70e98354f398f\"","last-modified":"Mon, 01 Jun 2026 09:38:48 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
e88d176a08778649375e12b00ae8e4c22c0a8f0c {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz","integrity":"sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==","time":1785953911305,"size":7167,"metadata":{"time":1785953910846,"url":"https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:31 GMT","etag":"\"cac9bb6bc6055fc1fcf99c3135dfa5f3\"","last-modified":"Thu, 18 Jun 2026 17:29:54 GMT"},"options":{"compress":true}}}
@@ -0,0 +1,2 @@
bb6ac3a8e022d5572d54b2f5d3932c2d21af0188 {"key":"make-fetch-happen:request-cache:https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz","integrity":"sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==","time":1785953911308,"size":3965,"metadata":{"time":1785953911137,"url":"https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz","reqHeaders":{},"resHeaders":{"cache-control":"public, immutable, max-age=31557600","content-type":"application/octet-stream","date":"Wed, 05 Aug 2026 18:18:32 GMT","etag":"\"5d5a51f9670467a8edaef4269b413647\"","last-modified":"Fri, 20 Jan 2023 02:27:13 GMT"},"options":{"compress":true}}}
+1
View File
@@ -7,6 +7,7 @@
"": { "": {
"name": "vigilcare-dashboard", "name": "vigilcare-dashboard",
"version": "0.0.0", "version": "0.0.0",
"license": "SEE LICENSE IN LICENSE",
"dependencies": { "dependencies": {
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
+1
View File
@@ -10,6 +10,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:unit": "vitest run",
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
@@ -40,17 +40,15 @@ describe('PatientBanner', () => {
expect(strip.classes().join(' ')).toMatch(/bg-red-600/) expect(strip.classes().join(' ')).toMatch(/bg-red-600/)
}) })
it('showsNkdaWhenNoAllergies', () => { it('showsSimChipWhenPatientIsSimulated', () => {
const wrapper = mount(PatientBanner, { const wrapper = mount(PatientBanner, {
props: { props: {
encounter: { encounter: {
...encounter, ...encounter,
patient: { ...encounter.patient, allergies: null }, patient: { ...encounter.patient, isSimulated: true },
}, },
}, },
}) })
expect(wrapper.text()).toContain('NKDA') expect(wrapper.text()).toContain('SIM')
const strip = wrapper.find('[role="status"]')
expect(strip.classes().join(' ')).not.toMatch(/bg-red-600/)
}) })
}) })
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
const baseScenario = {
id: 'stable-baseline-01',
name: 'Stable Baseline — Routine Inpatient Monitoring',
description: '52-year-old female admitted for elective cholecystectomy. Completely uneventful 8-hour post-op monitoring period with all vitals remaining normal throughout. This is a CONTROL scenario that validates the system does NOT generate false alerts on a stable patient.',
durationMinutes: 480,
tags: ['stable', 'baseline', 'control', 'surgery'],
department: 'Surgery',
eventCount: 312,
}
describe('ScenarioCard', () => {
it('rendersMetadata', () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
expect(wrapper.text()).toContain(baseScenario.name)
expect(wrapper.text()).toContain('8 hours of monitoring')
expect(wrapper.text()).toContain('Surgery')
expect(wrapper.text()).toContain('312 events')
expect(wrapper.text()).toContain('control')
})
it('labelsControlScenario', () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
expect(wrapper.text()).toContain('Control scenario — should stay quiet')
})
it('labelsRealDataScenario', () => {
const wrapper = mount(ScenarioCard, {
props: {
scenario: {
...baseScenario,
id: 'mimic-case-01',
tags: ['mimic-iv', 'real-data', 'sepsis'],
},
},
})
expect(wrapper.text()).toContain('Real de-identified ICU record')
})
it('disablesStartAtConcurrencyLimit', async () => {
const wrapper = mount(ScenarioCard, {
props: {
scenario: baseScenario,
atConcurrencyLimit: true,
maxConcurrentRuns: 2,
},
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
expect(startBtn.attributes('disabled')).toBeDefined()
expect(startBtn.attributes('title')).toMatch(/Concurrency limit reached/)
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toBeUndefined()
})
it('emitsStartWithScenarioId', async () => {
const wrapper = mount(ScenarioCard, {
props: { scenario: baseScenario },
})
const startBtn = wrapper.findAll('button').find(b => b.text() === 'Start')
await startBtn.trigger('click')
expect(wrapper.emitted('start')).toEqual([['stable-baseline-01']])
})
})
@@ -0,0 +1,100 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SimulationControlView from '@/views/SimulationControlView.vue'
const {
fetchScenarios,
fetchRuns,
fetchSimulationConfig,
startRun,
stopRun,
} = vi.hoisted(() => ({
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
fetchSimulationConfig: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchScenarios,
fetchRuns,
fetchSimulationConfig,
startRun,
stopRun,
fetchRun: vi.fn(),
}))
vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters: vi.fn() }),
}))
const scenarios = [
{
id: 'uti-sepsis-elderly-01',
name: 'UTI Sepsis Elderly',
description: 'Elderly patient with UTI progressing to sepsis.',
durationMinutes: 480,
tags: ['sepsis', 'uti'],
department: 'GeneralMedicine',
eventCount: 200,
},
{
id: 'stable-baseline-01',
name: 'Stable Baseline',
description: 'Control scenario.',
durationMinutes: 480,
tags: ['control', 'stable'],
department: 'Surgery',
eventCount: 312,
},
]
describe('SimulationControlView', () => {
beforeEach(() => {
vi.clearAllMocks()
setActivePinia(createPinia())
fetchScenarios.mockResolvedValue(scenarios)
fetchRuns.mockResolvedValue([])
fetchSimulationConfig.mockResolvedValue({
enabled: true,
maxSpeed: 600,
maxConcurrentRuns: 2,
})
})
it('showsWallClockDurationForSelectedScenarioSpeed', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('UTI Sepsis Elderly'))
// Default Fast (60×): 480 sim minutes → 8 wall-clock minutes
expect(wrapper.text()).toMatch(/8 minutes for selected scenario/)
const realTime = wrapper.findAll('button').find(b => b.text().includes('Real time'))
expect(realTime.text()).toMatch(/8 hours for selected scenario/)
const instant = wrapper.findAll('button').find(b => b.text().includes('Instant'))
expect(instant.text()).toMatch(/1 minute for selected scenario/)
})
it('filtersCatalogueByTag', async () => {
const wrapper = mount(SimulationControlView)
await vi.waitFor(() => expect(wrapper.text()).toContain('Stable Baseline'))
const sepsisChip = wrapper.findAll('button').find(b => b.text() === 'sepsis')
await sepsisChip.trigger('click')
expect(wrapper.text()).toContain('UTI Sepsis Elderly')
expect(wrapper.text()).not.toContain('Stable Baseline')
})
it('showsEmptyStateWhenCatalogueMissing', async () => {
fetchScenarios.mockResolvedValue([])
const wrapper = mount(SimulationControlView)
await vi.waitFor(() =>
expect(wrapper.text()).toContain('Simulation:ScenarioDirectory'),
)
})
})
@@ -0,0 +1,25 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
import { useSimulationStore } from '@/stores/simulation'
describe('SimulationModeBanner', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('rendersWhenEnabled', () => {
useSimulationStore().enabled = true
const wrapper = mount(SimulationModeBanner)
expect(wrapper.text()).toContain('SIMULATION MODE')
expect(wrapper.find('[role="status"]').exists()).toBe(true)
})
it('isAbsentWhenDisabled', () => {
useSimulationStore().enabled = false
const wrapper = mount(SimulationModeBanner)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.text()).toBe('')
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
const RouterLinkStub = {
props: ['to'],
template: '<a :href="typeof to === \'string\' ? to : \'\'"><slot /></a>',
}
const activeRun = {
runId: 'run-1',
scenarioId: 'uti-sepsis-elderly-01',
scenarioName: 'UTI Sepsis Elderly',
status: 'RUNNING',
speed: 60,
encounterId: 'enc-99',
patientDisplayName: 'Mary Chen',
startedAt: '2026-08-05T10:00:00Z',
elapsedRealSeconds: 125,
lastOffsetMinutes: 200,
totalOffsetMinutes: 480,
progressPercent: 41.6,
observationsSent: 40,
medicationsSent: 2,
ordersPlaced: 1,
failureReason: null,
}
function mountPanel(props) {
return mount(SimulationRunPanel, {
props,
global: { stubs: { RouterLink: RouterLinkStub } },
})
}
describe('SimulationRunPanel', () => {
it('showsProgressAndSimClock', () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
expect(wrapper.text()).toContain('03:20 of 08:00 elapsed')
expect(wrapper.text()).toContain('42%')
expect(wrapper.text()).toContain('60×')
expect(wrapper.find('[role="progressbar"]').attributes('aria-valuenow')).toBe('42')
})
it('linksToPatientEncounter', () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
const link = wrapper.find('a[href="/patients/enc-99"]')
expect(link.exists()).toBe(true)
expect(link.text()).toContain('Mary Chen')
})
it('requiresStopConfirmBeforeEmitting', async () => {
const wrapper = mountPanel({ activeRuns: [activeRun], recentRuns: [] })
await wrapper.findAll('button').find(b => b.text() === 'Stop').trigger('click')
expect(wrapper.emitted('stop')).toBeUndefined()
expect(wrapper.text()).toContain('Stopping leaves the patient on the ward')
await wrapper.findAll('button').find(b => b.text() === 'Confirm stop').trigger('click')
expect(wrapper.emitted('stop')).toEqual([['run-1']])
})
it('showsFailureReasonForFailedRecentRun', () => {
const wrapper = mountPanel({
activeRuns: [],
recentRuns: [{
...activeRun,
status: 'FAILED',
failureReason: 'Loopback authentication failed',
}],
})
expect(wrapper.text()).toContain('Failed')
expect(wrapper.text()).toContain('Loopback authentication failed')
})
})
@@ -1,5 +1,15 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect, beforeEach } from 'vitest'
import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN_NAV_LINKS } from '@/composables/roleAccess' import { createPinia, setActivePinia } from 'pinia'
import {
canAccessOps,
filterNavLinks,
isDashboardRole,
roleCanAccessRoute,
MAIN_NAV_LINKS,
useRoleAccess,
} from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
describe('roleAccess', () => { describe('roleAccess', () => {
it('filtersNavLinksByRole', () => { it('filtersNavLinksByRole', () => {
@@ -29,3 +39,50 @@ describe('roleAccess', () => {
expect(canAccessOps('INTEGRATION')).toBe(false) expect(canAccessOps('INTEGRATION')).toBe(false)
}) })
}) })
describe('useRoleAccess simulation nav', () => {
beforeEach(() => {
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'tok'
auth.user = {
userId: '1',
username: 'nurse',
displayName: 'Nurse',
role: 'NURSE',
}
})
it('includesSimulationLinkOnlyWhenEnabled', () => {
const sim = useSimulationStore()
const { mainNavLinks } = useRoleAccess()
sim.enabled = false
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
sim.enabled = true
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
})
it('omitsSimulationLinkWhenDisabledRegardlessOfRole', () => {
const auth = useAuthStore()
const sim = useSimulationStore()
sim.enabled = false
for (const role of ['NURSE', 'PHYSICIAN', 'ADMIN']) {
auth.user = { ...auth.user, role }
const { mainNavLinks } = useRoleAccess()
expect(mainNavLinks.value.some(l => l.to === '/simulation')).toBe(false)
}
})
it('movesQualityToOverflowWhenSimulationEnabledOnMobile', () => {
const sim = useSimulationStore()
const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
sim.enabled = true
expect(mobileNavLinks.value.some(l => l.to === '/simulation')).toBe(true)
expect(mobileNavLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(false)
expect(mobileOverflowLinks.value.some(l => l.to === '/admin/reconciliation')).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
const { fetchSimulationConfig } = vi.hoisted(() => ({
fetchSimulationConfig: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchSimulationConfig,
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
fetchRun: vi.fn(),
}))
describe('simulation router guard', () => {
beforeEach(async () => {
vi.clearAllMocks()
setActivePinia(createPinia())
const auth = useAuthStore()
auth.token = 'test-token'
auth.user = {
userId: 'u1',
username: 'nurse',
displayName: 'Test Nurse',
role: 'NURSE',
}
fetchSimulationConfig.mockResolvedValue({ enabled: false })
const { default: router } = await import('@/router')
// Reset to a known route; ignore failures from first load
await router.push('/ward').catch(() => {})
await router.isReady()
})
it('redirectsDirectSimulationNavigationWhenDisabled', async () => {
const sim = useSimulationStore()
sim.enabled = false
const { default: router } = await import('@/router')
await router.push('/simulation')
expect(router.currentRoute.value.path).toBe('/ward')
expect(fetchSimulationConfig).toHaveBeenCalled()
})
it('allowsSimulationWhenEnabled', async () => {
const sim = useSimulationStore()
sim.enabled = true
const { default: router } = await import('@/router')
await router.push('/simulation')
expect(router.currentRoute.value.path).toBe('/simulation')
})
})
@@ -0,0 +1,171 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { useSimulationStore } from '@/stores/simulation'
const {
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
loadEncounters,
} = vi.hoisted(() => ({
fetchSimulationConfig: vi.fn(),
fetchScenarios: vi.fn(),
fetchRuns: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
loadEncounters: vi.fn(),
}))
vi.mock('@/api/simulation', () => ({
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
fetchRun: vi.fn(),
}))
vi.mock('@/stores/ward', () => ({
useWardStore: () => ({ loadEncounters }),
}))
function runningRun(overrides = {}) {
return {
runId: 'run-1',
scenarioId: 'uti-sepsis-elderly-01',
scenarioName: 'UTI Sepsis',
status: 'RUNNING',
speed: 60,
patientId: 'p1',
encounterId: 'e1',
patientDisplayName: 'Test Patient',
startedAt: '2026-08-05T10:00:00Z',
elapsedRealSeconds: 30,
lastOffsetMinutes: 20,
totalOffsetMinutes: 480,
progressPercent: 4,
observationsSent: 3,
medicationsSent: 0,
ordersPlaced: 0,
failureReason: null,
...overrides,
}
}
describe('useSimulationStore', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
setActivePinia(createPinia())
fetchRuns.mockResolvedValue([])
fetchScenarios.mockResolvedValue([])
fetchSimulationConfig.mockResolvedValue({
enabled: true,
maxSpeed: 600,
maxConcurrentRuns: 2,
})
})
afterEach(() => {
const store = useSimulationStore()
store.stopPolling()
vi.useRealTimers()
})
it('activeRuns_and_atConcurrencyLimit', () => {
const store = useSimulationStore()
store.maxConcurrentRuns = 2
store.runs = [
runningRun({ runId: 'a', status: 'RUNNING' }),
runningRun({ runId: 'b', status: 'PENDING' }),
runningRun({ runId: 'c', status: 'COMPLETED' }),
]
expect(store.activeRuns.map(r => r.runId)).toEqual(['a', 'b'])
expect(store.hasActiveRun).toBe(true)
expect(store.atConcurrencyLimit).toBe(true)
expect(store.recentRuns.map(r => r.runId)).toEqual(['c'])
})
it('start_pushesOptimisticPlaceholderThenReconciles', async () => {
const store = useSimulationStore()
store.scenarios = [{ id: 's1', name: 'Scenario One', durationMinutes: 480 }]
let resolveStart
startRun.mockReturnValue(new Promise((resolve) => {
resolveStart = resolve
}))
const startPromise = store.start('s1', 60)
await nextTick()
expect(store.activeRuns).toHaveLength(1)
expect(store.activeRuns[0].status).toBe('PENDING')
expect(store.activeRuns[0]._optimistic).toBe(true)
expect(store.starting).toBe(true)
resolveStart(runningRun({ runId: 'real-1', scenarioId: 's1', scenarioName: 'Scenario One' }))
await startPromise
expect(store.runs).toHaveLength(1)
expect(store.runs[0].runId).toBe('real-1')
expect(store.runs[0]._optimistic).toBeUndefined()
expect(store.starting).toBe(false)
expect(loadEncounters).toHaveBeenCalled()
})
it('start_rollsBackPlaceholderOnFailure', async () => {
const store = useSimulationStore()
startRun.mockRejectedValue(new Error('Maximum concurrent simulation runs (2) reached.'))
await expect(store.start('s1', 60)).rejects.toThrow(/concurrent/)
expect(store.runs).toEqual([])
expect(store.error).toMatch(/concurrent/)
expect(store.starting).toBe(false)
})
it('stop_updatesRunFromApi', async () => {
const store = useSimulationStore()
store.runs = [runningRun()]
stopRun.mockResolvedValue(runningRun({ status: 'CANCELLED' }))
await store.stop('run-1')
expect(stopRun).toHaveBeenCalledWith('run-1')
expect(store.runs[0].status).toBe('CANCELLED')
})
it('polling_runsOnlyWhileActiveRunExists', async () => {
const store = useSimulationStore()
store.startPolling()
await vi.advanceTimersByTimeAsync(5_000)
expect(fetchRuns).not.toHaveBeenCalled()
store.runs = [runningRun()]
await nextTick()
await vi.advanceTimersByTimeAsync(2_000)
expect(fetchRuns).toHaveBeenCalledTimes(1)
fetchRuns.mockResolvedValue([runningRun({ status: 'COMPLETED', progressPercent: 100 })])
await store.refreshRuns()
await nextTick()
fetchRuns.mockClear()
await vi.advanceTimersByTimeAsync(4_000)
expect(fetchRuns).not.toHaveBeenCalled()
store.stopPolling()
})
it('loadConfig_failsClosedOnError', async () => {
const store = useSimulationStore()
fetchSimulationConfig.mockRejectedValue(new Error('network'))
await store.loadConfig()
expect(store.enabled).toBe(false)
expect(store.maxConcurrentRuns).toBe(0)
})
})
+11
View File
@@ -89,3 +89,14 @@ const BUNDLE_ELEMENT_LABELS = {
export function bundleElementLabel(elementType) { export function bundleElementLabel(elementType) {
return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType return BUNDLE_ELEMENT_LABELS[elementType] ?? elementType
} }
/**
* True when the ward summary or encounter detail marks the patient as simulated.
* Ward list: top-level `isSimulated`. Patient detail: `patient.isSimulated`.
*/
export function isSimulatedPatient(source) {
if (!source) return false
if (source.isSimulated === true) return true
if (source.patient?.isSimulated === true) return true
return false
}
@@ -0,0 +1,8 @@
import { api } from './client'
export const fetchSimulationConfig = () => api.get('/api/v1/simulation/config')
export const fetchScenarios = () => api.get('/api/v1/simulation/scenarios')
export const fetchRuns = () => api.get('/api/v1/simulation/runs')
export const fetchRun = (runId) => api.get(`/api/v1/simulation/runs/${runId}`)
export const startRun = (body) => api.post('/api/v1/simulation/runs', body)
export const stopRun = (runId) => api.post(`/api/v1/simulation/runs/${runId}/stop`)
@@ -1,13 +1,31 @@
<script setup> <script setup>
import { onMounted, watch } from 'vue'
import AppHeader from './AppHeader.vue' import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue' import AppSidebar from './AppSidebar.vue'
import MobileNav from './MobileNav.vue' import MobileNav from './MobileNav.vue'
import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue' import CriticalAlertBanner from '@/components/alerts/CriticalAlertBanner.vue'
import SimulationModeBanner from '@/components/simulation/SimulationModeBanner.vue'
import { useAuthStore } from '@/stores/auth'
import { useSettingsStore } from '@/stores/settings' import { useSettingsStore } from '@/stores/settings'
import { useSimulationStore } from '@/stores/simulation'
import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling' import { useCriticalAlertPolling } from '@/composables/useCriticalAlertPolling'
import { useSimulationAvailable } from '@/composables/useSimulationAvailable'
const authStore = useAuthStore()
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const simulationStore = useSimulationStore()
const { detect: detectSimulation } = useSimulationAvailable()
useCriticalAlertPolling(settingsStore.pollInterval) useCriticalAlertPolling(settingsStore.pollInterval)
onMounted(() => {
if (authStore.isAuthenticated) detectSimulation()
})
watch(() => authStore.isAuthenticated, (ok) => {
if (ok) detectSimulation()
else simulationStore.enabled = false
})
</script> </script>
<template> <template>
@@ -21,6 +39,7 @@ useCriticalAlertPolling(settingsStore.pollInterval)
<AppSidebar /> <AppSidebar />
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<AppHeader /> <AppHeader />
<SimulationModeBanner />
<CriticalAlertBanner /> <CriticalAlertBanner />
<main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1"> <main id="main-content" class="flex-1 overflow-y-auto p-4 pb-24 lg:p-8 lg:pb-8" tabindex="-1">
<div class="mx-auto w-full max-w-7xl"> <div class="mx-auto w-full max-w-7xl">
@@ -133,6 +133,27 @@ function linkClasses(path) {
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/> />
</svg> </svg>
<svg
v-else-if="link.icon === 'simulation'"
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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg <svg
v-else v-else
class="h-6 w-6 shrink-0" class="h-6 w-6 shrink-0"
@@ -1,14 +1,28 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useRoleAccess } from '@/composables/roleAccess' import { useRoleAccess } from '@/composables/roleAccess'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const route = useRoute() const route = useRoute()
const authStore = useAuthStore() const authStore = useAuthStore()
const { mobileNavLinks } = useRoleAccess() const { mobileNavLinks, mobileOverflowLinks } = useRoleAccess()
const overflowOpen = ref(false)
const activePath = computed(() => route.path) const activePath = computed(() => route.path)
function linkActive(to) {
return activePath.value.startsWith(to)
}
function toggleOverflow() {
overflowOpen.value = !overflowOpen.value
}
function closeOverflow() {
overflowOpen.value = false
}
</script> </script>
<template> <template>
@@ -16,14 +30,32 @@ const activePath = computed(() => route.path)
class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900" class="fixed inset-x-0 bottom-0 z-40 border-t border-gray-200 bg-white lg:hidden dark:border-gray-800 dark:bg-gray-900"
aria-label="Mobile navigation" aria-label="Mobile navigation"
> >
<div v-if="overflowOpen && mobileOverflowLinks.length" class="border-b border-gray-200 dark:border-gray-800">
<ul class="flex flex-col py-2">
<li v-for="link in mobileOverflowLinks" :key="link.to">
<RouterLink
:to="link.to"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500"
:class="linkActive(link.to)
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300'"
@click="closeOverflow"
>
{{ link.label }}
</RouterLink>
</li>
</ul>
</div>
<ul class="flex h-16 items-stretch"> <ul class="flex h-16 items-stretch">
<li v-for="link in mobileNavLinks" :key="link.to" class="flex-1"> <li v-for="link in mobileNavLinks" :key="link.to" class="min-w-0 flex-1">
<RouterLink <RouterLink
:to="link.to" :to="link.to"
class="flex h-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500" class="flex h-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
:class="activePath.startsWith(link.to) :class="linkActive(link.to)
? 'text-blue-600 dark:text-blue-400' ? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'" : 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="closeOverflow"
> >
<svg <svg
v-if="link.icon === 'ward'" v-if="link.icon === 'ward'"
@@ -70,6 +102,27 @@ const activePath = computed(() => route.path)
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/> />
</svg> </svg>
<svg
v-else-if="link.icon === 'simulation'"
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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<svg <svg
v-else v-else
class="h-6 w-6" class="h-6 w-6"
@@ -85,19 +138,38 @@ const activePath = computed(() => route.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" 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>
{{ link.label }} <span class="truncate">{{ link.label }}</span>
</RouterLink> </RouterLink>
</li> </li>
<li class="flex-1">
<li v-if="mobileOverflowLinks.length" class="min-w-0 flex-1">
<button <button
type="button" type="button"
class="flex h-full w-full flex-col items-center justify-center gap-2 px-4 py-2 text-xs font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400" class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium transition duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-blue-500 sm:gap-2 sm:px-4 sm:text-xs"
:class="overflowOpen || mobileOverflowLinks.some(l => linkActive(l.to))
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
:aria-expanded="overflowOpen"
aria-label="More navigation"
@click="toggleOverflow"
>
<svg 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 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z" />
</svg>
<span class="truncate">More</span>
</button>
</li>
<li class="min-w-0 flex-1">
<button
type="button"
class="flex h-full w-full flex-col items-center justify-center gap-1 px-1 py-2 text-[11px] font-medium text-gray-500 transition duration-200 hover:text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-red-500 dark:text-gray-400 dark:hover:text-red-400 sm:gap-2 sm:px-4 sm:text-xs"
@click="authStore.logout()" @click="authStore.logout()"
> >
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"> <svg 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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg> </svg>
Sign out <span class="truncate">Sign out</span>
</button> </button>
</li> </li>
</ul> </ul>
@@ -8,6 +8,7 @@ import {
formatGender, formatGender,
hasKnownAllergies, hasKnownAllergies,
} from '@/composables/patientFormat' } from '@/composables/patientFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({ const props = defineProps({
encounter: { type: Object, required: true }, encounter: { type: Object, required: true },
@@ -15,6 +16,8 @@ const props = defineProps({
const patient = computed(() => props.encounter.patient ?? {}) const patient = computed(() => props.encounter.patient ?? {})
const simulated = computed(() => isSimulatedPatient(props.encounter))
const fullName = computed(() => const fullName = computed(() =>
[patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient', [patient.value.firstName, patient.value.lastName].filter(Boolean).join(' ') || 'Unknown patient',
) )
@@ -49,6 +52,13 @@ const emergencyContact = computed(() => {
<h1 class="text-xl font-bold text-gray-900 dark:text-white"> <h1 class="text-xl font-bold text-gray-900 dark:text-white">
{{ fullName }} {{ fullName }}
</h1> </h1>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
<span class="text-sm text-gray-600 dark:text-gray-400"> <span class="text-sm text-gray-600 dark:text-gray-400">
MRN {{ patient.mrn ?? '—' }} MRN {{ patient.mrn ?? '—' }}
</span> </span>
@@ -0,0 +1,163 @@
<script setup>
import { computed, ref } from 'vue'
import Card from '@/components/ui/Card.vue'
import Button from '@/components/ui/Button.vue'
import { formatDepartment } from '@/composables/sepsisFormat'
import {
formatMonitoringDuration,
tagChipClass,
isControlTag,
isRealDataTag,
} from '@/composables/simulationFormat'
const props = defineProps({
scenario: { type: Object, required: true },
atConcurrencyLimit: { type: Boolean, default: false },
maxConcurrentRuns: { type: Number, default: 0 },
selected: { type: Boolean, default: false },
starting: { type: Boolean, default: false },
})
const emit = defineEmits(['start', 'select'])
const expanded = ref(false)
const description = computed(() => props.scenario.description?.trim() || '')
const canExpand = computed(() => description.value.length > 140)
const durationLabel = computed(() =>
formatMonitoringDuration(props.scenario.durationMinutes),
)
const departmentLabel = computed(() =>
formatDepartment(props.scenario.department),
)
const eventLabel = computed(() => {
const n = props.scenario.eventCount ?? 0
return `${n} event${n === 1 ? '' : 's'}`
})
const tags = computed(() => props.scenario.tags ?? [])
const showControlNote = computed(() => tags.value.some(isControlTag))
const showRealDataNote = computed(() => tags.value.some(isRealDataTag))
const startDisabled = computed(() =>
props.atConcurrencyLimit || props.starting,
)
const disabledReason = computed(() => {
if (props.atConcurrencyLimit) {
const n = props.maxConcurrentRuns
return n > 0
? `Concurrency limit reached (${n} run${n === 1 ? '' : 's'} max). Stop an active run first.`
: 'Concurrency limit reached. Stop an active run first.'
}
return undefined
})
function onStart() {
if (startDisabled.value) return
emit('start', props.scenario.id)
}
function onSelect() {
emit('select', props.scenario.id)
}
</script>
<template>
<Card
padding="md"
class="flex h-full flex-col transition"
:class="selected
? 'ring-2 ring-blue-500 dark:ring-blue-400'
: 'hover:border-gray-300 dark:hover:border-gray-600'"
>
<div
class="flex-1 cursor-pointer"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="onSelect"
@keydown.enter.prevent="onSelect"
@keydown.space.prevent="onSelect"
>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ scenario.name }}
</h3>
<p
v-if="description"
class="mt-2 text-sm text-gray-600 dark:text-gray-400"
:class="expanded ? '' : 'line-clamp-3'"
>
{{ description }}
</p>
</div>
<button
v-if="canExpand"
type="button"
class="mt-1 self-start text-xs font-medium text-blue-600 hover:underline dark:text-blue-400"
@click="expanded = !expanded"
>
{{ expanded ? 'Show less' : 'Show more' }}
</button>
<div class="mt-3 flex flex-wrap gap-2">
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ durationLabel }}
</span>
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ departmentLabel }}
</span>
<span
class="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
{{ eventLabel }}
</span>
</div>
<div v-if="tags.length" class="mt-3 flex flex-wrap gap-1.5">
<span
v-for="tag in tags"
:key="tag"
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
:class="tagChipClass(tag)"
>
{{ tag }}
</span>
</div>
<p
v-if="showControlNote"
class="mt-3 text-xs font-medium text-emerald-700 dark:text-emerald-300"
>
Control scenario should stay quiet
</p>
<p
v-if="showRealDataNote"
class="mt-1 text-xs font-medium text-violet-700 dark:text-violet-300"
>
Real de-identified ICU record
</p>
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-800">
<Button
class="w-full"
size="sm"
:disabled="startDisabled"
:title="disabledReason"
@click="onStart"
>
Start
</Button>
</div>
</Card>
</template>
@@ -0,0 +1,18 @@
<script setup>
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
const { enabled } = storeToRefs(useSimulationStore())
</script>
<template>
<div
v-if="enabled"
role="status"
class="border-b border-amber-400 bg-amber-100 px-4 py-2.5 text-center text-sm text-amber-950 dark:border-amber-600 dark:bg-amber-950/60 dark:text-amber-100"
>
<span class="font-bold tracking-wide">SIMULATION MODE</span>
<span class="mx-1.5"></span>
patients shown here are simulated. Do not use for clinical decisions.
</div>
</template>
@@ -0,0 +1,238 @@
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import Card from '@/components/ui/Card.vue'
import Badge from '@/components/ui/Badge.vue'
import Button from '@/components/ui/Button.vue'
import {
formatSimClock,
formatElapsedReal,
formatSpeedBadge,
runStatusLabel,
runStatusVariant,
} from '@/composables/simulationFormat'
defineProps({
activeRuns: { type: Array, default: () => [] },
recentRuns: { type: Array, default: () => [] },
stoppingId: { type: [String, null], default: null },
})
const emit = defineEmits(['stop'])
const confirmStopId = ref(null)
function requestStop(runId) {
confirmStopId.value = runId
}
function cancelStop() {
confirmStopId.value = null
}
function confirmStop(runId) {
confirmStopId.value = null
emit('stop', runId)
}
function patientLink(run) {
return run.encounterId ? `/patients/${run.encounterId}` : null
}
function progressWidth(run) {
const pct = Math.min(100, Math.max(0, Number(run.progressPercent) || 0))
return `${pct}%`
}
</script>
<template>
<div class="space-y-6">
<div v-if="activeRuns.length" class="space-y-4">
<h2 class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Active runs
</h2>
<Card
v-for="run in activeRuns"
:key="run.runId"
padding="md"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ run.scenarioName }}
</h3>
<Badge :variant="runStatusVariant(run.status)" size="xs">
{{ runStatusLabel(run.status) }}
</Badge>
<span
class="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-800 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ formatSpeedBadge(run.speed) }}
</span>
</div>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
<template v-if="patientLink(run)">
<RouterLink
:to="patientLink(run)"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ run.patientDisplayName || 'Open patient' }}
</RouterLink>
</template>
<template v-else>
{{ run.patientDisplayName || 'Patient pending' }}
</template>
</p>
</div>
<div class="shrink-0">
<template v-if="confirmStopId === run.runId">
<div class="flex flex-col items-end gap-2">
<p class="max-w-xs text-right text-xs text-gray-500 dark:text-gray-400">
Stopping leaves the patient on the ward.
</p>
<div class="flex gap-2">
<Button size="sm" variant="secondary" @click="cancelStop">
Cancel
</Button>
<Button
size="sm"
variant="danger"
:disabled="stoppingId === run.runId"
@click="confirmStop(run.runId)"
>
Confirm stop
</Button>
</div>
</div>
</template>
<Button
v-else
size="sm"
variant="danger"
:disabled="run._optimistic || stoppingId === run.runId"
@click="requestStop(run.runId)"
>
Stop
</Button>
</div>
</div>
<div class="mt-4">
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
<span>{{ formatSimClock(run.lastOffsetMinutes, run.totalOffsetMinutes) }}</span>
<span>{{ Math.round(run.progressPercent ?? 0) }}%</span>
</div>
<div
class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800"
role="progressbar"
:aria-valuenow="Math.round(run.progressPercent ?? 0)"
aria-valuemin="0"
aria-valuemax="100"
>
<div
class="h-full rounded-full bg-blue-600 transition-all dark:bg-blue-500"
:style="{ width: progressWidth(run) }"
/>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
Wall clock {{ formatElapsedReal(run.elapsedRealSeconds) }}
</p>
</div>
<dl class="mt-4 grid grid-cols-3 gap-4 text-sm">
<div>
<dt class="text-gray-500 dark:text-gray-400">Observations</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.observationsSent ?? 0 }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Medications</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.medicationsSent ?? 0 }}
</dd>
</div>
<div>
<dt class="text-gray-500 dark:text-gray-400">Orders</dt>
<dd class="mt-0.5 font-semibold text-gray-900 dark:text-white">
{{ run.ordersPlaced ?? 0 }}
</dd>
</div>
</dl>
<p
v-if="run.status === 'FAILED' && run.failureReason"
class="mt-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950/40 dark:text-red-300"
role="alert"
>
{{ run.failureReason }}
</p>
</Card>
</div>
<div v-if="recentRuns.length">
<h2 class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Recent runs
</h2>
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Scenario</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Status</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Duration</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Counters</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Patient</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
<tr
v-for="run in recentRuns"
:key="run.runId"
:class="run.status === 'FAILED' ? 'bg-red-50/50 dark:bg-red-950/20' : ''"
>
<td class="px-4 py-3 text-gray-900 dark:text-white">
{{ run.scenarioName }}
<p
v-if="run.status === 'FAILED' && run.failureReason"
class="mt-1 text-xs text-red-600 dark:text-red-400"
>
{{ run.failureReason }}
</p>
</td>
<td class="px-4 py-3">
<Badge :variant="runStatusVariant(run.status)" size="xs">
{{ runStatusLabel(run.status) }}
</Badge>
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
{{ formatElapsedReal(run.elapsedRealSeconds) }}
<span class="text-gray-400">·</span>
{{ formatSpeedBadge(run.speed) }}
</td>
<td class="whitespace-nowrap px-4 py-3 text-gray-600 dark:text-gray-400">
{{ run.observationsSent ?? 0 }} obs ·
{{ run.medicationsSent ?? 0 }} med ·
{{ run.ordersPlaced ?? 0 }} ord
</td>
<td class="px-4 py-3">
<RouterLink
v-if="patientLink(run)"
:to="patientLink(run)"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
>
{{ run.patientDisplayName || 'Open' }}
</RouterLink>
<span v-else class="text-gray-400"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
@@ -9,6 +9,7 @@ import {
patientRoom, patientRoom,
stalenessClass, stalenessClass,
} from '@/composables/wardFormat' } from '@/composables/wardFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({ patient: { type: Object, required: true } }) const props = defineProps({ patient: { type: Object, required: true } })
@@ -23,8 +24,10 @@ const vitalsStaleness = computed(() =>
observationStaleness(props.patient.lastObservationAt, props.patient.status), observationStaleness(props.patient.lastObservationAt, props.patient.status),
) )
const simulated = computed(() => isSimulatedPatient(props.patient))
const cardLabel = computed(() => const cardLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`, `Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
) )
const emit = defineEmits(['activate']) const emit = defineEmits(['activate'])
@@ -53,6 +56,13 @@ function onKeydown(event) {
Room {{ patientRoom(patient) }} Room {{ patientRoom(patient) }}
</span> </span>
<Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge> <Badge v-if="patient.sepsisActive" variant="critical" size="xs">Sepsis</Badge>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
</div> </div>
<h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white"> <h3 class="mt-2 truncate text-base font-semibold text-gray-900 dark:text-white">
{{ patient.firstName }} {{ patient.lastName }} {{ patient.firstName }} {{ patient.lastName }}
@@ -10,6 +10,7 @@ import {
stalenessClass, stalenessClass,
} from '@/composables/wardFormat' } from '@/composables/wardFormat'
import { formatDepartment } from '@/composables/sepsisFormat' import { formatDepartment } from '@/composables/sepsisFormat'
import { isSimulatedPatient } from '@/api/normalize'
const props = defineProps({ const props = defineProps({
patient: { type: Object, required: true }, patient: { type: Object, required: true },
@@ -18,6 +19,8 @@ const props = defineProps({
const emit = defineEmits(['activate']) const emit = defineEmits(['activate'])
const simulated = computed(() => isSimulatedPatient(props.patient))
const riskVariant = computed(() => { const riskVariant = computed(() => {
const score = props.patient.news2Score ?? 0 const score = props.patient.news2Score ?? 0
if (score >= 7) return 'critical' if (score >= 7) return 'critical'
@@ -45,7 +48,7 @@ const vitalsStaleness = computed(() =>
) )
const rowLabel = computed(() => const rowLabel = computed(() =>
`Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}`, `Patient ${props.patient.firstName} ${props.patient.lastName}, room ${patientRoom(props.patient)}${simulated.value ? ', simulated' : ''}`,
) )
function onKeydown(event) { function onKeydown(event) {
@@ -68,8 +71,17 @@ function onKeydown(event) {
{{ patientRoom(patient) }} {{ patientRoom(patient) }}
</td> </td>
<td class="px-4 py-4"> <td class="px-4 py-4">
<div class="text-sm font-medium text-gray-900 dark:text-white"> <div class="flex flex-wrap items-center gap-2">
{{ patient.firstName }} {{ patient.lastName }} <div class="text-sm font-medium text-gray-900 dark:text-white">
{{ patient.firstName }} {{ patient.lastName }}
</div>
<span
v-if="simulated"
class="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
title="Simulated patient"
>
SIM
</span>
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div> <div class="text-xs text-gray-500 dark:text-gray-400">{{ patient.mrn }}</div>
</td> </td>
@@ -1,6 +1,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN'] const CLINICAL_ROLES = ['NURSE', 'PHYSICIAN', 'ADMIN']
@@ -10,6 +11,19 @@ export const OPS_NAV_LINK = {
icon: 'ops', icon: 'ops',
} }
export const SIMULATION_NAV_LINK = {
to: '/simulation',
label: 'Simulation',
icon: 'simulation',
roles: CLINICAL_ROLES,
}
/** Shorter label for the bottom bar at ~360 px. */
export const SIMULATION_MOBILE_NAV_LINK = {
...SIMULATION_NAV_LINK,
label: 'Sim',
}
export const MAIN_NAV_LINKS = [ export const MAIN_NAV_LINKS = [
{ to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES }, { to: '/ward', label: 'Virtual Ward', icon: 'ward', roles: CLINICAL_ROLES },
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES }, { to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
@@ -37,6 +51,8 @@ export const MOBILE_NAV_LINKS = [
{ to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES }, { to: '/admin/reconciliation', label: 'Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
] ]
const QUALITY_MOBILE_PATH = '/admin/reconciliation'
export function isDashboardRole(role) { export function isDashboardRole(role) {
return CLINICAL_ROLES.includes(role) return CLINICAL_ROLES.includes(role)
} }
@@ -60,6 +76,7 @@ export function filterNavLinks(links, role) {
export function useRoleAccess() { export function useRoleAccess() {
const auth = useAuthStore() const auth = useAuthStore()
const simulation = useSimulationStore()
const { role } = storeToRefs(auth) const { role } = storeToRefs(auth)
const isNurse = computed(() => role.value === 'NURSE') const isNurse = computed(() => role.value === 'NURSE')
@@ -69,11 +86,30 @@ export function useRoleAccess() {
const mainNavLinks = computed(() => { const mainNavLinks = computed(() => {
const links = filterNavLinks(MAIN_NAV_LINKS, role.value) const links = filterNavLinks(MAIN_NAV_LINKS, role.value)
if (simulation.enabled && CLINICAL_ROLES.includes(role.value)) {
links.push(SIMULATION_NAV_LINK)
}
if (canAccessOps(role.value)) links.push(OPS_NAV_LINK) if (canAccessOps(role.value)) links.push(OPS_NAV_LINK)
return links return links
}) })
const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value)) const adminNavLinks = computed(() => filterNavLinks(ADMIN_NAV_LINKS, role.value))
const mobileNavLinks = computed(() => filterNavLinks(MOBILE_NAV_LINKS, role.value))
// Keep the bottom bar usable at ~360 px: when simulation is on, Quality moves to overflow.
const mobileNavLinks = computed(() => {
const links = filterNavLinks(MOBILE_NAV_LINKS, role.value)
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return links
return [
...links.filter(l => l.to !== QUALITY_MOBILE_PATH),
SIMULATION_MOBILE_NAV_LINK,
]
})
const mobileOverflowLinks = computed(() => {
if (!simulation.enabled || !CLINICAL_ROLES.includes(role.value)) return []
return filterNavLinks(MOBILE_NAV_LINKS, role.value)
.filter(l => l.to === QUALITY_MOBILE_PATH)
})
const showAdminSection = computed(() => adminNavLinks.value.length > 0) const showAdminSection = computed(() => adminNavLinks.value.length > 0)
const nursePatientLayout = computed(() => isNurse.value) const nursePatientLayout = computed(() => isNurse.value)
@@ -88,6 +124,7 @@ export function useRoleAccess() {
mainNavLinks, mainNavLinks,
adminNavLinks, adminNavLinks,
mobileNavLinks, mobileNavLinks,
mobileOverflowLinks,
showAdminSection, showAdminSection,
nursePatientLayout, nursePatientLayout,
physicianPatientLayout, physicianPatientLayout,
@@ -0,0 +1,98 @@
/** Formats a minute count as a short clinician-facing duration. */
export function formatDurationMinutes(minutes) {
if (minutes == null || Number.isNaN(Number(minutes))) return '—'
const total = Math.max(0, Number(minutes))
if (total < 60) {
const m = Math.round(total)
return `${m} minute${m === 1 ? '' : 's'}`
}
const hoursExact = total / 60
if (Math.abs(hoursExact - Math.round(hoursExact)) < 0.05) {
const h = Math.round(hoursExact)
return `${h} hour${h === 1 ? '' : 's'}`
}
const h = Math.floor(total / 60)
const m = Math.round(total % 60)
if (m === 0) return `${h} hour${h === 1 ? '' : 's'}`
return `${h}h ${m}m`
}
/** Scenario duration pill: "8 hours of monitoring". */
export function formatMonitoringDuration(durationMinutes) {
return `${formatDurationMinutes(durationMinutes)} of monitoring`
}
/** Wall-clock time for a scenario at a given speed multiplier. */
export function formatWallClockForSpeed(durationMinutes, speed) {
if (durationMinutes == null || !speed) return '—'
return formatDurationMinutes(Number(durationMinutes) / Number(speed))
}
/** Simulated clock: "03:20 of 08:00 elapsed". */
export function formatSimClock(lastOffsetMinutes, totalOffsetMinutes) {
const fmt = (mins) => {
const total = Math.max(0, Math.floor(Number(mins) || 0))
const h = Math.floor(total / 60)
const m = total % 60
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
}
return `${fmt(lastOffsetMinutes)} of ${fmt(totalOffsetMinutes)} elapsed`
}
/** Wall-clock elapsed from elapsedRealSeconds. */
export function formatElapsedReal(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0))
const m = Math.floor(s / 60)
const rem = s % 60
if (m >= 60) {
const h = Math.floor(m / 60)
return `${h}h ${m % 60}m`
}
return `${m}:${String(rem).padStart(2, '0')}`
}
export function formatSpeedBadge(speed) {
if (speed == null) return '—'
return `${speed}×`
}
export function runStatusLabel(status) {
const map = {
PENDING: 'Pending',
RUNNING: 'Running',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
FAILED: 'Failed',
}
return map[status] ?? status ?? '—'
}
export function runStatusVariant(status) {
if (status === 'FAILED') return 'critical'
if (status === 'CANCELLED') return 'warning'
if (status === 'RUNNING') return 'success'
if (status === 'COMPLETED') return 'success'
return 'info'
}
const TAG_CHIP_CLASS = {
sepsis: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
control: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300',
'mimic-iv': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
'real-data': 'bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-300',
stable: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
baseline: 'bg-sky-100 text-sky-800 dark:bg-sky-900/30 dark:text-sky-300',
}
export function tagChipClass(tag) {
return TAG_CHIP_CLASS[tag]
?? 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}
export function isControlTag(tag) {
return tag === 'control'
}
export function isRealDataTag(tag) {
return tag === 'real-data' || tag === 'mimic-iv'
}
@@ -0,0 +1,25 @@
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
/**
* Feature-detects in-app simulation via GET /api/v1/simulation/config.
* Fail-closed: any error leaves simulationStore.enabled = false.
*/
export function useSimulationAvailable() {
const auth = useAuthStore()
const simulation = useSimulationStore()
const available = computed(() => simulation.enabled)
async function detect() {
if (!auth.isAuthenticated) {
simulation.enabled = false
return false
}
await simulation.loadConfig()
return simulation.enabled
}
return { available, detect }
}
+17 -1
View File
@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useSimulationStore } from '@/stores/simulation'
import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess' import { canAccessOps, defaultRouteForRole, isDashboardRole, roleCanAccessRoute } from '@/composables/roleAccess'
const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN'] const CLINICAL = ['NURSE', 'PHYSICIAN', 'ADMIN']
@@ -45,6 +46,12 @@ const routes = [
component: () => import('@/views/SepsisBoardView.vue'), component: () => import('@/views/SepsisBoardView.vue'),
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL }, meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
}, },
{
path: '/simulation',
name: 'SimulationControl',
component: () => import('@/views/SimulationControlView.vue'),
meta: { title: 'Simulation', layout: 'default', allowedRoles: CLINICAL, requiresSimulation: true },
},
{ {
path: '/admin/thresholds', path: '/admin/thresholds',
name: 'ThresholdManagement', name: 'ThresholdManagement',
@@ -92,7 +99,7 @@ const router = createRouter({
routes, routes,
}) })
router.beforeEach((to) => { router.beforeEach(async (to) => {
document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare` document.title = `${to.meta.title ?? 'VigilCare'} — VigilCare`
const auth = useAuthStore() const auth = useAuthStore()
@@ -108,6 +115,15 @@ router.beforeEach((to) => {
if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) { if (!to.meta.public && auth.isAuthenticated && !roleCanAccessRoute(auth.role, to.meta)) {
return { path: defaultRouteForRole(auth.role) } return { path: defaultRouteForRole(auth.role) }
} }
if (to.meta.requiresSimulation && auth.isAuthenticated) {
const simulation = useSimulationStore()
if (!simulation.enabled) {
await simulation.loadConfig()
}
if (!simulation.enabled) {
return { path: '/ward' }
}
}
}) })
export default router export default router
@@ -0,0 +1,237 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import {
fetchSimulationConfig,
fetchScenarios,
fetchRuns,
startRun,
stopRun,
} from '@/api/simulation'
import { useWardStore } from '@/stores/ward'
const ACTIVE_STATUSES = new Set(['PENDING', 'RUNNING'])
const POLL_INTERVAL_MS = 2_000
function isActiveStatus(status) {
return ACTIVE_STATUSES.has(status)
}
export const useSimulationStore = defineStore('simulation', () => {
const enabled = ref(false)
const maxSpeed = ref(null)
const maxConcurrentRuns = ref(0)
const scenarios = ref([])
const runs = ref([])
const loading = ref(false)
const error = ref(null)
const starting = ref(false)
let pollTimer = null
let stopActiveWatch = null
const activeRuns = computed(() =>
runs.value.filter(r => isActiveStatus(r.status)),
)
const recentRuns = computed(() =>
runs.value
.filter(r => !isActiveStatus(r.status))
.slice()
.sort((a, b) => new Date(b.startedAt) - new Date(a.startedAt))
.slice(0, 10),
)
const hasActiveRun = computed(() => activeRuns.value.length > 0)
const atConcurrencyLimit = computed(() =>
activeRuns.value.length >= maxConcurrentRuns.value,
)
const scenariosByTag = computed(() => {
const groups = {}
for (const scenario of scenarios.value) {
for (const tag of scenario.tags ?? []) {
if (!groups[tag]) groups[tag] = []
groups[tag].push(scenario)
}
}
return groups
})
function clearError() {
error.value = null
}
async function loadConfig() {
try {
const data = await fetchSimulationConfig()
enabled.value = !!data?.enabled
maxSpeed.value = data?.maxSpeed ?? null
maxConcurrentRuns.value = data?.maxConcurrentRuns ?? 0
} catch {
// Fail closed — never surface simulation controls on a broken probe.
enabled.value = false
maxSpeed.value = null
maxConcurrentRuns.value = 0
}
}
async function loadScenarios() {
loading.value = true
error.value = null
try {
const data = await fetchScenarios()
scenarios.value = Array.isArray(data) ? data : []
} catch (e) {
error.value = e.message
scenarios.value = []
} finally {
loading.value = false
}
}
function detectNewlyRunning(previousById, nextRuns) {
return nextRuns.some((run) => {
if (run.status !== 'RUNNING') return false
const prev = previousById.get(run.runId)
return prev !== 'RUNNING'
})
}
async function refreshRuns() {
try {
const previousById = new Map(runs.value.map(r => [r.runId, r.status]))
const data = await fetchRuns()
const next = Array.isArray(data) ? data : []
// Keep optimistic placeholders until the server echoes a real run.
const optimistic = runs.value.filter(r => r._optimistic)
const serverIds = new Set(next.map(r => r.runId))
const stillPending = optimistic.filter(r => !serverIds.has(r.runId))
runs.value = [...stillPending, ...next]
if (detectNewlyRunning(previousById, next)) {
useWardStore().loadEncounters()
}
} catch (e) {
error.value = e.message
}
}
async function start(scenarioId, speed) {
starting.value = true
error.value = null
const scenario = scenarios.value.find(s => s.id === scenarioId)
const placeholderId = `optimistic-${Date.now()}`
const placeholder = {
runId: placeholderId,
scenarioId,
scenarioName: scenario?.name ?? scenarioId,
status: 'PENDING',
speed,
patientId: null,
encounterId: null,
patientDisplayName: '',
startedAt: new Date().toISOString(),
elapsedRealSeconds: 0,
lastOffsetMinutes: 0,
totalOffsetMinutes: scenario?.durationMinutes ?? 0,
progressPercent: 0,
observationsSent: 0,
medicationsSent: 0,
ordersPlaced: 0,
failureReason: null,
_optimistic: true,
}
runs.value = [placeholder, ...runs.value]
try {
const run = await startRun({ scenarioId, speed })
runs.value = [
run,
...runs.value.filter(r => r.runId !== placeholderId && r.runId !== run.runId),
]
if (run.status === 'RUNNING') {
useWardStore().loadEncounters()
}
return run
} catch (e) {
runs.value = runs.value.filter(r => r.runId !== placeholderId)
error.value = e.message
throw e
} finally {
starting.value = false
}
}
async function stop(runId) {
error.value = null
try {
const run = await stopRun(runId)
const idx = runs.value.findIndex(r => r.runId === runId)
if (idx >= 0) {
runs.value[idx] = run
} else {
runs.value = [run, ...runs.value]
}
return run
} catch (e) {
error.value = e.message
throw e
}
}
function syncPollTimer(active) {
if (active) {
if (!pollTimer) {
pollTimer = setInterval(() => {
refreshRuns()
}, POLL_INTERVAL_MS)
}
} else if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
/** View-owned lifecycle: start watching active runs and poll while any are in flight. */
function startPolling() {
if (stopActiveWatch) return
stopActiveWatch = watch(hasActiveRun, (active) => {
syncPollTimer(active)
}, { immediate: true })
}
function stopPolling() {
if (stopActiveWatch) {
stopActiveWatch()
stopActiveWatch = null
}
syncPollTimer(false)
}
return {
enabled,
maxSpeed,
maxConcurrentRuns,
scenarios,
runs,
loading,
error,
starting,
activeRuns,
recentRuns,
hasActiveRun,
atConcurrencyLimit,
scenariosByTag,
clearError,
loadConfig,
loadScenarios,
refreshRuns,
start,
stop,
startPolling,
stopPolling,
}
})
@@ -0,0 +1,297 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useSimulationStore } from '@/stores/simulation'
import ScenarioCard from '@/components/simulation/ScenarioCard.vue'
import SimulationRunPanel from '@/components/simulation/SimulationRunPanel.vue'
import Button from '@/components/ui/Button.vue'
import Skeleton from '@/components/ui/Skeleton.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { formatWallClockForSpeed } from '@/composables/simulationFormat'
const SPEED_OPTIONS = [
{ label: 'Real time', multiplier: 1 },
{ label: 'Fast', multiplier: 60 },
{ label: 'Very fast', multiplier: 240 },
{ label: 'Instant', multiplier: 600 },
]
const simulationStore = useSimulationStore()
const {
scenarios,
activeRuns,
recentRuns,
loading,
error,
starting,
atConcurrencyLimit,
maxConcurrentRuns,
maxSpeed,
} = storeToRefs(simulationStore)
const selectedScenarioId = ref(null)
const searchQuery = ref('')
const activeTag = ref(null)
const speed = ref(60)
const stoppingId = ref(null)
const availableSpeeds = computed(() => {
const cap = maxSpeed.value
return SPEED_OPTIONS.map((opt) => {
const multiplier = cap != null ? Math.min(opt.multiplier, cap) : opt.multiplier
return { ...opt, multiplier }
}).filter((opt, index, arr) =>
// Drop Instant (or any option) if a lower option already uses the same capped value
index === arr.findIndex(o => o.multiplier === opt.multiplier),
)
})
const selectedScenario = computed(() =>
scenarios.value.find(s => s.id === selectedScenarioId.value)
?? filteredScenarios.value[0]
?? null,
)
const allTags = computed(() => {
const set = new Set()
for (const s of scenarios.value) {
for (const tag of s.tags ?? []) set.add(tag)
}
return [...set].sort()
})
const filteredScenarios = computed(() => {
const q = searchQuery.value.trim().toLowerCase()
return scenarios.value.filter((s) => {
if (activeTag.value && !(s.tags ?? []).includes(activeTag.value)) return false
if (!q) return true
const haystack = [
s.name,
s.description,
s.id,
s.department,
...(s.tags ?? []),
].filter(Boolean).join(' ').toLowerCase()
return haystack.includes(q)
})
})
function wallClockFor(multiplier) {
if (!selectedScenario.value?.durationMinutes) return null
return formatWallClockForSpeed(selectedScenario.value.durationMinutes, multiplier)
}
function concurrencyErrorMessage(raw) {
const n = maxConcurrentRuns.value
if (/concurrent|concurrency|409/i.test(raw ?? '')) {
return n > 0
? `Concurrency limit reached — at most ${n} simulation run${n === 1 ? '' : 's'} can run at once. Stop an active run first.`
: 'Concurrency limit reached. Stop an active run first.'
}
return raw
}
async function onStart(scenarioId) {
selectedScenarioId.value = scenarioId
try {
await simulationStore.start(scenarioId, speed.value)
} catch (e) {
simulationStore.error = concurrencyErrorMessage(e.message)
}
}
async function onStop(runId) {
stoppingId.value = runId
try {
await simulationStore.stop(runId)
} catch {
// error already on store
} finally {
stoppingId.value = null
}
}
function selectScenario(id) {
selectedScenarioId.value = id
}
function toggleTag(tag) {
activeTag.value = activeTag.value === tag ? null : tag
}
onMounted(async () => {
await Promise.all([
simulationStore.loadScenarios(),
simulationStore.refreshRuns(),
])
simulationStore.startPolling()
if (!selectedScenarioId.value && scenarios.value[0]) {
selectedScenarioId.value = scenarios.value[0].id
}
if (!availableSpeeds.value.some(o => o.multiplier === speed.value)) {
const preferred = availableSpeeds.value.find(o => o.multiplier === 60)
?? availableSpeeds.value[1]
?? availableSpeeds.value[0]
if (preferred) speed.value = preferred.multiplier
}
})
onBeforeUnmount(() => {
simulationStore.stopPolling()
})
</script>
<template>
<div class="space-y-8">
<div>
<h1 class="text-xl font-bold dark:text-white">Simulation</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Replay a recorded clinical scenario into this ward. All patients created here are simulated.
</p>
</div>
<div
v-if="error"
role="alert"
class="flex items-start justify-between gap-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-800 dark:bg-red-950/40 dark:text-red-200"
>
<p>{{ error }}</p>
<button
type="button"
class="shrink-0 font-medium underline"
@click="simulationStore.clearError()"
>
Dismiss
</button>
</div>
<SimulationRunPanel
v-if="activeRuns.length || recentRuns.length"
:active-runs="activeRuns"
:recent-runs="recentRuns"
:stopping-id="stoppingId"
@stop="onStop"
/>
<section aria-labelledby="sim-speed-heading">
<h2
id="sim-speed-heading"
class="mb-3 text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Replay speed
</h2>
<div
class="inline-flex max-w-full flex-wrap rounded-lg border border-gray-200 bg-white p-1 dark:border-gray-700 dark:bg-gray-900"
role="group"
aria-label="Replay speed"
>
<button
v-for="opt in availableSpeeds"
:key="opt.label"
type="button"
class="min-h-11 rounded-md px-4 py-2 text-left text-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
:class="speed === opt.multiplier
? 'bg-blue-600 text-white dark:bg-blue-500'
: 'text-gray-700 hover:bg-gray-50 dark:text-gray-300 dark:hover:bg-gray-800'"
:aria-pressed="speed === opt.multiplier"
@click="speed = opt.multiplier"
>
<span class="font-medium">{{ opt.label }}</span>
<span
class="mt-0.5 block text-xs"
:class="speed === opt.multiplier ? 'text-blue-100' : 'text-gray-500 dark:text-gray-400'"
>
<template v-if="wallClockFor(opt.multiplier)">
{{ wallClockFor(opt.multiplier) }} for selected scenario
</template>
<template v-else>
{{ opt.multiplier }}×
</template>
</span>
</button>
</div>
<p v-if="selectedScenario" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
Durations above use
<span class="font-medium text-gray-700 dark:text-gray-300">{{ selectedScenario.name }}</span>
({{ selectedScenario.durationMinutes }} simulated minutes).
</p>
</section>
<section aria-labelledby="sim-catalogue-heading">
<div class="mb-4 flex flex-wrap items-end justify-between gap-4">
<h2
id="sim-catalogue-heading"
class="text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Scenario catalogue
</h2>
</div>
<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 scenarios</span>
<input
v-model="searchQuery"
type="search"
placeholder="Search scenarios by name, tag, or department…"
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"
>
</label>
<div v-if="allTags.length" class="flex flex-wrap items-center gap-2">
<Button
v-for="tag in allTags"
:key="tag"
size="sm"
:variant="activeTag === tag ? 'primary' : 'secondary'"
@click="toggleTag(tag)"
>
{{ tag }}
</Button>
<Button
v-if="activeTag"
size="sm"
variant="ghost"
@click="activeTag = null"
>
Clear filter
</Button>
</div>
</div>
<Skeleton v-if="loading && scenarios.length === 0" :rows="3" />
<EmptyState
v-else-if="scenarios.length === 0"
message="No scenarios found. Check that Simulation:ScenarioDirectory is configured and contains scenario JSON files."
/>
<EmptyState
v-else-if="filteredScenarios.length === 0"
message="No scenarios match the current search or tag filter."
/>
<div
v-else
class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"
role="list"
>
<div
v-for="scenario in filteredScenarios"
:key="scenario.id"
role="listitem"
>
<ScenarioCard
:scenario="scenario"
:selected="selectedScenarioId === scenario.id"
:at-concurrency-limit="atConcurrencyLimit"
:max-concurrent-runs="maxConcurrentRuns"
:starting="starting"
@select="selectScenario"
@start="onStart"
/>
</div>
</div>
</section>
</div>
</template>