feature: Clinical Review Mode: Charts, Replay Controls & Alert Reasoning
This commit is contained in:
Generated
+331
@@ -0,0 +1,331 @@
|
|||||||
|
{
|
||||||
|
"name": "VigilCare.Simulator",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"vue-chartjs": "^5.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-string-parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/helper-validator-identifier": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/parser": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/types": "^7.29.7"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"parser": "bin/babel-parser.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/types": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/helper-string-parser": "^7.29.7",
|
||||||
|
"@babel/helper-validator-identifier": "^7.29.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-core": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.7",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"entities": "^7.0.1",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-dom": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-core": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-sfc": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.29.7",
|
||||||
|
"@vue/compiler-core": "3.5.38",
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"estree-walker": "^2.0.2",
|
||||||
|
"magic-string": "^0.30.21",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/compiler-ssr": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/reactivity": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-core": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/runtime-dom": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/reactivity": "3.5.38",
|
||||||
|
"@vue/runtime-core": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38",
|
||||||
|
"csstype": "^3.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/server-renderer": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-ssr": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "3.5.38"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue/shared": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/estree-walker": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/magic-string": {
|
||||||
|
"version": "0.30.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
|
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz",
|
||||||
|
"integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.12",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue": {
|
||||||
|
"version": "3.5.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
|
||||||
|
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@vue/compiler-dom": "3.5.38",
|
||||||
|
"@vue/compiler-sfc": "3.5.38",
|
||||||
|
"@vue/runtime-dom": "3.5.38",
|
||||||
|
"@vue/server-renderer": "3.5.38",
|
||||||
|
"@vue/shared": "3.5.38"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"vue-chartjs": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+30
@@ -9,10 +9,12 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -744,6 +746,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -2299,6 +2307,18 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/check-error": {
|
"node_modules/check-error": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||||
@@ -4363,6 +4383,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-component-type-helpers": {
|
"node_modules/vue-component-type-helpers": {
|
||||||
"version": "3.3.5",
|
"version": "3.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz",
|
||||||
|
|||||||
@@ -12,10 +12,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.3.0",
|
"@vueuse/core": "^14.3.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"vue": "^3.5.34",
|
"vue": "^3.5.34",
|
||||||
|
"vue-chartjs": "^5.3.3",
|
||||||
"vue-router": "^4.6.4"
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||||
|
|
||||||
|
const sepsisAlert = {
|
||||||
|
id: 'alert-1',
|
||||||
|
alertType: 'SepsisWarning',
|
||||||
|
severity: 'Critical',
|
||||||
|
details: 'SIRS criteria met',
|
||||||
|
triggeredAt: '2026-06-19T12:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const qsofaAlert = {
|
||||||
|
id: 'alert-2',
|
||||||
|
alertType: 'QsofaWarning',
|
||||||
|
severity: 'Warning',
|
||||||
|
details: 'qSOFA score elevated',
|
||||||
|
triggeredAt: '2026-06-19T12:30:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AlertReasoning', () => {
|
||||||
|
it('showsExplanationForSepsisWarning', () => {
|
||||||
|
const wrapper = mount(AlertReasoning, { props: { alert: sepsisAlert } })
|
||||||
|
expect(wrapper.text()).toContain('SIRS / Sepsis Alert')
|
||||||
|
expect(wrapper.text()).toContain('≥2 of 4 SIRS criteria met')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showsExplanationForQsofa', () => {
|
||||||
|
const wrapper = mount(AlertReasoning, { props: { alert: qsofaAlert } })
|
||||||
|
expect(wrapper.text()).toContain('qSOFA Alert')
|
||||||
|
expect(wrapper.text()).toContain('≥2 of 3 qSOFA criteria met')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('showsRawDetailsForUnknownType', () => {
|
||||||
|
const unknown = {
|
||||||
|
id: 'alert-3',
|
||||||
|
alertType: 'CustomUnknown',
|
||||||
|
severity: 'Warning',
|
||||||
|
details: 'Something unusual happened',
|
||||||
|
triggeredAt: '2026-06-19T13:00:00Z',
|
||||||
|
}
|
||||||
|
const wrapper = mount(AlertReasoning, { props: { alert: unknown } })
|
||||||
|
expect(wrapper.text()).toContain('CustomUnknown')
|
||||||
|
expect(wrapper.text()).toContain('Something unusual happened')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { defineComponent } from 'vue'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { useChartData } from '@/composables/useChartData'
|
||||||
|
|
||||||
|
function mountChartData(observations, code) {
|
||||||
|
let exposed
|
||||||
|
const Comp = defineComponent({
|
||||||
|
setup() {
|
||||||
|
exposed = useChartData(observations, code)
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mount(Comp)
|
||||||
|
return exposed
|
||||||
|
}
|
||||||
|
|
||||||
|
const observations = [
|
||||||
|
{ observationCode: 'HEART_RATE', value: 80, recordedAt: '2026-06-19T12:00:00Z' },
|
||||||
|
{ observationCode: 'RESP_RATE', value: 18, recordedAt: '2026-06-19T12:00:00Z' },
|
||||||
|
{ observationCode: 'HEART_RATE', value: 72, recordedAt: '2026-06-19T11:00:00Z' },
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('useChartData', () => {
|
||||||
|
it('filtersObservationsByCode', () => {
|
||||||
|
const { chartData } = mountChartData(observations, 'HEART_RATE')
|
||||||
|
expect(chartData.value.datasets[0].data).toEqual([72, 80])
|
||||||
|
expect(chartData.value.labels).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sortsChronologically', () => {
|
||||||
|
const { chartData } = mountChartData(observations, 'HEART_RATE')
|
||||||
|
expect(chartData.value.datasets[0].data[0]).toBe(72)
|
||||||
|
expect(chartData.value.datasets[0].data[1]).toBe(80)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handlesEmptyData', () => {
|
||||||
|
const { chartData } = mountChartData([], 'HEART_RATE')
|
||||||
|
expect(chartData.value.labels).toEqual([])
|
||||||
|
expect(chartData.value.datasets[0].data).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { useReplayControls } from '@/composables/useReplayControls'
|
||||||
|
|
||||||
|
describe('useReplayControls', () => {
|
||||||
|
it('pauseAndResume', () => {
|
||||||
|
const { isPaused, pause, resume } = useReplayControls()
|
||||||
|
expect(isPaused.value).toBe(false)
|
||||||
|
pause()
|
||||||
|
expect(isPaused.value).toBe(true)
|
||||||
|
resume()
|
||||||
|
expect(isPaused.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('speedPresets', () => {
|
||||||
|
const { speed, setSpeed } = useReplayControls()
|
||||||
|
expect(speed.value).toBe(60)
|
||||||
|
setSpeed(360)
|
||||||
|
expect(speed.value).toBe(360)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('progressComputation', () => {
|
||||||
|
const { currentOffsetMinutes, scenarioDurationMinutes, progress } = useReplayControls()
|
||||||
|
scenarioDurationMinutes.value = 100
|
||||||
|
currentOffsetMinutes.value = 50
|
||||||
|
expect(progress.value).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('jumpToOffset', () => {
|
||||||
|
const { currentOffsetMinutes, jumpToOffset } = useReplayControls()
|
||||||
|
jumpToOffset(42)
|
||||||
|
expect(currentOffsetMinutes.value).toBe(42)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -14,4 +14,32 @@ export function fetchSepsisBundle(encounterId) {
|
|||||||
|
|
||||||
export function fetchOrders(encounterId) {
|
export function fetchOrders(encounterId) {
|
||||||
return api.get(`/api/v1/encounters/${encounterId}/orders`)
|
return api.get(`/api/v1/encounters/${encounterId}/orders`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNews2History(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}/news2/history?${params}`)
|
||||||
|
all.push(...(page.items ?? []))
|
||||||
|
cursor = page.hasMore ? page.nextCursor : null
|
||||||
|
} while (cursor)
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMedications(encounterId, { pageSize = 50, since } = {}) {
|
||||||
|
const all = []
|
||||||
|
let page = 1
|
||||||
|
let totalPages = 1
|
||||||
|
do {
|
||||||
|
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
|
||||||
|
if (since) params.set('since', since)
|
||||||
|
const result = await api.get(`/api/v1/encounters/${encounterId}/medications?${params}`)
|
||||||
|
all.push(...(result.items ?? []))
|
||||||
|
totalPages = result.totalPages ?? 1
|
||||||
|
page++
|
||||||
|
} while (page <= totalPages)
|
||||||
|
return all
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Badge from '@/components/ui/Badge.vue'
|
||||||
|
import Card from '@/components/ui/Card.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
alert: { type: Object, required: true },
|
||||||
|
medications: { type: Array, default: () => [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
const CORRELATION_WINDOW_MS = 90 * 60 * 1000
|
||||||
|
|
||||||
|
const reasoningMap = {
|
||||||
|
WarningHeartRate: { label: 'Heart Rate Warning', explain: (a) => `Heart rate ${extractValue(a)} is in the warning range (91–110 bpm or 41–50 bpm).` },
|
||||||
|
WarningSystolicBp: { label: 'Systolic BP Warning', explain: (a) => `Systolic BP ${extractValue(a)} is in the warning range (101–110 mmHg).` },
|
||||||
|
WarningTempC: { label: 'Temperature Warning', explain: (a) => `Temperature ${extractValue(a)} is in the warning range.` },
|
||||||
|
SepsisWarning: { label: 'SIRS / Sepsis Alert', explain: () => '≥2 of 4 SIRS criteria met: temperature, heart rate, respiratory rate, WBC.' },
|
||||||
|
QsofaWarning: { label: 'qSOFA Alert', explain: () => '≥2 of 3 qSOFA criteria met: RR ≥22, SBP ≤100, altered mentation (AVPU ≥1).' },
|
||||||
|
News2Warning: { label: 'NEWS2 Medium Risk', explain: () => 'NEWS2 composite score 5–6 indicating medium clinical risk.' },
|
||||||
|
News2Emergency: { label: 'NEWS2 High Risk', explain: () => 'NEWS2 composite score ≥7 or any single parameter scored 3.' },
|
||||||
|
RapidDeterioration: { label: 'Rapid Deterioration', explain: () => 'Vital sign trajectory shows rapid change within the sliding window.' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentMedications = computed(() => {
|
||||||
|
if (!props.medications.length || !props.alert.triggeredAt) return []
|
||||||
|
const alertTime = new Date(props.alert.triggeredAt).getTime()
|
||||||
|
return props.medications.filter(m => {
|
||||||
|
const administeredAt = new Date(m.administeredAt).getTime()
|
||||||
|
const delta = alertTime - administeredAt
|
||||||
|
return delta >= 0 && delta <= CORRELATION_WINDOW_MS
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function extractValue(alert) {
|
||||||
|
const match = alert.details?.match(/(\d+\.?\d*)/)
|
||||||
|
return match ? match[1] : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMed(med) {
|
||||||
|
return `${med.drugName} ${med.dose}${med.doseUnit} (${med.route})`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge :variant="alert.severity === 'Critical' ? 'critical' : 'warning'">
|
||||||
|
{{ alert.severity }}
|
||||||
|
</Badge>
|
||||||
|
<h3 class="font-medium dark:text-white">
|
||||||
|
{{ reasoningMap[alert.alertType]?.label ?? alert.alertType }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{{ reasoningMap[alert.alertType]?.explain(alert) ?? alert.details }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="recentMedications.length"
|
||||||
|
class="rounded bg-amber-50 p-4 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
<p class="mb-2 font-medium">Recent medications (within 90 min)</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li v-for="med in recentMedications" :key="med.id">
|
||||||
|
{{ formatMed(med) }} — {{ new Date(med.administeredAt).toLocaleString() }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="alert.details" class="rounded bg-gray-50 p-4 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||||
|
{{ alert.details }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
Triggered at {{ new Date(alert.triggeredAt).toLocaleString() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, shallowRef, markRaw } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import { Chart, registerables } from 'chart.js'
|
||||||
|
import { formatTime } from '@/composables/chartFormat'
|
||||||
|
|
||||||
|
Chart.register(...registerables)
|
||||||
|
|
||||||
|
const props = defineProps({ history: { type: Array, required: true } })
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.history.map(h => formatTime(h.calculatedAt)),
|
||||||
|
datasets: [{
|
||||||
|
label: 'NEWS2',
|
||||||
|
data: props.history.map(h => h.totalScore),
|
||||||
|
borderColor: '#3b82f6',
|
||||||
|
backgroundColor: props.history.map(h => {
|
||||||
|
if (h.totalScore >= 7) return 'rgba(220, 38, 38, 0.3)'
|
||||||
|
if (h.totalScore >= 5) return 'rgba(245, 158, 11, 0.3)'
|
||||||
|
return 'rgba(34, 197, 94, 0.3)'
|
||||||
|
}),
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
}],
|
||||||
|
}))
|
||||||
|
|
||||||
|
const chartOptions = shallowRef(markRaw({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { min: 0, max: 20, title: { display: true, text: 'NEWS2 Score' } },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</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-4 text-sm font-medium text-gray-700 dark:text-gray-300">NEWS2 Score Over Time</h3>
|
||||||
|
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup>
|
||||||
|
import VitalTrendChart from './VitalTrendChart.vue'
|
||||||
|
|
||||||
|
defineProps({ observations: { type: Array, required: true } })
|
||||||
|
|
||||||
|
const charts = [
|
||||||
|
{ code: 'HEART_RATE', title: 'Heart Rate (bpm)', yMin: 30, yMax: 180 },
|
||||||
|
{ code: 'RESP_RATE', title: 'Respiratory Rate (/min)', yMin: 0, yMax: 40 },
|
||||||
|
{ code: 'SYSTOLIC_BP', title: 'Systolic BP (mmHg)', yMin: 60, yMax: 250 },
|
||||||
|
{ code: 'SPO2', title: 'SpO₂ (%)', yMin: 80, yMax: 100 },
|
||||||
|
{ code: 'TEMP_C', title: 'Temperature (°C)', yMin: 34, yMax: 42 },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid w-full min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<VitalTrendChart
|
||||||
|
v-for="chart in charts"
|
||||||
|
:key="chart.code"
|
||||||
|
:observations="observations"
|
||||||
|
:code="chart.code"
|
||||||
|
:title="chart.title"
|
||||||
|
:y-min="chart.yMin"
|
||||||
|
:y-max="chart.yMax"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, toValue, shallowRef, markRaw } from 'vue'
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import { Chart, registerables } from 'chart.js'
|
||||||
|
|
||||||
|
Chart.register(...registerables)
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
chartData: { type: Object, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
yMin: { type: Number, default: undefined },
|
||||||
|
yMax: { type: Number, default: undefined },
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineData = computed(() => {
|
||||||
|
const data = toValue(props.chartData)
|
||||||
|
return data?.datasets ? data : { labels: [], datasets: [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = shallowRef(markRaw({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: true,
|
||||||
|
animation: {
|
||||||
|
duration: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 400,
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: { min: props.yMin, max: props.yMax },
|
||||||
|
x: { ticks: { maxTicksAuto: true, maxRotation: 45 } },
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
</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-4 text-sm font-medium text-gray-700 dark:text-gray-300">{{ title }}</h3>
|
||||||
|
<div class="aspect-video w-full min-h-0 overflow-hidden">
|
||||||
|
<Line :data="lineData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useChartData } from '@/composables/useChartData'
|
||||||
|
import VitalChart from './VitalChart.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
observations: { type: Array, required: true },
|
||||||
|
code: { type: String, required: true },
|
||||||
|
title: { type: String, required: true },
|
||||||
|
yMin: { type: Number, default: undefined },
|
||||||
|
yMax: { type: Number, default: undefined },
|
||||||
|
})
|
||||||
|
|
||||||
|
const { chartData } = useChartData(() => props.observations, props.code)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VitalChart
|
||||||
|
:chart-data="chartData"
|
||||||
|
:title="title"
|
||||||
|
:y-min="yMin"
|
||||||
|
:y-max="yMax"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -11,8 +11,11 @@ import { alertTypeLabel } from '@/api/normalize'
|
|||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
encounterId: { type: String, required: true },
|
encounterId: { type: String, required: true },
|
||||||
|
selectedId: { type: String, default: null },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['select'])
|
||||||
|
|
||||||
const alertStore = useAlertStore()
|
const alertStore = useAlertStore()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const { alerts, loading } = storeToRefs(alertStore)
|
const { alerts, loading } = storeToRefs(alertStore)
|
||||||
@@ -51,7 +54,9 @@ async function resolve(alertId) {
|
|||||||
<li
|
<li
|
||||||
v-for="alert in alerts"
|
v-for="alert in alerts"
|
||||||
:key="alert.id"
|
:key="alert.id"
|
||||||
class="flex flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
class="flex cursor-pointer flex-col gap-4 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-start sm:justify-between"
|
||||||
|
:class="selectedId === alert.id ? 'bg-blue-50/50 dark:bg-blue-950/20' : ''"
|
||||||
|
@click="emit('select', alert)"
|
||||||
>
|
>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useReplayControls } from '@/composables/useReplayControls'
|
||||||
|
import Button from '@/components/ui/Button.vue'
|
||||||
|
|
||||||
|
const { isPaused, speed, progress, formattedTime, pause, resume, setSpeed } = useReplayControls()
|
||||||
|
|
||||||
|
defineProps({ alerts: { type: Array, default: () => [] } })
|
||||||
|
const emit = defineEmits(['jump-to-alert'])
|
||||||
|
|
||||||
|
const speedPresets = [
|
||||||
|
{ label: '1×', value: 1 },
|
||||||
|
{ label: '60×', value: 60 },
|
||||||
|
{ label: '360×', value: 360 },
|
||||||
|
{ label: 'Instant', value: 0 },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex w-full min-w-0 flex-col gap-4 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900 sm:flex-row sm:items-center">
|
||||||
|
<Button variant="ghost" size="sm" @click="isPaused ? resume() : pause()">
|
||||||
|
<span class="sr-only">{{ isPaused ? 'Resume' : 'Pause' }}</span>
|
||||||
|
{{ isPaused ? '▶' : '⏸' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
|
<div class="h-full rounded-full bg-blue-500 transition-all duration-300"
|
||||||
|
:style="{ width: `${progress}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="min-w-16 text-center text-sm font-mono text-gray-600 dark:text-gray-400">
|
||||||
|
{{ formattedTime }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
v-for="preset in speedPresets"
|
||||||
|
:key="preset.value"
|
||||||
|
class="rounded px-2 py-2 text-xs transition"
|
||||||
|
:class="speed === preset.value
|
||||||
|
? 'bg-blue-500 text-white'
|
||||||
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
|
||||||
|
@click="setSpeed(preset.value)"
|
||||||
|
>
|
||||||
|
{{ preset.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="alerts.length" class="border-t border-gray-200 pt-4 sm:border-t-0 sm:border-l sm:pl-4 sm:pt-0 dark:border-gray-700">
|
||||||
|
<Button variant="ghost" size="sm" @click="$emit('jump-to-alert')">
|
||||||
|
Next Alert →
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export function formatTime(iso) {
|
||||||
|
if (!iso) return ''
|
||||||
|
return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE_COLORS = {
|
||||||
|
HEART_RATE: '#ef4444',
|
||||||
|
RESP_RATE: '#3b82f6',
|
||||||
|
SYSTOLIC_BP: '#8b5cf6',
|
||||||
|
SPO2: '#06b6d4',
|
||||||
|
TEMP_C: '#f59e0b',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getColorForCode(code) {
|
||||||
|
return CODE_COLORS[code] ?? '#6b7280'
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { formatTime, getColorForCode } from './chartFormat'
|
||||||
|
|
||||||
|
export function useChartData(observations, code) {
|
||||||
|
const source = computed(() =>
|
||||||
|
(typeof observations === 'function' ? observations() : observations) ?? [])
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const filtered = source.value
|
||||||
|
.filter(o => o.observationCode === code)
|
||||||
|
.sort((a, b) => new Date(a.recordedAt) - new Date(b.recordedAt))
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: filtered.map(o => formatTime(o.recordedAt)),
|
||||||
|
datasets: [{
|
||||||
|
label: code,
|
||||||
|
data: filtered.map(o => o.value),
|
||||||
|
borderColor: getColorForCode(code),
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return { chartData }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
export function useReplayControls() {
|
||||||
|
const isPaused = ref(false)
|
||||||
|
const speed = ref(60)
|
||||||
|
const currentOffsetMinutes = ref(0)
|
||||||
|
const scenarioDurationMinutes = ref(0)
|
||||||
|
|
||||||
|
const progress = computed(() =>
|
||||||
|
scenarioDurationMinutes.value > 0
|
||||||
|
? (currentOffsetMinutes.value / scenarioDurationMinutes.value) * 100
|
||||||
|
: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
const h = Math.floor(currentOffsetMinutes.value / 60)
|
||||||
|
const m = Math.floor(currentOffsetMinutes.value % 60)
|
||||||
|
return `${h}:${String(m).padStart(2, '0')}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function pause() { isPaused.value = true }
|
||||||
|
function resume() { isPaused.value = false }
|
||||||
|
function setSpeed(s) { speed.value = s }
|
||||||
|
|
||||||
|
function jumpToOffset(offset) {
|
||||||
|
currentOffsetMinutes.value = offset
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isPaused, speed, currentOffsetMinutes, scenarioDurationMinutes, progress, formattedTime, pause, resume, setSpeed, jumpToOffset }
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
import { usePolling } from '@/composables/usePolling'
|
import { usePolling } from '@/composables/usePolling'
|
||||||
|
import { useAlertStore } from '@/stores/alerts'
|
||||||
import * as encountersApi from '@/api/encounters'
|
import * as encountersApi from '@/api/encounters'
|
||||||
import * as clinicalApi from '@/api/clinical'
|
import * as clinicalApi from '@/api/clinical'
|
||||||
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
|
import VitalsPanel from '@/components/patient/VitalsPanel.vue'
|
||||||
@@ -9,30 +11,49 @@ import ScoresPanel from '@/components/patient/ScoresPanel.vue'
|
|||||||
import AlertsList from '@/components/patient/AlertsList.vue'
|
import AlertsList from '@/components/patient/AlertsList.vue'
|
||||||
import OrdersPanel from '@/components/patient/OrdersPanel.vue'
|
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 News2History from '@/components/charts/News2History.vue'
|
||||||
|
import ReplayControls from '@/components/replay/ReplayControls.vue'
|
||||||
|
import AlertReasoning from '@/components/alerts/AlertReasoning.vue'
|
||||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const alertStore = useAlertStore()
|
||||||
|
const { alerts } = storeToRefs(alertStore)
|
||||||
|
|
||||||
const encounter = ref(null)
|
const encounter = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const observations = ref([])
|
const observations = ref([])
|
||||||
const news2 = ref(null)
|
const news2 = ref(null)
|
||||||
|
const news2History = ref([])
|
||||||
|
const medications = ref([])
|
||||||
const sepsisBundle = ref(null)
|
const sepsisBundle = ref(null)
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
|
const selectedAlert = ref(null)
|
||||||
|
let nextAlertIndex = 0
|
||||||
|
|
||||||
|
const openAlerts = computed(() =>
|
||||||
|
alerts.value.filter(a => a.status === 'Open' || a.status === 'Escalated'),
|
||||||
|
)
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const id = route.params.encounterId
|
const id = route.params.encounterId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [enc, obs, n2, bundle, ord] = await Promise.all([
|
const [enc, obs, n2, history, meds, bundle, ord] = await Promise.all([
|
||||||
encountersApi.fetchEncounter(id),
|
encountersApi.fetchEncounter(id),
|
||||||
encountersApi.fetchObservations(id),
|
encountersApi.fetchObservations(id),
|
||||||
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
clinicalApi.fetchCurrentNews2(id).catch(() => null),
|
||||||
|
clinicalApi.fetchNews2History(id).catch(() => []),
|
||||||
|
clinicalApi.fetchMedications(id).catch(() => []),
|
||||||
clinicalApi.fetchSepsisBundle(id).catch(() => null),
|
clinicalApi.fetchSepsisBundle(id).catch(() => null),
|
||||||
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
clinicalApi.fetchOrders(id).catch(() => ({ items: [] })),
|
||||||
])
|
])
|
||||||
encounter.value = enc
|
encounter.value = enc
|
||||||
observations.value = obs
|
observations.value = obs
|
||||||
news2.value = n2
|
news2.value = n2
|
||||||
|
news2History.value = history
|
||||||
|
medications.value = meds
|
||||||
sepsisBundle.value = bundle
|
sepsisBundle.value = bundle
|
||||||
orders.value = ord.items ?? ord
|
orders.value = ord.items ?? ord
|
||||||
} finally {
|
} finally {
|
||||||
@@ -40,14 +61,32 @@ async function loadAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onSelectAlert(alert) {
|
||||||
|
selectedAlert.value = alert
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToNextAlert() {
|
||||||
|
const sorted = [...openAlerts.value].sort(
|
||||||
|
(a, b) => new Date(a.triggeredAt) - new Date(b.triggeredAt),
|
||||||
|
)
|
||||||
|
if (!sorted.length) return
|
||||||
|
selectedAlert.value = sorted[nextAlertIndex % sorted.length]
|
||||||
|
nextAlertIndex++
|
||||||
|
document.getElementById('clinical-review')?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
|
||||||
usePolling(loadAll, 5_000)
|
usePolling(loadAll, 5_000)
|
||||||
|
|
||||||
watch(() => route.params.encounterId, loadAll)
|
watch(() => route.params.encounterId, () => {
|
||||||
|
selectedAlert.value = null
|
||||||
|
nextAlertIndex = 0
|
||||||
|
loadAll()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Skeleton v-if="loading && !encounter" :rows="6" />
|
<Skeleton v-if="loading && !encounter" :rows="6" />
|
||||||
<div v-else-if="encounter" class="space-y-8">
|
<div v-else-if="encounter" class="w-full min-w-0 space-y-8">
|
||||||
<div class="flex flex-wrap items-center gap-4">
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<RouterLink to="/ward" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
← Ward
|
← Ward
|
||||||
@@ -57,15 +96,31 @@ watch(() => route.params.encounterId, loadAll)
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid 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">
|
||||||
<ScoresPanel :news2="news2" :encounter="encounter" />
|
<ScoresPanel :news2="news2" :encounter="encounter" />
|
||||||
<VitalsPanel :observations="observations" />
|
<VitalsPanel :observations="observations" />
|
||||||
<AlertsList :encounter-id="route.params.encounterId" />
|
<AlertsList
|
||||||
|
:encounter-id="route.params.encounterId"
|
||||||
|
:selected-id="selectedAlert?.id"
|
||||||
|
@select="onSelectAlert"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<AlertReasoning
|
||||||
|
v-if="selectedAlert"
|
||||||
|
:alert="selectedAlert"
|
||||||
|
:medications="medications"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="grid min-w-0 gap-4 lg:grid-cols-2">
|
||||||
<OrdersPanel :orders="orders" />
|
<OrdersPanel :orders="orders" />
|
||||||
<SepsisBundlePanel v-if="sepsisBundle" :bundle="sepsisBundle" />
|
<SepsisBundlePanel v-if="sepsisBundle" :bundle="sepsisBundle" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="clinical-review" class="w-full min-w-0 space-y-8">
|
||||||
|
<TrendsGrid :observations="observations" />
|
||||||
|
<News2History v-if="news2History.length" :history="news2History" />
|
||||||
|
<ReplayControls :alerts="openAlerts" @jump-to-alert="jumpToNextAlert" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user