Finish: Phase 33 — Alert Quality Analytics
This commit is contained in:
@@ -1,8 +1,26 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FeedbackButtons from '@/components/feedback/FeedbackButtons.vue'
|
||||
|
||||
const { feedbackByAlert } = vi.hoisted(() => {
|
||||
const { ref } = require('vue')
|
||||
return { feedbackByAlert: ref({}) }
|
||||
})
|
||||
|
||||
vi.mock('@/stores/alertQuality', () => ({
|
||||
useAlertQualityStore: () => ({
|
||||
async submitFeedback(alertId, rating, comment = '') {
|
||||
feedbackByAlert.value = {
|
||||
...feedbackByAlert.value,
|
||||
[alertId]: { rating, comment, submittedAt: '2026-06-23T00:00:00Z' },
|
||||
}
|
||||
return { id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }
|
||||
},
|
||||
getFeedback: (alertId) => feedbackByAlert.value[alertId] ?? null,
|
||||
}),
|
||||
}))
|
||||
|
||||
const defaultProps = {
|
||||
alertId: 'alert-1',
|
||||
alertType: 'SepsisWarning',
|
||||
@@ -19,7 +37,7 @@ function ratingButton(wrapper, label) {
|
||||
|
||||
describe('FeedbackButtons', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
feedbackByAlert.value = {}
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
@@ -28,11 +46,21 @@ describe('FeedbackButtons', () => {
|
||||
expect(ratingButtons(wrapper)).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('hidesRatingButtonsWhenCannotSubmit', () => {
|
||||
const wrapper = mount(FeedbackButtons, {
|
||||
props: { ...defaultProps, canSubmit: false },
|
||||
})
|
||||
|
||||
expect(ratingButtons(wrapper)).toHaveLength(0)
|
||||
expect(wrapper.text()).toContain('Acknowledge this alert to rate it.')
|
||||
})
|
||||
|
||||
it('selectingRatingHighlightsButton', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
const usefulBtn = ratingButton(wrapper, 'Useful')
|
||||
|
||||
await usefulBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(usefulBtn.classes()).toContain('ring-2')
|
||||
expect(usefulBtn.classes()).toContain('bg-green-100')
|
||||
@@ -41,6 +69,7 @@ describe('FeedbackButtons', () => {
|
||||
it('showNotesFieldOnPlusNote', async () => {
|
||||
const wrapper = mount(FeedbackButtons, { props: defaultProps })
|
||||
await ratingButton(wrapper, 'Useful').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
|
||||
await noteBtn.trigger('click')
|
||||
@@ -54,6 +83,7 @@ describe('FeedbackButtons', () => {
|
||||
const fpBtn = ratingButton(wrapper, 'False positive')
|
||||
|
||||
await usefulBtn.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(usefulBtn.attributes('aria-checked')).toBe('true')
|
||||
expect(fpBtn.attributes('aria-checked')).toBe('false')
|
||||
@@ -66,11 +96,13 @@ describe('FeedbackButtons', () => {
|
||||
expect(buttons.every(b => b.element.tagName === 'BUTTON')).toBe(true)
|
||||
|
||||
await ratingButton(wrapper, 'Useful').trigger('click')
|
||||
await flushPromises()
|
||||
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('true')
|
||||
|
||||
await ratingButton(wrapper, 'Too early').trigger('click')
|
||||
expect(ratingButton(wrapper, 'Too early').attributes('aria-checked')).toBe('true')
|
||||
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('false')
|
||||
await flushPromises()
|
||||
expect(ratingButton(wrapper, 'Too early').attributes('aria-checked')).toBe('false')
|
||||
expect(ratingButton(wrapper, 'Useful').attributes('aria-checked')).toBe('true')
|
||||
|
||||
const noteBtn = wrapper.findAll('button').find(b => b.text() === '+ Note')
|
||||
await noteBtn.trigger('click')
|
||||
@@ -78,6 +110,7 @@ describe('FeedbackButtons', () => {
|
||||
const input = wrapper.get('input')
|
||||
await input.setValue('Expected after metoprolol')
|
||||
await input.trigger('keydown.enter')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import FeedbackSummary from '@/views/FeedbackSummary.vue'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
|
||||
const { mockSummaryStats, mockByAlertType } = vi.hoisted(() => ({
|
||||
mockSummaryStats: {
|
||||
totalFeedback: 3,
|
||||
usefulRate: 67,
|
||||
falsePositiveRate: 33,
|
||||
},
|
||||
mockByAlertType: {
|
||||
SepsisWarning: [{ rating: 'useful' }, { rating: 'would-act' }],
|
||||
WarningHeartRate: [{ rating: 'false-positive' }],
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/alertQuality', () => ({
|
||||
useAlertQualityStore: () => ({
|
||||
summaryStats: mockSummaryStats,
|
||||
byAlertType: mockByAlertType,
|
||||
submitFeedback: vi.fn(),
|
||||
getFeedback: vi.fn(() => null),
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('FeedbackSummary', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
setActivePinia(createPinia())
|
||||
|
||||
const store = useFeedbackStore()
|
||||
store.addFeedback('a1', 'SepsisWarning', 'Critical', 'useful')
|
||||
store.addFeedback('a2', 'SepsisWarning', 'Critical', 'would-act')
|
||||
store.addFeedback('a3', 'WarningHeartRate', 'Warning', 'false-positive')
|
||||
})
|
||||
|
||||
it('showsAggregateStats', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAlertQualityStore } from './alertQuality'
|
||||
|
||||
vi.mock('@/api/alertQuality', () => ({
|
||||
submitAlertFeedback: vi.fn().mockResolvedValue({ id: 'fb-1', createdAt: '2026-06-23T00:00:00Z' }),
|
||||
fetchQualityMetricsSummary: vi.fn().mockResolvedValue({
|
||||
totalAlerts: 10, totalFeedback: 4,
|
||||
acknowledgementRate: 0.8, usefulRate: 0.75,
|
||||
falsePositiveRate: 0.1, wouldActRate: 0.6,
|
||||
avgSecondsToAcknowledge: 300, avgSecondsToResolution: 1200,
|
||||
}),
|
||||
fetchQualityMetrics: vi.fn().mockResolvedValue({
|
||||
items: [{ alertType: 'News2Warning', usefulRate: 0.75, falsePositiveRate: 0.1, acknowledgementRate: 0.8, totalAlerts: 5 }],
|
||||
}),
|
||||
FEEDBACK_TYPE_MAP: { useful: 'Useful' },
|
||||
}))
|
||||
|
||||
describe('alertQuality store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('loads dashboard summary and snapshots', async () => {
|
||||
const store = useAlertQualityStore()
|
||||
await store.loadDashboard()
|
||||
expect(store.summaryStats.totalAlerts).toBe(10)
|
||||
expect(store.summaryStats.usefulRate).toBe(75)
|
||||
expect(store.snapshots).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('caches submitted feedback per alert', async () => {
|
||||
const store = useAlertQualityStore()
|
||||
await store.submitFeedback('alert-1', 'useful', 'test note')
|
||||
expect(store.getFeedback('alert-1').rating).toBe('useful')
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,11 @@ import { canAccessOps, filterNavLinks, isDashboardRole, roleCanAccessRoute, MAIN
|
||||
describe('roleAccess', () => {
|
||||
it('filtersNavLinksByRole', () => {
|
||||
const nurseLinks = filterNavLinks(MAIN_NAV_LINKS, 'NURSE')
|
||||
expect(nurseLinks.some((l) => l.to === '/feedback')).toBe(false)
|
||||
expect(nurseLinks.some((l) => l.to === '/analytics/alerts')).toBe(true)
|
||||
expect(nurseLinks.some((l) => l.to === '/alerts')).toBe(true)
|
||||
|
||||
const physicianLinks = filterNavLinks(MAIN_NAV_LINKS, 'PHYSICIAN')
|
||||
expect(physicianLinks.some((l) => l.to === '/feedback')).toBe(true)
|
||||
expect(physicianLinks.some((l) => l.to === '/analytics/alerts')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejectsIntegrationDashboardRole', () => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from './client'
|
||||
|
||||
/** Maps frontend pill values to backend PascalCase enum strings. */
|
||||
export const FEEDBACK_TYPE_MAP = {
|
||||
'useful': 'Useful',
|
||||
'too-early': 'TooEarly',
|
||||
'too-late': 'TooLate',
|
||||
'false-positive': 'FalsePositive',
|
||||
'missing-context': 'MissingContext',
|
||||
'would-act': 'WouldAct',
|
||||
}
|
||||
|
||||
export function submitAlertFeedback(alertId, rating, comment = '') {
|
||||
const feedbackType = FEEDBACK_TYPE_MAP[rating]
|
||||
if (!feedbackType) throw new Error(`Unknown feedback rating: ${rating}`)
|
||||
return api.post(`/api/v1/alerts/${alertId}/feedback`, {
|
||||
feedbackType,
|
||||
comment: comment || null,
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchQualityMetricsSummary(from, to) {
|
||||
const params = new URLSearchParams()
|
||||
if (from) params.set('from', from)
|
||||
if (to) params.set('to', to)
|
||||
const qs = params.toString()
|
||||
return api.get(`/api/v1/alerts/quality-metrics/summary${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export function fetchQualityMetrics({ alertType, from, to } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
if (alertType) params.set('alertType', alertType)
|
||||
if (from) params.set('from', from)
|
||||
if (to) params.set('to', to)
|
||||
const qs = params.toString()
|
||||
return api.get(`/api/v1/alerts/quality-metrics${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
@@ -91,11 +91,15 @@ function formatTime(iso) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700">
|
||||
<div
|
||||
v-if="alert.status === 'Acknowledged' || alert.status === 'Resolved'"
|
||||
class="mt-4 border-t border-gray-100 pt-4 dark:border-gray-700"
|
||||
>
|
||||
<FeedbackButtons
|
||||
:alert-id="alert.id"
|
||||
:alert-type="alert.alertType"
|
||||
:severity="alert.severity"
|
||||
:can-submit="true"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { computed, shallowRef, markRaw } from 'vue'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import { Chart, registerables } from 'chart.js'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
|
||||
Chart.register(...registerables)
|
||||
|
||||
const props = defineProps({
|
||||
snapshots: { type: Array, required: true },
|
||||
metric: {
|
||||
type: String,
|
||||
default: 'usefulRate',
|
||||
validator: v => ['usefulRate', 'falsePositiveRate', 'acknowledgementRate', 'wouldActRate'].includes(v),
|
||||
},
|
||||
title: { type: String, default: 'Alert Quality Over Time' },
|
||||
})
|
||||
|
||||
const METRIC_LABELS = {
|
||||
usefulRate: 'Useful Rate %',
|
||||
falsePositiveRate: 'False Positive Rate %',
|
||||
acknowledgementRate: 'Acknowledgement Rate %',
|
||||
wouldActRate: 'Would Act Rate %',
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const sorted = [...props.snapshots].sort(
|
||||
(a, b) => new Date(a.windowStart) - new Date(b.windowStart),
|
||||
)
|
||||
return {
|
||||
labels: sorted.map(s => alertTypeLabel(s.alertType)),
|
||||
datasets: [{
|
||||
label: METRIC_LABELS[props.metric],
|
||||
data: sorted.map(s => Math.round(s[props.metric] * 100)),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
||||
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: 100, title: { display: true, text: '%' } },
|
||||
},
|
||||
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 v-if="snapshots.length" :data="chartData" :options="chartOptions" />
|
||||
<p v-else class="text-sm text-gray-400">No metrics for this period yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,18 +1,22 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||
|
||||
const props = defineProps({
|
||||
alertId: { type: String, required: true },
|
||||
alertType: { type: String, required: true },
|
||||
severity: { type: String, required: true },
|
||||
/** Feedback only allowed after acknowledgement (backend enforces). */
|
||||
canSubmit: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const feedbackStore = useFeedbackStore()
|
||||
const alertQuality = useAlertQualityStore()
|
||||
const showNotes = ref(false)
|
||||
const notes = ref('')
|
||||
const submitting = ref(false)
|
||||
const submitError = ref(null)
|
||||
|
||||
const existing = computed(() => feedbackStore.getFeedback(props.alertId))
|
||||
const existing = computed(() => alertQuality.getFeedback(props.alertId))
|
||||
const selectedRating = computed(() => existing.value?.rating ?? null)
|
||||
|
||||
const ratings = [
|
||||
@@ -24,27 +28,48 @@ const ratings = [
|
||||
{ value: 'would-act', label: 'Would act', color: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300' },
|
||||
]
|
||||
|
||||
function select(rating) {
|
||||
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, rating, notes.value)
|
||||
async function select(rating) {
|
||||
if (!props.canSubmit || submitting.value || selectedRating.value) return
|
||||
submitting.value = true
|
||||
submitError.value = null
|
||||
try {
|
||||
await alertQuality.submitFeedback(props.alertId, rating, notes.value)
|
||||
} catch (e) {
|
||||
submitError.value = e.message
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function submitNotes() {
|
||||
if (selectedRating.value) {
|
||||
feedbackStore.addFeedback(props.alertId, props.alertType, props.severity, selectedRating.value, notes.value)
|
||||
async function submitNotes() {
|
||||
if (!selectedRating.value) return
|
||||
submitting.value = true
|
||||
submitError.value = null
|
||||
try {
|
||||
await alertQuality.submitFeedback(props.alertId, selectedRating.value, notes.value)
|
||||
showNotes.value = false
|
||||
} catch (e) {
|
||||
submitError.value = e.message
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
showNotes.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2" role="radiogroup" aria-label="Rate this alert">
|
||||
<p v-if="!canSubmit" class="text-xs text-gray-400 dark:text-gray-500">
|
||||
Acknowledge this alert to rate it.
|
||||
</p>
|
||||
|
||||
<div v-else class="flex flex-wrap gap-2" role="radiogroup" aria-label="Rate this alert">
|
||||
<button
|
||||
v-for="r in ratings"
|
||||
:key="r.value"
|
||||
role="radio"
|
||||
:aria-checked="selectedRating === r.value"
|
||||
class="rounded-full px-4 py-2 text-xs font-medium transition duration-150 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||
:disabled="submitting || !!selectedRating"
|
||||
class="rounded-full px-4 py-2 text-xs font-medium transition duration-150 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:opacity-50"
|
||||
:class="[
|
||||
selectedRating === r.value ? r.color : 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700',
|
||||
selectedRating === r.value ? 'ring-2 ring-current' : '',
|
||||
@@ -63,8 +88,10 @@ function submitNotes() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="submitError" class="text-xs text-red-600 dark:text-red-400">{{ submitError }}</p>
|
||||
|
||||
<Transition name="slide">
|
||||
<div v-if="showNotes" class="flex flex-col gap-2 sm:flex-row">
|
||||
<div v-if="showNotes && canSubmit" class="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
v-model.trim="notes"
|
||||
type="text"
|
||||
@@ -73,7 +100,8 @@ function submitNotes() {
|
||||
@keydown.enter="submitNotes"
|
||||
/>
|
||||
<button
|
||||
class="rounded bg-blue-500 px-4 py-2 text-xs text-white hover:bg-blue-600"
|
||||
class="rounded bg-blue-500 px-4 py-2 text-xs text-white hover:bg-blue-600 disabled:opacity-50"
|
||||
:disabled="submitting"
|
||||
@click.stop="submitNotes"
|
||||
>
|
||||
Save
|
||||
|
||||
@@ -116,6 +116,21 @@ function linkClasses(path) {
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="link.icon === 'analytics'"
|
||||
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="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
|
||||
v-else
|
||||
class="h-6 w-6 shrink-0"
|
||||
|
||||
@@ -15,7 +15,7 @@ export const MAIN_NAV_LINKS = [
|
||||
{ to: '/departments', label: 'Departments', icon: 'departments', roles: CLINICAL_ROLES },
|
||||
{ to: '/sepsis', label: 'Sepsis Board', icon: 'sepsis', roles: CLINICAL_ROLES },
|
||||
{ to: '/alerts', label: 'Alert Center', icon: 'alerts', roles: CLINICAL_ROLES },
|
||||
{ to: '/feedback', label: 'Feedback Summary', icon: 'feedback', roles: ['PHYSICIAN', 'ADMIN'] },
|
||||
{ to: '/analytics/alerts', label: 'Alert Quality', icon: 'analytics', roles: CLINICAL_ROLES },
|
||||
{ to: '/admin/reconciliation', label: 'Data Quality', icon: 'reconciliation', roles: CLINICAL_ROLES },
|
||||
]
|
||||
|
||||
|
||||
@@ -45,12 +45,6 @@ const routes = [
|
||||
component: () => import('@/views/SepsisBoardView.vue'),
|
||||
meta: { title: 'Sepsis Bundle Board', layout: 'default', allowedRoles: CLINICAL },
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
name: 'FeedbackSummary',
|
||||
component: () => import('@/views/FeedbackSummary.vue'),
|
||||
meta: { title: 'Feedback Summary', layout: 'default', allowedRoles: ['PHYSICIAN', 'ADMIN'] },
|
||||
},
|
||||
{
|
||||
path: '/admin/thresholds',
|
||||
name: 'ThresholdManagement',
|
||||
@@ -81,6 +75,16 @@ const routes = [
|
||||
component: () => import('@/views/GatewayOperations.vue'),
|
||||
meta: { title: 'Gateway Operations', layout: 'default', opsRoute: true },
|
||||
},
|
||||
{
|
||||
path: '/analytics/alerts',
|
||||
name: 'AlertQualityAnalytics',
|
||||
component: () => import('@/views/AlertQualityAnalytics.vue'),
|
||||
meta: { title: 'Alert Quality', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/feedback',
|
||||
redirect: '/analytics/alerts',
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import {
|
||||
submitAlertFeedback,
|
||||
fetchQualityMetricsSummary,
|
||||
fetchQualityMetrics,
|
||||
} from '@/api/alertQuality'
|
||||
|
||||
export const useAlertQualityStore = defineStore('alertQuality', () => {
|
||||
const summary = ref(null)
|
||||
const snapshots = ref([])
|
||||
const periodDays = ref(7)
|
||||
const selectedAlertType = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// Per-alert feedback cache: alertId → { rating, comment, submittedAt }
|
||||
const feedbackByAlert = ref({})
|
||||
|
||||
const summaryStats = computed(() => {
|
||||
const s = summary.value
|
||||
if (!s) {
|
||||
return {
|
||||
totalAlerts: 0,
|
||||
totalFeedback: 0,
|
||||
acknowledgementRate: 0,
|
||||
usefulRate: 0,
|
||||
falsePositiveRate: 0,
|
||||
wouldActRate: 0,
|
||||
avgAckMinutes: 0,
|
||||
avgResolveMinutes: 0,
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalAlerts: s.totalAlerts,
|
||||
totalFeedback: s.totalFeedback,
|
||||
acknowledgementRate: Math.round(s.acknowledgementRate * 100),
|
||||
usefulRate: Math.round(s.usefulRate * 100),
|
||||
falsePositiveRate: Math.round(s.falsePositiveRate * 100),
|
||||
wouldActRate: Math.round(s.wouldActRate * 100),
|
||||
avgAckMinutes: Math.round(s.avgSecondsToAcknowledge / 60),
|
||||
avgResolveMinutes: Math.round(s.avgSecondsToResolution / 60),
|
||||
}
|
||||
})
|
||||
|
||||
const byAlertType = computed(() => {
|
||||
const map = {}
|
||||
for (const snap of snapshots.value) {
|
||||
if (!map[snap.alertType]) map[snap.alertType] = []
|
||||
map[snap.alertType].push(snap)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function periodRange() {
|
||||
const to = new Date().toISOString()
|
||||
const from = new Date(Date.now() - periodDays.value * 86_400_000).toISOString()
|
||||
return { from, to }
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const { from, to } = periodRange()
|
||||
const [sum, list] = await Promise.all([
|
||||
fetchQualityMetricsSummary(from, to),
|
||||
fetchQualityMetrics({
|
||||
alertType: selectedAlertType.value,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
])
|
||||
summary.value = sum
|
||||
snapshots.value = list.items ?? []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitFeedback(alertId, rating, comment = '') {
|
||||
const result = await submitAlertFeedback(alertId, rating, comment)
|
||||
feedbackByAlert.value[alertId] = {
|
||||
rating,
|
||||
comment,
|
||||
submittedAt: result.createdAt,
|
||||
feedbackType: result.feedbackType,
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getFeedback(alertId) {
|
||||
return feedbackByAlert.value[alertId] ?? null
|
||||
}
|
||||
|
||||
function setPeriodDays(days) {
|
||||
periodDays.value = days
|
||||
}
|
||||
|
||||
function setAlertTypeFilter(type) {
|
||||
selectedAlertType.value = type
|
||||
}
|
||||
|
||||
return {
|
||||
summary,
|
||||
snapshots,
|
||||
periodDays,
|
||||
selectedAlertType,
|
||||
loading,
|
||||
error,
|
||||
summaryStats,
|
||||
byAlertType,
|
||||
loadDashboard,
|
||||
submitFeedback,
|
||||
getFeedback,
|
||||
setPeriodDays,
|
||||
setAlertTypeFilter,
|
||||
}
|
||||
})
|
||||
@@ -1,88 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useAlertQualityStore } from './alertQuality'
|
||||
|
||||
export const useFeedbackStore = defineStore('feedback', () => {
|
||||
const entries = ref(JSON.parse(localStorage.getItem('vigilcare-feedback') || '[]'))
|
||||
|
||||
const stats = computed(() => {
|
||||
const total = entries.value.length
|
||||
if (total === 0) return { total: 0, useful: 0, falsePositive: 0, usefulPct: 0, fpPct: 0 }
|
||||
|
||||
const useful = entries.value.filter(e => e.rating === 'useful' || e.rating === 'would-act').length
|
||||
const fp = entries.value.filter(e => e.rating === 'false-positive').length
|
||||
|
||||
return {
|
||||
total,
|
||||
useful,
|
||||
falsePositive: fp,
|
||||
usefulPct: Math.round((useful / total) * 100),
|
||||
fpPct: Math.round((fp / total) * 100),
|
||||
}
|
||||
})
|
||||
|
||||
const byAlertType = computed(() => {
|
||||
const map = {}
|
||||
for (const entry of entries.value) {
|
||||
if (!map[entry.alertType]) map[entry.alertType] = []
|
||||
map[entry.alertType].push(entry)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
function addFeedback(alertId, alertType, severity, rating, notes = '') {
|
||||
const existing = entries.value.findIndex(e => e.alertId === alertId)
|
||||
const entry = {
|
||||
alertId,
|
||||
alertType,
|
||||
severity,
|
||||
rating,
|
||||
notes: notes.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (existing >= 0) {
|
||||
entries.value[existing] = entry
|
||||
} else {
|
||||
entries.value.push(entry)
|
||||
}
|
||||
|
||||
persist()
|
||||
export function useFeedbackStore() {
|
||||
const store = useAlertQualityStore()
|
||||
return {
|
||||
entries: computed(() => []),
|
||||
stats: computed(() => ({
|
||||
total: store.summaryStats.totalFeedback,
|
||||
usefulPct: store.summaryStats.usefulRate,
|
||||
fpPct: store.summaryStats.falsePositiveRate,
|
||||
})),
|
||||
byAlertType: store.byAlertType,
|
||||
addFeedback: (alertId, _type, _severity, rating, notes) =>
|
||||
store.submitFeedback(alertId, rating, notes),
|
||||
getFeedback: store.getFeedback,
|
||||
exportAsJson: () => {},
|
||||
exportAsCsv: () => {},
|
||||
clearAll: () => {},
|
||||
}
|
||||
|
||||
function getFeedback(alertId) {
|
||||
return entries.value.find(e => e.alertId === alertId) ?? null
|
||||
}
|
||||
|
||||
function persist() {
|
||||
localStorage.setItem('vigilcare-feedback', JSON.stringify(entries.value))
|
||||
}
|
||||
|
||||
function exportAsJson() {
|
||||
const blob = new Blob([JSON.stringify(entries.value, null, 2)], { type: 'application/json' })
|
||||
downloadBlob(blob, 'vigilcare-feedback.json')
|
||||
}
|
||||
|
||||
function exportAsCsv() {
|
||||
const headers = ['alertId', 'alertType', 'severity', 'rating', 'notes', 'timestamp']
|
||||
const rows = entries.value.map(e => headers.map(h => `"${(e[h] ?? '').toString().replace(/"/g, '""')}"`).join(','))
|
||||
const csv = [headers.join(','), ...rows].join('\n')
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
downloadBlob(blob, 'vigilcare-feedback.csv')
|
||||
}
|
||||
|
||||
function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
entries.value = []
|
||||
persist()
|
||||
}
|
||||
|
||||
return { entries, stats, byAlertType, addFeedback, getFeedback, exportAsJson, exportAsCsv, clearAll }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useAlertQualityStore } from '@/stores/alertQuality'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
import Button from '@/components/ui/Button.vue'
|
||||
import Skeleton from '@/components/ui/Skeleton.vue'
|
||||
import AlertQualityChart from '@/components/charts/AlertQualityChart.vue'
|
||||
|
||||
const store = useAlertQualityStore()
|
||||
const { summaryStats, byAlertType, snapshots, loading, error, periodDays } = storeToRefs(store)
|
||||
|
||||
const periodOptions = [
|
||||
{ label: '7 days', value: 7 },
|
||||
{ label: '14 days', value: 14 },
|
||||
{ label: '30 days', value: 30 },
|
||||
]
|
||||
|
||||
onMounted(() => store.loadDashboard())
|
||||
|
||||
function changePeriod(days) {
|
||||
store.setPeriodDays(days)
|
||||
store.loadDashboard()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full min-w-0 space-y-8">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 class="text-xl font-bold dark:text-white">Alert Quality Analytics</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Clinician feedback and acknowledgement metrics from the last {{ periodDays }} days.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-for="opt in periodOptions"
|
||||
:key="opt.value"
|
||||
size="sm"
|
||||
:variant="periodDays === opt.value ? 'primary' : 'secondary'"
|
||||
@click="changePeriod(opt.value)"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" :disabled="loading" @click="store.loadDashboard()">
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400">{{ error }}</p>
|
||||
|
||||
<!-- KPI cards -->
|
||||
<div v-if="loading" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Skeleton v-for="n in 4" :key="n" class="h-24" />
|
||||
</div>
|
||||
<div v-else class="grid w-full min-w-0 grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold dark:text-white">{{ summaryStats.totalAlerts }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Total Alerts</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-green-600">{{ summaryStats.usefulRate }}%</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Useful Rate</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-red-600">{{ summaryStats.falsePositiveRate }}%</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">False Positive Rate</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-3xl font-bold text-blue-600">{{ summaryStats.acknowledgementRate }}%</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Acknowledgement Rate</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Secondary KPIs -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.totalFeedback }}</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Feedback Submissions</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgAckMinutes }} min</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Acknowledge</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold dark:text-white">{{ summaryStats.avgResolveMinutes }} min</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Avg Time to Resolve</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<AlertQualityChart
|
||||
:snapshots="snapshots"
|
||||
metric="usefulRate"
|
||||
title="Useful Rate by Alert Type"
|
||||
/>
|
||||
<AlertQualityChart
|
||||
:snapshots="snapshots"
|
||||
metric="falsePositiveRate"
|
||||
title="False Positive Rate by Alert Type"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Per alert type table -->
|
||||
<Card>
|
||||
<h2 class="mb-4 text-lg font-semibold dark:text-white">By Alert Type</h2>
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<div
|
||||
v-for="(rows, alertType) in byAlertType"
|
||||
:key="alertType"
|
||||
class="flex flex-col gap-2 py-4 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div>
|
||||
<span class="text-sm font-medium dark:text-white">{{ alertTypeLabel(alertType) }}</span>
|
||||
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({{ rows.reduce((s, r) => s + r.totalAlerts, 0) }} alerts)
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant="success" size="xs">
|
||||
{{ Math.round(rows.at(-1)?.usefulRate * 100 ?? 0) }}% useful
|
||||
</Badge>
|
||||
<Badge variant="critical" size="xs">
|
||||
{{ Math.round(rows.at(-1)?.falsePositiveRate * 100 ?? 0) }}% FP
|
||||
</Badge>
|
||||
<Badge variant="info" size="xs">
|
||||
{{ Math.round(rows.at(-1)?.acknowledgementRate * 100 ?? 0) }}% ack
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!Object.keys(byAlertType).length" class="py-4 text-sm text-gray-400">
|
||||
No aggregated metrics yet. Metrics appear after the hourly aggregation cycle runs.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useFeedbackStore } from '@/stores/feedback'
|
||||
import { alertTypeLabel } from '@/api/normalize'
|
||||
import Badge from '@/components/ui/Badge.vue'
|
||||
@@ -7,7 +6,7 @@ import Button from '@/components/ui/Button.vue'
|
||||
import Card from '@/components/ui/Card.vue'
|
||||
|
||||
const feedbackStore = useFeedbackStore()
|
||||
const { entries, stats, byAlertType } = storeToRefs(feedbackStore)
|
||||
const { entries, stats, byAlertType } = feedbackStore
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user