From ebd53f2df6da5b637bcfb7dbcea76e07cd125116 Mon Sep 17 00:00:00 2001 From: voltsrage Date: Sat, 20 Jun 2026 14:17:58 +0800 Subject: [PATCH] feature: Clinical Review Mode: Charts, Replay Controls & Alert Reasoning --- VigilCare.Simulator/package-lock.json | 331 ++++++++++++++++++ VigilCare.Simulator/package.json | 6 + vigilcare-dashboard/package-lock.json | 30 ++ vigilcare-dashboard/package.json | 2 + .../src/__tests__/AlertReasoning.test.js | 46 +++ .../src/__tests__/useChartData.test.js | 42 +++ .../src/__tests__/useReplayControls.test.js | 33 ++ vigilcare-dashboard/src/api/clinical.js | 28 ++ .../src/components/alerts/AlertReasoning.vue | 81 +++++ .../src/components/charts/News2History.vue | 50 +++ .../src/components/charts/TrendsGrid.vue | 27 ++ .../src/components/charts/VitalChart.vue | 43 +++ .../src/components/charts/VitalTrendChart.vue | 23 ++ .../src/components/patient/AlertsList.vue | 7 +- .../src/components/replay/ReplayControls.vue | 56 +++ .../src/composables/chartFormat.js | 16 + .../src/composables/useChartData.js | 26 ++ .../src/composables/useReplayControls.js | 30 ++ .../src/views/PatientDetail.vue | 71 +++- 19 files changed, 939 insertions(+), 9 deletions(-) create mode 100644 VigilCare.Simulator/package-lock.json create mode 100644 VigilCare.Simulator/package.json create mode 100644 vigilcare-dashboard/src/__tests__/AlertReasoning.test.js create mode 100644 vigilcare-dashboard/src/__tests__/useChartData.test.js create mode 100644 vigilcare-dashboard/src/__tests__/useReplayControls.test.js create mode 100644 vigilcare-dashboard/src/components/alerts/AlertReasoning.vue create mode 100644 vigilcare-dashboard/src/components/charts/News2History.vue create mode 100644 vigilcare-dashboard/src/components/charts/TrendsGrid.vue create mode 100644 vigilcare-dashboard/src/components/charts/VitalChart.vue create mode 100644 vigilcare-dashboard/src/components/charts/VitalTrendChart.vue create mode 100644 vigilcare-dashboard/src/components/replay/ReplayControls.vue create mode 100644 vigilcare-dashboard/src/composables/chartFormat.js create mode 100644 vigilcare-dashboard/src/composables/useChartData.js create mode 100644 vigilcare-dashboard/src/composables/useReplayControls.js diff --git a/VigilCare.Simulator/package-lock.json b/VigilCare.Simulator/package-lock.json new file mode 100644 index 0000000..b2dc262 --- /dev/null +++ b/VigilCare.Simulator/package-lock.json @@ -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" + } + } + } +} diff --git a/VigilCare.Simulator/package.json b/VigilCare.Simulator/package.json new file mode 100644 index 0000000..74bc07b --- /dev/null +++ b/VigilCare.Simulator/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "chart.js": "^4.5.1", + "vue-chartjs": "^5.3.3" + } +} diff --git a/vigilcare-dashboard/package-lock.json b/vigilcare-dashboard/package-lock.json index dec3f57..ed1764b 100644 --- a/vigilcare-dashboard/package-lock.json +++ b/vigilcare-dashboard/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "dependencies": { "@vueuse/core": "^14.3.0", + "chart.js": "^4.5.1", "clsx": "^2.1.1", "pinia": "^3.0.4", "tailwind-merge": "^3.6.0", "vue": "^3.5.34", + "vue-chartjs": "^5.3.3", "vue-router": "^4.6.4" }, "devDependencies": { @@ -744,6 +746,12 @@ "@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": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -2299,6 +2307,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": { "version": "2.1.3", "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": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.5.tgz", diff --git a/vigilcare-dashboard/package.json b/vigilcare-dashboard/package.json index 8f6ab2f..d8ed1d8 100644 --- a/vigilcare-dashboard/package.json +++ b/vigilcare-dashboard/package.json @@ -12,10 +12,12 @@ }, "dependencies": { "@vueuse/core": "^14.3.0", + "chart.js": "^4.5.1", "clsx": "^2.1.1", "pinia": "^3.0.4", "tailwind-merge": "^3.6.0", "vue": "^3.5.34", + "vue-chartjs": "^5.3.3", "vue-router": "^4.6.4" }, "devDependencies": { diff --git a/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js b/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js new file mode 100644 index 0000000..e3b8825 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/AlertReasoning.test.js @@ -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') + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/useChartData.test.js b/vigilcare-dashboard/src/__tests__/useChartData.test.js new file mode 100644 index 0000000..777f3af --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/useChartData.test.js @@ -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([]) + }) +}) diff --git a/vigilcare-dashboard/src/__tests__/useReplayControls.test.js b/vigilcare-dashboard/src/__tests__/useReplayControls.test.js new file mode 100644 index 0000000..033a403 --- /dev/null +++ b/vigilcare-dashboard/src/__tests__/useReplayControls.test.js @@ -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) + }) +}) diff --git a/vigilcare-dashboard/src/api/clinical.js b/vigilcare-dashboard/src/api/clinical.js index 2f8bb6a..207010b 100644 --- a/vigilcare-dashboard/src/api/clinical.js +++ b/vigilcare-dashboard/src/api/clinical.js @@ -14,4 +14,32 @@ export function fetchSepsisBundle(encounterId) { export function fetchOrders(encounterId) { 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 } \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/alerts/AlertReasoning.vue b/vigilcare-dashboard/src/components/alerts/AlertReasoning.vue new file mode 100644 index 0000000..8bf1a25 --- /dev/null +++ b/vigilcare-dashboard/src/components/alerts/AlertReasoning.vue @@ -0,0 +1,81 @@ + + + diff --git a/vigilcare-dashboard/src/components/charts/News2History.vue b/vigilcare-dashboard/src/components/charts/News2History.vue new file mode 100644 index 0000000..31eebcf --- /dev/null +++ b/vigilcare-dashboard/src/components/charts/News2History.vue @@ -0,0 +1,50 @@ + + + diff --git a/vigilcare-dashboard/src/components/charts/TrendsGrid.vue b/vigilcare-dashboard/src/components/charts/TrendsGrid.vue new file mode 100644 index 0000000..4b90d28 --- /dev/null +++ b/vigilcare-dashboard/src/components/charts/TrendsGrid.vue @@ -0,0 +1,27 @@ + + + diff --git a/vigilcare-dashboard/src/components/charts/VitalChart.vue b/vigilcare-dashboard/src/components/charts/VitalChart.vue new file mode 100644 index 0000000..d89729d --- /dev/null +++ b/vigilcare-dashboard/src/components/charts/VitalChart.vue @@ -0,0 +1,43 @@ + + + \ No newline at end of file diff --git a/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue b/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue new file mode 100644 index 0000000..d0f5178 --- /dev/null +++ b/vigilcare-dashboard/src/components/charts/VitalTrendChart.vue @@ -0,0 +1,23 @@ + + + diff --git a/vigilcare-dashboard/src/components/patient/AlertsList.vue b/vigilcare-dashboard/src/components/patient/AlertsList.vue index 772f2be..c6d0432 100644 --- a/vigilcare-dashboard/src/components/patient/AlertsList.vue +++ b/vigilcare-dashboard/src/components/patient/AlertsList.vue @@ -11,8 +11,11 @@ import { alertTypeLabel } from '@/api/normalize' const props = defineProps({ encounterId: { type: String, required: true }, + selectedId: { type: String, default: null }, }) +const emit = defineEmits(['select']) + const alertStore = useAlertStore() const settings = useSettingsStore() const { alerts, loading } = storeToRefs(alertStore) @@ -51,7 +54,9 @@ async function resolve(alertId) {
  • diff --git a/vigilcare-dashboard/src/components/replay/ReplayControls.vue b/vigilcare-dashboard/src/components/replay/ReplayControls.vue new file mode 100644 index 0000000..a6cc047 --- /dev/null +++ b/vigilcare-dashboard/src/components/replay/ReplayControls.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/vigilcare-dashboard/src/composables/chartFormat.js b/vigilcare-dashboard/src/composables/chartFormat.js new file mode 100644 index 0000000..f53bfb4 --- /dev/null +++ b/vigilcare-dashboard/src/composables/chartFormat.js @@ -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' +} diff --git a/vigilcare-dashboard/src/composables/useChartData.js b/vigilcare-dashboard/src/composables/useChartData.js new file mode 100644 index 0000000..f40d82b --- /dev/null +++ b/vigilcare-dashboard/src/composables/useChartData.js @@ -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 } +} diff --git a/vigilcare-dashboard/src/composables/useReplayControls.js b/vigilcare-dashboard/src/composables/useReplayControls.js new file mode 100644 index 0000000..9e58edc --- /dev/null +++ b/vigilcare-dashboard/src/composables/useReplayControls.js @@ -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 } +} \ No newline at end of file diff --git a/vigilcare-dashboard/src/views/PatientDetail.vue b/vigilcare-dashboard/src/views/PatientDetail.vue index 7b6b8e9..7a44027 100644 --- a/vigilcare-dashboard/src/views/PatientDetail.vue +++ b/vigilcare-dashboard/src/views/PatientDetail.vue @@ -1,7 +1,9 @@ \ No newline at end of file +