Finish: Phase 33 — Alert Quality Analytics

This commit is contained in:
voltsrage
2026-06-24 03:39:24 +08:00
parent 185dc93fa1
commit 68c397350b
26 changed files with 934 additions and 169 deletions
@@ -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 },
]
+10 -6
View File
@@ -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,
}
})
+19 -86
View File
@@ -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>