From a31fdfe55a15aa3f04f8a244a6bc5dd4e2bcae4f Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 13:48:37 -0800 Subject: [PATCH] feat(predictive-analytics): complete admin UI for churn prediction and health scoring - Add health-dashboard page with 6-dimension health cards and anomaly detection - Add predictive/at-risk page with user risk profiles and segmentation - Add predictive/campaigns page with campaign management and stats - Add predictive-client.ts API client with full type coverage - Update all 3 roadmaps to reflect complete implementation status --- .../app/(dashboard)/health-dashboard/page.tsx | 328 +++++++++++ .../(dashboard)/predictive/at-risk/page.tsx | 404 +++++++++++++ .../(dashboard)/predictive/campaigns/page.tsx | 539 ++++++++++++++++++ .../admin-web/src/lib/predictive-client.ts | 250 ++++++++ .../AI_DIAGNOSTIC_ASSISTANT_ROADMAP.md | 12 +- .../INTELLIGENT_AB_TESTING_ROADMAP.md | 12 +- ...PREDICTIVE_CHURN_HEALTH_SCORING_ROADMAP.md | 14 +- .../predictive-analytics/anomaly-detection.ts | 4 +- .../predictive-analytics/feature-extractor.ts | 3 + .../predictive-analytics.test.ts | 15 +- 10 files changed, 1555 insertions(+), 26 deletions(-) create mode 100644 dashboards/admin-web/src/app/(dashboard)/health-dashboard/page.tsx create mode 100644 dashboards/admin-web/src/app/(dashboard)/predictive/at-risk/page.tsx create mode 100644 dashboards/admin-web/src/app/(dashboard)/predictive/campaigns/page.tsx create mode 100644 dashboards/admin-web/src/lib/predictive-client.ts diff --git a/dashboards/admin-web/src/app/(dashboard)/health-dashboard/page.tsx b/dashboards/admin-web/src/app/(dashboard)/health-dashboard/page.tsx new file mode 100644 index 00000000..30a5dba9 --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/health-dashboard/page.tsx @@ -0,0 +1,328 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Activity, + TrendingUp, + TrendingDown, + Minus, + Users, + Zap, + DollarSign, + Shield, + AlertTriangle, + ArrowRight, + RefreshCw, +} from 'lucide-react'; +import Link from 'next/link'; +import { getProductHealth, type ProductHealth, type HealthDimension } from '@/lib/predictive-client'; + +interface HealthScoreCardProps { + title: string; + dimension: HealthDimension; + icon: React.ReactNode; +} + +function HealthScoreCard({ title, dimension, icon }: HealthScoreCardProps) { + const getScoreColor = (score: number) => { + if (score >= 80) return 'text-green-500'; + if (score >= 60) return 'text-yellow-500'; + return 'text-red-500'; + }; + + const getTrendIcon = (trend: string) => { + switch (trend) { + case 'improving': + return ; + case 'declining': + return ; + default: + return ; + } + }; + + return ( + + + {title} + {icon} + + +
+ + {dimension.score} + + /100 +
+
+ {getTrendIcon(dimension.trend)} + {dimension.trend} +
+ {Object.entries(dimension.metrics).slice(0, 2).map(([key, value]) => ( +
+ {key}: {typeof value === 'number' ? value.toFixed(1) : value} +
+ ))} +
+
+ ); +} + +export default function HealthDashboardPage() { + const [healthData, setHealthData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedProduct, setSelectedProduct] = useState('all'); + + useEffect(() => { + loadHealthData(); + }, [selectedProduct]); + + async function loadHealthData() { + try { + setLoading(true); + setError(null); + const data = await getProductHealth(selectedProduct === 'all' ? undefined : selectedProduct); + setHealthData(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load health data'); + } finally { + setLoading(false); + } + } + + const getStatusBadge = (status: string) => { + switch (status) { + case 'healthy': + return Healthy; + case 'warning': + return Warning; + case 'critical': + return Critical; + default: + return {status}; + } + }; + + const latestHealth = healthData[0]; + + if (loading) { + return ( +
+ +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+
+ ); + } + + if (error) { + return ( +
+ + + {error} + + +
+ ); + } + + return ( +
+
+
+

Product Health Dashboard

+

+ Monitor product health across 6 dimensions with ML-powered predictions +

+
+
+ + +
+
+ + {latestHealth && ( + <> + {/* Overall Health */} + + +
+
+ + + Overall Health Score + + + {latestHealth.productId} — Last updated: {new Date(latestHealth.date).toLocaleDateString()} + +
+
+
+
{latestHealth.overallHealthScore}
+
/100
+
+ {getStatusBadge(latestHealth.healthStatus)} +
+
+
+ +
+
+ vs 7d: + = 0 ? 'text-green-500' : 'text-red-500'}> + {latestHealth.vsBaseline7Day >= 0 ? '+' : ''}{latestHealth.vsBaseline7Day.toFixed(1)}% + +
+
+ vs 30d: + = 0 ? 'text-green-500' : 'text-red-500'}> + {latestHealth.vsBaseline30Day >= 0 ? '+' : ''}{latestHealth.vsBaseline30Day.toFixed(1)}% + +
+
+ {latestHealth.forecasts && ( +
+
+ Next 7 days:{' '} + {latestHealth.forecasts.next7Days.expectedHealthScore} + + {' '}(±{((latestHealth.forecasts.next7Days.confidenceInterval[1] - latestHealth.forecasts.next7Days.confidenceInterval[0]) / 2).toFixed(1)}) + +
+
+ Next 30 days:{' '} + {latestHealth.forecasts.next30Days.expectedHealthScore} + + {' '}(±{((latestHealth.forecasts.next30Days.confidenceInterval[1] - latestHealth.forecasts.next30Days.confidenceInterval[0]) / 2).toFixed(1)}) + +
+
+ )} +
+
+ + {/* Dimension Cards */} +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+ + {/* Anomalies */} + {latestHealth.anomalies && latestHealth.anomalies.length > 0 && ( + + + + + Detected Anomalies ({latestHealth.anomalies.length}) + + + +
+ {latestHealth.anomalies.map((anomaly, idx) => ( +
+
+
{anomaly.metric}
+
+ Expected: {anomaly.expectedValue.toFixed(2)} • Actual: {anomaly.actualValue.toFixed(2)} +
+ {anomaly.suggestedCause && ( +
+ Suggested cause: {anomaly.suggestedCause} +
+ )} +
+ + {anomaly.deviationPercent > 0 ? '+' : ''}{anomaly.deviationPercent.toFixed(1)}% + +
+ ))} +
+
+
+ )} + + {/* Navigation */} +
+ + + + + + +
+ + )} + + {!latestHealth && !loading && ( + + + No health data available. Health scores are computed daily from telemetry data. + + + )} +
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/predictive/at-risk/page.tsx b/dashboards/admin-web/src/app/(dashboard)/predictive/at-risk/page.tsx new file mode 100644 index 00000000..d32b68dd --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/predictive/at-risk/page.tsx @@ -0,0 +1,404 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { + AlertTriangle, + Users, + RefreshCw, + Search, + ArrowRight, + TrendingUp, + Mail, + Activity, +} from 'lucide-react'; +import Link from 'next/link'; +import { + getAtRiskUsers, + getUserRiskProfile, + type AtRiskUser, + type RiskSegment, + type UserRiskProfile, +} from '@/lib/predictive-client'; + +const riskSegmentConfig: Record = { + critical: { color: 'bg-red-500', label: 'Critical Risk', icon: }, + high: { color: 'bg-orange-500', label: 'High Risk', icon: }, + medium: { color: 'bg-yellow-500', label: 'Medium Risk', icon: }, + low: { color: 'bg-green-500', label: 'Low Risk', icon: }, +}; + +function UserDetailDialog({ userId, productId }: { userId: string; productId: string }) { + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadProfile(); + }, [userId, productId]); + + async function loadProfile() { + try { + const data = await getUserRiskProfile(userId); + setProfile(data); + } catch (err) { + console.error('Failed to load user profile:', err); + } finally { + setLoading(false); + } + } + + if (loading) { + return ; + } + + if (!profile) { + return Failed to load user profile; + } + + return ( +
+
+
+
Churn Probability
+
{(profile.churnProbability * 100).toFixed(1)}%
+
+ + {riskSegmentConfig[profile.riskSegment].label} + +
+ + {profile.explanation && ( +
+
AI Explanation
+

{profile.explanation.nlExplanation}

+ +
Top Risk Factors
+
+ {profile.explanation.topRiskFactors.slice(0, 5).map((factor, idx) => ( +
+ {factor.feature} + + {(factor.contribution * 100).toFixed(1)}% + +
+ ))} +
+ + {profile.explanation.suggestedActions.length > 0 && ( + <> +
Suggested Actions
+
    + {profile.explanation.suggestedActions.map((action, idx) => ( +
  • {action}
  • + ))} +
+ + )} +
+ )} + + {profile.interventionHistory && profile.interventionHistory.length > 0 && ( +
+
Intervention History
+
+ {profile.interventionHistory.map((intervention, idx) => ( +
+ {intervention.action} + + {new Date(intervention.timestamp).toLocaleDateString()} + + {intervention.outcome && ( + + {intervention.outcome} + + )} +
+ ))} +
+
+ )} +
+ ); +} + +export default function AtRiskUsersPage() { + const [users, setUsers] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedSegment, setSelectedSegment] = useState(undefined); + const [selectedProduct, setSelectedProduct] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [offset, setOffset] = useState(0); + const limit = 20; + + useEffect(() => { + loadUsers(); + }, [selectedSegment, selectedProduct, offset]); + + async function loadUsers() { + try { + setLoading(true); + setError(null); + const result = await getAtRiskUsers({ + productId: selectedProduct || undefined, + segment: selectedSegment, + limit, + offset, + }); + setUsers(result.users); + setTotal(result.total); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load users'); + } finally { + setLoading(false); + } + } + + const filteredUsers = users.filter((user) => + searchQuery ? user.userId.toLowerCase().includes(searchQuery.toLowerCase()) : true + ); + + const segmentCounts = { + critical: users.filter((u) => u.riskSegment === 'critical').length, + high: users.filter((u) => u.riskSegment === 'high').length, + medium: users.filter((u) => u.riskSegment === 'medium').length, + low: users.filter((u) => u.riskSegment === 'low').length, + }; + + return ( +
+
+
+

At-Risk Users

+

+ ML-powered churn prediction identifying users likely to churn in the next 30 days +

+
+ +
+ + {/* Summary Cards */} +
+ + + Critical Risk + + + +
{segmentCounts.critical}
+

Immediate action required

+
+
+ + + High Risk + + + +
{segmentCounts.high}
+

Proactive outreach recommended

+
+
+ + + Medium Risk + + + +
{segmentCounts.medium}
+

Monitor closely

+
+
+ + + Total At-Risk + + + +
{total}
+

Users tracked

+
+
+
+ + {/* Filters */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + + + + +
+ + {error && ( + + {error} + + )} + + {/* Users Table */} + + + At-Risk Users + + Users ranked by churn probability. Click a row to view detailed risk analysis. + + + + {loading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + <> + + + + User ID + Product + Risk Segment + Churn Probability + Confidence + Last Active + Action + + + + {filteredUsers.length === 0 ? ( + + + No at-risk users found + + + ) : ( + filteredUsers.map((user) => ( + + {user.userId} + {user.productId} + + + {riskSegmentConfig[user.riskSegment].icon} + {user.riskSegment} + + + {(user.churnProbability * 100).toFixed(1)}% + {(user.confidenceScore * 100).toFixed(0)}% + {user.daysSinceLastSession} days ago + + + + + + + + User Risk Profile + + + + + + + )) + )} + +
+ + {/* Pagination */} +
+
+ Showing {offset + 1}-{Math.min(offset + filteredUsers.length, total)} of {total} users +
+
+ + +
+
+ + )} +
+
+
+ ); +} diff --git a/dashboards/admin-web/src/app/(dashboard)/predictive/campaigns/page.tsx b/dashboards/admin-web/src/app/(dashboard)/predictive/campaigns/page.tsx new file mode 100644 index 00000000..4aec43ef --- /dev/null +++ b/dashboards/admin-web/src/app/(dashboard)/predictive/campaigns/page.tsx @@ -0,0 +1,539 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { + Mail, + RefreshCw, + Plus, + Play, + Pause, + BarChart3, + Users, + MousePointer, + CheckCircle, + TrendingUp, + AlertTriangle, +} from 'lucide-react'; +import Link from 'next/link'; +import { + listCampaigns, + createCampaign, + updateCampaign, + triggerCampaign, + getCampaignStats, + type Campaign, + type CampaignStatus, + type CampaignTriggerType, + type CampaignChannel, +} from '@/lib/predictive-client'; + +const statusConfig: Record = { + draft: { color: 'bg-gray-500', icon: }, + active: { color: 'bg-green-500', icon: }, + paused: { color: 'bg-yellow-500', icon: }, + completed: { color: 'bg-blue-500', icon: }, +}; + +const triggerLabels: Record = { + churn_risk: 'Churn Risk Detected', + health_score_drop: 'Health Score Drop', + behavioral: 'Behavioral Trigger', + scheduled: 'Scheduled', +}; + +const channelLabels: Record = { + email: 'Email', + push: 'Push Notification', + in_app: 'In-App Message', + slack_cs: 'Slack CS Alert', +}; + +function CreateCampaignDialog({ onCreated }: { onCreated: () => void }) { + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [productId, setProductId] = useState(''); + const [triggerType, setTriggerType] = useState('churn_risk'); + const [riskSegments, setRiskSegments] = useState(['critical', 'high']); + + async function handleCreate() { + try { + setCreating(true); + await createCampaign({ + name, + description, + productId, + trigger: { + type: triggerType, + conditions: [ + { field: 'riskSegment', operator: 'in', value: riskSegments }, + ], + }, + audience: { + riskSegments, + excludeRecentContact: 24, + }, + messages: [ + { + channel: 'email', + templateId: 'retention_check_in', + delayHours: 0, + }, + ], + }); + setOpen(false); + onCreated(); + } catch (err) { + console.error('Failed to create campaign:', err); + } finally { + setCreating(false); + } + } + + return ( + + + + + + + Create Retention Campaign + +
+
+ + setName(e.target.value)} placeholder="e.g., Q1 Churn Prevention" /> +
+
+ +