diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index d8ce2b7..778e826 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -1,18 +1,24 @@
+import { useScrollToTop } from '@react-navigation/native';
import { useRouter } from 'expo-router';
-import { useEffect } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Alert, ScrollView, StyleSheet, TouchableOpacity, View } from 'react-native';
import { CourseCardSkeleton, Skeleton, AppText as Text } from '@/src/components';
import { sampleCourse } from '@/src/data/sampleCourse';
import { useDynamicFontSize, useAnalytics } from '@/src/hooks';
-import { useAppStore } from '@/src/store';
+import { useUser } from '@/src/store';
import { AnalyticsEvent, ScreenName } from '@/src/utils/trackingEvents';
export default function HomeScreen() {
const router = useRouter();
- const { isLoading, setLoading } = useAppStore();
+ const [isLoading, setLoading] = useState(false);
const { scale } = useDynamicFontSize();
const { trackEvent, trackScreen } = useAnalytics();
+ const user = useUser();
+ const scrollRef = useRef(null);
+ useScrollToTop(scrollRef);
+
+ const isDashboardUser = user?.role === 'admin' || user?.role === 'instructor';
useEffect(() => {
trackScreen(ScreenName.HOME);
@@ -68,6 +74,7 @@ export default function HomeScreen() {
return (
{'>'}
+
+ {/* Team Dashboard — visible to admins and instructors */}
+ {isDashboardUser && (
+ {
+ trackEvent(AnalyticsEvent.BUTTON_CLICK, { button: 'team_dashboard', screen: 'home' });
+ router.push('../dashboard');
+ }}
+ accessibilityRole="button"
+ accessibilityLabel="Team Dashboard"
+ accessibilityHint="View app health and performance metrics"
+ >
+
+ ?
+
+ Team Dashboard
+ App health & metrics
+
+ {'>'}
+
+
+ )}
);
@@ -259,6 +289,10 @@ const styles = StyleSheet.create({
shadowRadius: 2,
elevation: 1,
},
+ dashboardButton: {
+ borderColor: '#a5f3fc',
+ backgroundColor: '#f0fdff',
+ },
secondaryButtonContent: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/app/dashboard.tsx b/app/dashboard.tsx
new file mode 100644
index 0000000..6bdca76
--- /dev/null
+++ b/app/dashboard.tsx
@@ -0,0 +1,144 @@
+import { MetricsDashboard } from '@/src/components/mobile/MetricsDashboard';
+import { useDashboardMetrics } from '@/src/hooks/useDashboardMetrics';
+import { useMetricsStore } from '@/src/store/metricsStore';
+import type { DashboardRole } from '@/src/store/metricsStore';
+import { useRouter } from 'expo-router';
+import React from 'react';
+import { StyleSheet, TouchableOpacity, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { AppText as Text } from '@/src/components/common/AppText';
+
+const ROLE_OPTIONS: { label: string; value: DashboardRole | null }[] = [
+ { label: 'My Role', value: null },
+ { label: 'Admin', value: 'admin' },
+ { label: 'Instructor', value: 'instructor' },
+ { label: 'Student', value: 'student' },
+];
+
+export default function DashboardScreen() {
+ const router = useRouter();
+ const metrics = useDashboardMetrics();
+ const { roleOverride, setRoleOverride, autoRefreshEnabled, setAutoRefresh } = useMetricsStore();
+
+ return (
+
+ {/* Top bar */}
+
+ router.back()} style={styles.backButton}>
+ {'← Back'}
+
+ setAutoRefresh(!autoRefreshEnabled)}
+ style={[styles.autoRefreshToggle, autoRefreshEnabled && styles.autoRefreshActive]}
+ >
+
+ {autoRefreshEnabled ? '⏸ Pause' : '▶ Resume'}
+
+
+
+
+ {/* Role selector — lets team members preview other views */}
+
+ View as:
+ {ROLE_OPTIONS.map((opt) => {
+ const isActive = roleOverride === opt.value;
+ return (
+ setRoleOverride(opt.value)}
+ style={[styles.roleChip, isActive && styles.roleChipActive]}
+ >
+
+ {opt.label}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ safeArea: {
+ flex: 1,
+ backgroundColor: '#f9fafb',
+ },
+ topBar: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#e5e7eb',
+ },
+ backButton: {
+ paddingVertical: 4,
+ paddingRight: 12,
+ },
+ backText: {
+ fontSize: 15,
+ color: '#2563eb',
+ fontWeight: '500',
+ },
+ autoRefreshToggle: {
+ paddingHorizontal: 14,
+ paddingVertical: 6,
+ borderRadius: 20,
+ borderWidth: 1,
+ borderColor: '#d1d5db',
+ backgroundColor: '#f9fafb',
+ },
+ autoRefreshActive: {
+ borderColor: '#16a34a',
+ backgroundColor: '#f0fdf4',
+ },
+ autoRefreshText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#6b7280',
+ },
+ autoRefreshTextActive: {
+ color: '#15803d',
+ },
+ roleSelector: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 10,
+ gap: 8,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#e5e7eb',
+ },
+ roleSelectorLabel: {
+ fontSize: 12,
+ color: '#6b7280',
+ fontWeight: '600',
+ marginRight: 2,
+ },
+ roleChip: {
+ paddingHorizontal: 12,
+ paddingVertical: 5,
+ borderRadius: 16,
+ borderWidth: 1,
+ borderColor: '#e5e7eb',
+ backgroundColor: '#f9fafb',
+ },
+ roleChipActive: {
+ borderColor: '#19c3e6',
+ backgroundColor: '#e0f7fb',
+ },
+ roleChipText: {
+ fontSize: 12,
+ fontWeight: '600',
+ color: '#6b7280',
+ },
+ roleChipTextActive: {
+ color: '#0c7a8a',
+ },
+});
diff --git a/src/components/mobile/MetricsDashboard.tsx b/src/components/mobile/MetricsDashboard.tsx
new file mode 100644
index 0000000..b70c4bc
--- /dev/null
+++ b/src/components/mobile/MetricsDashboard.tsx
@@ -0,0 +1,365 @@
+import React, { useCallback } from 'react';
+import {
+ RefreshControl,
+ ScrollView,
+ StyleSheet,
+ TouchableOpacity,
+ View,
+} from 'react-native';
+
+import { AppText as Text } from '../common/AppText';
+import { useMetricsStore } from '../../store/metricsStore';
+import type { DashboardMetrics, DashboardSection, MetricValue } from '../../hooks/useDashboardMetrics';
+import type { DashboardAlert } from '../../store/metricsStore';
+
+// ─── Status colours ──────────────────────────────────────────────────────────
+
+const STATUS_COLOR: Record = {
+ ok: '#16a34a',
+ warning: '#d97706',
+ critical: '#dc2626',
+ neutral: '#6b7280',
+};
+
+const ALERT_BG: Record = {
+ critical: '#fef2f2',
+ warning: '#fffbeb',
+ info: '#eff6ff',
+};
+
+const ALERT_BORDER: Record = {
+ critical: '#fca5a5',
+ warning: '#fcd34d',
+ info: '#93c5fd',
+};
+
+const ALERT_TEXT: Record = {
+ critical: '#991b1b',
+ warning: '#92400e',
+ info: '#1e40af',
+};
+
+// ─── Sub-components ──────────────────────────────────────────────────────────
+
+function AlertBanner({ alert, onDismiss }: { alert: DashboardAlert; onDismiss: (id: string) => void }) {
+ return (
+
+
+ {alert.severity === 'critical' ? '🚨 ' : alert.severity === 'warning' ? '⚠️ ' : 'ℹ️ '}
+ {alert.message}
+
+ onDismiss(alert.id)} style={styles.dismissButton}>
+ ✕
+
+
+ );
+}
+
+function MetricCard({ metric }: { metric: MetricValue }) {
+ const dotColor = STATUS_COLOR[metric.status] ?? STATUS_COLOR.neutral;
+ return (
+
+
+
+
+ {metric.label}
+
+
+ {metric.value}
+ {metric.detail ? (
+
+ {metric.detail}
+
+ ) : null}
+
+ );
+}
+
+function SectionBlock({ section }: { section: DashboardSection }) {
+ const pairs: MetricValue[][] = [];
+ for (let i = 0; i < section.metrics.length; i += 2) {
+ pairs.push(section.metrics.slice(i, i + 2));
+ }
+
+ return (
+
+ {section.title}
+ {pairs.map((pair, i) => (
+
+ {pair.map((m) => (
+
+ ))}
+ {pair.length === 1 && }
+
+ ))}
+
+ );
+}
+
+// ─── Header bar ─────────────────────────────────────────────────────────────
+
+function formatAge(ms: number): string {
+ const seconds = Math.floor((Date.now() - ms) / 1000);
+ if (seconds < 60) return 'just now';
+ const minutes = Math.floor(seconds / 60);
+ return `${minutes}m ago`;
+}
+
+function HeaderBar({
+ role,
+ lastRefreshedAt,
+ isAutoRefresh,
+}: {
+ role: string;
+ lastRefreshedAt: number;
+ isAutoRefresh: boolean;
+}) {
+ return (
+
+
+ Team Dashboard
+
+ Updated {formatAge(lastRefreshedAt)}
+ {' '}
+
+ {isAutoRefresh ? '● Live' : '○ Paused'}
+
+
+
+
+ {role.toUpperCase()}
+
+
+ );
+}
+
+// ─── Main component ──────────────────────────────────────────────────────────
+
+export interface MetricsDashboardProps {
+ metrics: DashboardMetrics;
+}
+
+export function MetricsDashboard({ metrics }: MetricsDashboardProps) {
+ const { dismissAlert } = useMetricsStore();
+ const [refreshing, setRefreshing] = React.useState(false);
+
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ metrics.refresh();
+ // Give a brief visual beat before clearing the spinner
+ setTimeout(() => setRefreshing(false), 600);
+ }, [metrics]);
+
+ return (
+ }
+ >
+
+
+ {/* Alert banners */}
+ {metrics.alerts.length > 0 && (
+
+ {metrics.alerts.map((alert) => (
+
+ ))}
+
+ )}
+
+ {metrics.alerts.length === 0 && (
+
+ ✓ All metrics within normal range
+
+ )}
+
+ {/* Metric sections */}
+ {metrics.sections.map((section) => (
+
+ ))}
+
+
+ Pull to refresh • Auto-refresh every 30s
+
+
+ );
+}
+
+// ─── Styles ──────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ backgroundColor: '#f9fafb',
+ },
+ content: {
+ paddingBottom: 40,
+ },
+
+ // Header
+ headerBar: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ paddingHorizontal: 16,
+ paddingTop: 20,
+ paddingBottom: 12,
+ backgroundColor: '#fff',
+ borderBottomWidth: StyleSheet.hairlineWidth,
+ borderBottomColor: '#e5e7eb',
+ },
+ headerTitle: {
+ fontSize: 20,
+ fontWeight: '700',
+ color: '#111827',
+ },
+ headerSub: {
+ fontSize: 12,
+ color: '#6b7280',
+ marginTop: 2,
+ },
+ roleBadge: {
+ backgroundColor: '#e0f2fe',
+ paddingHorizontal: 10,
+ paddingVertical: 4,
+ borderRadius: 12,
+ alignSelf: 'flex-start',
+ },
+ roleBadgeText: {
+ fontSize: 11,
+ fontWeight: '700',
+ color: '#0369a1',
+ letterSpacing: 0.5,
+ },
+
+ // Alerts
+ alertsContainer: {
+ paddingHorizontal: 16,
+ paddingTop: 12,
+ gap: 8,
+ },
+ alertBanner: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ borderWidth: 1,
+ borderRadius: 10,
+ padding: 12,
+ gap: 8,
+ },
+ alertText: {
+ flex: 1,
+ fontSize: 13,
+ fontWeight: '500',
+ lineHeight: 18,
+ },
+ dismissButton: {
+ paddingHorizontal: 4,
+ },
+ dismissText: {
+ fontSize: 14,
+ fontWeight: '600',
+ },
+
+ // Healthy banner
+ healthyBanner: {
+ marginHorizontal: 16,
+ marginTop: 12,
+ backgroundColor: '#f0fdf4',
+ borderWidth: 1,
+ borderColor: '#bbf7d0',
+ borderRadius: 10,
+ paddingHorizontal: 14,
+ paddingVertical: 10,
+ },
+ healthyText: {
+ fontSize: 13,
+ color: '#15803d',
+ fontWeight: '500',
+ },
+
+ // Sections
+ section: {
+ marginTop: 20,
+ marginHorizontal: 16,
+ },
+ sectionTitle: {
+ fontSize: 15,
+ fontWeight: '700',
+ color: '#374151',
+ marginBottom: 10,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ metricRow: {
+ flexDirection: 'row',
+ gap: 10,
+ marginBottom: 10,
+ },
+
+ // Metric cards
+ metricCard: {
+ flex: 1,
+ backgroundColor: '#fff',
+ borderRadius: 12,
+ padding: 14,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: '#e5e7eb',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 1 },
+ shadowOpacity: 0.04,
+ shadowRadius: 2,
+ elevation: 1,
+ },
+ metricCardSpacer: {
+ flex: 1,
+ },
+ metricHeader: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 6,
+ marginBottom: 6,
+ },
+ statusDot: {
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ },
+ metricLabel: {
+ fontSize: 11,
+ fontWeight: '600',
+ color: '#6b7280',
+ textTransform: 'uppercase',
+ letterSpacing: 0.3,
+ flex: 1,
+ },
+ metricValue: {
+ fontSize: 22,
+ fontWeight: '700',
+ color: '#111827',
+ marginBottom: 2,
+ },
+ metricDetail: {
+ fontSize: 11,
+ color: '#9ca3af',
+ marginTop: 2,
+ },
+
+ // Footer
+ footer: {
+ textAlign: 'center',
+ fontSize: 11,
+ color: '#9ca3af',
+ marginTop: 24,
+ paddingHorizontal: 16,
+ },
+});
diff --git a/src/components/mobile/index.ts b/src/components/mobile/index.ts
index 7f1629f..e350d5b 100644
--- a/src/components/mobile/index.ts
+++ b/src/components/mobile/index.ts
@@ -24,3 +24,4 @@ export * from './SubscriptionSkeleton';
export * from './VoiceSearch';
export * from './SwipeableRow';
export * from './SwipeableCoordinator';
+export * from './MetricsDashboard';
diff --git a/src/hooks/index.ts b/src/hooks/index.ts
index ef79712..e817355 100644
--- a/src/hooks/index.ts
+++ b/src/hooks/index.ts
@@ -30,3 +30,4 @@ export * from './useOptimizedSwipe';
export * from './useOptimizedVideoGestures';
export * from './useDebounce';
+export * from './useDashboardMetrics';
diff --git a/src/hooks/useDashboardMetrics.ts b/src/hooks/useDashboardMetrics.ts
new file mode 100644
index 0000000..583652f
--- /dev/null
+++ b/src/hooks/useDashboardMetrics.ts
@@ -0,0 +1,252 @@
+import { useCallback, useEffect, useState } from 'react';
+
+import { crashReportingService } from '../services/crashReporting';
+import { useAppStore } from '../store';
+import { useCourseProgressStore } from '../store/courseProgressStore';
+import type { DashboardAlert, DashboardRole } from '../store/metricsStore';
+import { useMetricsStore } from '../store/metricsStore';
+import { useNotificationStore } from '../store/notificationStore';
+import { useQuizStore } from '../store/quizStore';
+import { useSettingsStore } from '../store/settingsStore';
+
+export interface MetricValue {
+ label: string;
+ value: string | number;
+ status: 'ok' | 'warning' | 'critical' | 'neutral';
+ icon: string;
+ detail?: string;
+}
+
+export interface DashboardSection {
+ id: string;
+ title: string;
+ metrics: MetricValue[];
+}
+
+export interface DashboardMetrics {
+ role: DashboardRole;
+ lastRefreshedAt: number;
+ alerts: DashboardAlert[];
+ sections: DashboardSection[];
+ refresh: () => void;
+ isAutoRefreshEnabled: boolean;
+}
+
+function resolveRole(userRole: string | undefined, override: DashboardRole | null): DashboardRole {
+ if (override) return override;
+ if (userRole === 'admin') return 'admin';
+ if (userRole === 'instructor') return 'instructor';
+ return 'student';
+}
+
+export function useDashboardMetrics(): DashboardMetrics {
+ const user = useAppStore((s) => s.user);
+ const isAuthenticated = useAppStore((s) => s.isAuthenticated);
+
+ const progressMap = useCourseProgressStore((s) => s.progressMap);
+ const quizProgress = useQuizStore((s) => s.quizProgress);
+
+ const pushToken = useNotificationStore((s) => s.pushToken);
+ const isTokenRegistered = useNotificationStore((s) => s.isTokenRegistered);
+ const unreadCount = useNotificationStore((s) => s.unreadCount);
+ const notifications = useNotificationStore((s) => s.notifications);
+
+ const analyticsEnabled = useSettingsStore((s) => s.analyticsEnabled);
+
+ const { thresholds, dismissedAlertIds, refreshIntervalMs, autoRefreshEnabled, roleOverride } =
+ useMetricsStore();
+
+ const [errorCount, setErrorCount] = useState(() => crashReportingService.getErrorCount());
+ const [lastRefreshedAt, setLastRefreshedAt] = useState(Date.now);
+
+ const refresh = useCallback(() => {
+ setErrorCount(crashReportingService.getErrorCount());
+ setLastRefreshedAt(Date.now());
+ }, []);
+
+ useEffect(() => {
+ if (!autoRefreshEnabled) return;
+ const id = setInterval(refresh, refreshIntervalMs);
+ return () => clearInterval(id);
+ }, [autoRefreshEnabled, refresh, refreshIntervalMs]);
+
+ // ─── Derived stats ──────────────────────────────────────────────────────────
+ const role = resolveRole(user?.role, roleOverride);
+
+ const courseIds = Object.keys(progressMap);
+ const completedCourses = courseIds.filter(
+ (id) => progressMap[id].overallProgress >= 100
+ ).length;
+ const inProgressCourses = courseIds.filter(
+ (id) => progressMap[id].overallProgress > 0 && progressMap[id].overallProgress < 100
+ ).length;
+
+ const completedQuizzes = Object.values(quizProgress).filter((q) => q.completed);
+ const avgQuizScore =
+ completedQuizzes.length > 0
+ ? Math.round(
+ completedQuizzes.reduce((sum, q) => sum + (q.score ?? 0), 0) / completedQuizzes.length
+ )
+ : 0;
+
+ const recentNotifications = notifications.filter((n) => {
+ const age = Date.now() - new Date(n.receivedAt).getTime();
+ return age < 24 * 60 * 60 * 1000; // last 24 h
+ }).length;
+
+ // ─── Alerts ─────────────────────────────────────────────────────────────────
+ const rawAlerts: DashboardAlert[] = [];
+
+ if (errorCount >= thresholds.errorCountCritical) {
+ rawAlerts.push({
+ id: 'err_critical',
+ severity: 'critical',
+ message: `${errorCount} unhandled errors this session — investigate immediately`,
+ timestamp: lastRefreshedAt,
+ });
+ } else if (errorCount >= thresholds.errorCountWarning) {
+ rawAlerts.push({
+ id: 'err_warning',
+ severity: 'warning',
+ message: `${errorCount} errors logged this session`,
+ timestamp: lastRefreshedAt,
+ });
+ }
+
+ if (avgQuizScore > 0 && avgQuizScore < thresholds.avgQuizScoreWarning) {
+ rawAlerts.push({
+ id: 'quiz_score_low',
+ severity: 'warning',
+ message: `Average quiz score is low (${avgQuizScore}%) — content review may be needed`,
+ timestamp: lastRefreshedAt,
+ });
+ }
+
+ if (!isAuthenticated) {
+ rawAlerts.push({
+ id: 'auth_none',
+ severity: 'info',
+ message: 'No authenticated user — some metrics are unavailable',
+ timestamp: lastRefreshedAt,
+ });
+ }
+
+ const alerts = rawAlerts.filter((a) => !dismissedAlertIds.includes(a.id));
+
+ // ─── Sections ───────────────────────────────────────────────────────────────
+ const appHealthSection: DashboardSection = {
+ id: 'app_health',
+ title: 'App Health',
+ metrics: [
+ {
+ label: 'Errors',
+ value: errorCount,
+ status:
+ errorCount >= thresholds.errorCountCritical
+ ? 'critical'
+ : errorCount >= thresholds.errorCountWarning
+ ? 'warning'
+ : 'ok',
+ icon: errorCount > 0 ? 'exclamationmark.triangle.fill' : 'checkmark.shield.fill',
+ detail: `threshold: ${thresholds.errorCountCritical}`,
+ },
+ {
+ label: 'Session',
+ value: isAuthenticated ? 'Active' : 'Guest',
+ status: isAuthenticated ? 'ok' : 'neutral',
+ icon: 'person.circle',
+ detail: user?.name ?? 'Not signed in',
+ },
+ {
+ label: 'Analytics',
+ value: analyticsEnabled ? 'On' : 'Off',
+ status: analyticsEnabled ? 'ok' : 'warning',
+ icon: 'chart.bar.fill',
+ },
+ {
+ label: 'Push Token',
+ value: isTokenRegistered ? 'Registered' : 'Pending',
+ status: isTokenRegistered ? 'ok' : pushToken ? 'warning' : 'neutral',
+ icon: 'bell.fill',
+ detail: isTokenRegistered ? 'Device registered' : 'Not yet registered',
+ },
+ ],
+ };
+
+ const notificationsSection: DashboardSection = {
+ id: 'notifications',
+ title: 'Notifications',
+ metrics: [
+ {
+ label: 'Unread',
+ value: unreadCount,
+ status: unreadCount > 10 ? 'warning' : 'neutral',
+ icon: 'envelope.badge.fill',
+ },
+ {
+ label: 'Last 24h',
+ value: recentNotifications,
+ status: 'neutral',
+ icon: 'clock.fill',
+ detail: `of ${notifications.length} total`,
+ },
+ ],
+ };
+
+ const learningSection: DashboardSection = {
+ id: 'learning',
+ title: 'Learning Metrics',
+ metrics: [
+ {
+ label: 'In Progress',
+ value: inProgressCourses,
+ status: 'neutral',
+ icon: 'play.circle.fill',
+ detail: `${courseIds.length} total courses`,
+ },
+ {
+ label: 'Completed',
+ value: completedCourses,
+ status: completedCourses > 0 ? 'ok' : 'neutral',
+ icon: 'checkmark.circle.fill',
+ },
+ {
+ label: 'Quizzes Done',
+ value: completedQuizzes.length,
+ status: 'neutral',
+ icon: 'doc.text.fill',
+ },
+ {
+ label: 'Avg Score',
+ value: completedQuizzes.length > 0 ? `${avgQuizScore}%` : 'N/A',
+ status:
+ avgQuizScore >= 80
+ ? 'ok'
+ : avgQuizScore >= thresholds.avgQuizScoreWarning
+ ? 'neutral'
+ : avgQuizScore > 0
+ ? 'warning'
+ : 'neutral',
+ icon: 'star.fill',
+ detail: `from ${completedQuizzes.length} attempt(s)`,
+ },
+ ],
+ };
+
+ // Role gates which sections are visible
+ const sections: DashboardSection[] =
+ role === 'admin'
+ ? [appHealthSection, notificationsSection, learningSection]
+ : role === 'instructor'
+ ? [notificationsSection, learningSection]
+ : [learningSection];
+
+ return {
+ role,
+ lastRefreshedAt,
+ alerts,
+ sections,
+ refresh,
+ isAutoRefreshEnabled: autoRefreshEnabled,
+ };
+}
diff --git a/src/store/index.ts b/src/store/index.ts
index c3a24dd..1986563 100644
--- a/src/store/index.ts
+++ b/src/store/index.ts
@@ -136,3 +136,4 @@ export const useAppStore = create()(
export * from './notificationStore';
export * from './courseProgressStore';
export * from './selectors';
+export * from './metricsStore';
diff --git a/src/store/metricsStore.ts b/src/store/metricsStore.ts
new file mode 100644
index 0000000..cedd501
--- /dev/null
+++ b/src/store/metricsStore.ts
@@ -0,0 +1,82 @@
+import { create } from 'zustand';
+import { persist, createJSONStorage } from 'zustand/middleware';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+export type DashboardRole = 'admin' | 'instructor' | 'student';
+export type AlertSeverity = 'info' | 'warning' | 'critical';
+
+export interface AlertThresholds {
+ errorCountWarning: number;
+ errorCountCritical: number;
+ avgQuizScoreWarning: number;
+}
+
+export interface DashboardAlert {
+ id: string;
+ severity: AlertSeverity;
+ message: string;
+ timestamp: number;
+}
+
+interface MetricsState {
+ // Configurable alert thresholds
+ thresholds: AlertThresholds;
+ // IDs of alerts the user has dismissed in this session
+ dismissedAlertIds: string[];
+ // Polling interval in ms (default 30s)
+ refreshIntervalMs: number;
+ // Whether auto-refresh is enabled
+ autoRefreshEnabled: boolean;
+ // Role view override (null = use the authenticated user's role)
+ roleOverride: DashboardRole | null;
+
+ // Actions
+ setThresholds: (thresholds: Partial) => void;
+ dismissAlert: (id: string) => void;
+ clearDismissedAlerts: () => void;
+ setRefreshInterval: (ms: number) => void;
+ setAutoRefresh: (enabled: boolean) => void;
+ setRoleOverride: (role: DashboardRole | null) => void;
+}
+
+const DEFAULT_THRESHOLDS: AlertThresholds = {
+ errorCountWarning: 2,
+ errorCountCritical: 5,
+ avgQuizScoreWarning: 60,
+};
+
+export const useMetricsStore = create()(
+ persist(
+ (set) => ({
+ thresholds: DEFAULT_THRESHOLDS,
+ dismissedAlertIds: [],
+ refreshIntervalMs: 30_000,
+ autoRefreshEnabled: true,
+ roleOverride: null,
+
+ setThresholds: (partial) =>
+ set((s) => ({ thresholds: { ...s.thresholds, ...partial } })),
+
+ dismissAlert: (id) =>
+ set((s) => ({ dismissedAlertIds: [...s.dismissedAlertIds, id] })),
+
+ clearDismissedAlerts: () => set({ dismissedAlertIds: [] }),
+
+ setRefreshInterval: (ms) => set({ refreshIntervalMs: ms }),
+
+ setAutoRefresh: (enabled) => set({ autoRefreshEnabled: enabled }),
+
+ setRoleOverride: (role) => set({ roleOverride: role }),
+ }),
+ {
+ name: 'metrics-dashboard-storage',
+ storage: createJSONStorage(() => AsyncStorage),
+ partialize: (state) => ({
+ thresholds: state.thresholds,
+ refreshIntervalMs: state.refreshIntervalMs,
+ autoRefreshEnabled: state.autoRefreshEnabled,
+ roleOverride: state.roleOverride,
+ }),
+ }
+ )
+);