import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Link } from 'react-router-dom';
import {
AlertTriangle,
AlertCircle,
Target,
TrendingUp,
Shield,
Lightbulb,
Filter,
X,
ArrowUpRight,
ArrowRight,
RefreshCw,
CheckCircle2,
Minus,
ChevronUp,
ChevronDown,
} from 'lucide-react';
import { AppLayout } from '../../components/layout/AppLayout';
import { coachingApi } from '../../api/coachingApi';
import type { CoachingInsight, CoachingInsightsResponse, CoachingSeverity } from '../../types';
// ---------------------------------------------------------------------------
// Constants / styling maps
// ---------------------------------------------------------------------------
const REFRESH_INTERVAL_MS = 5 * 60 * 1000; // match backend 5-min cache TTL
const SEVERITY_ORDER: Record<CoachingSeverity, number> = { high: 0, medium: 1, low: 2 };
const severityCardStyles: Record<CoachingSeverity, string> = {
high: 'border-red-200 bg-red-50/60',
medium: 'border-amber-200 bg-amber-50/60',
low: 'border-emerald-200 bg-emerald-50/60',
};
const severityBadgeStyles: Record<CoachingSeverity, string> = {
high: 'bg-red-100 text-red-700',
medium: 'bg-amber-100 text-amber-700',
low: 'bg-emerald-100 text-emerald-700',
};
const severityIconStyles: Record<CoachingSeverity, string> = {
high: 'bg-red-100 text-red-600',
medium: 'bg-amber-100 text-amber-600',
low: 'bg-emerald-100 text-emerald-600',
};
const CATEGORY_META: Record<string, { label: string; icon: React.ElementType }> = {
market: { label: 'Market', icon: AlertTriangle },
forecast: { label: 'Forecast', icon: TrendingUp },
goal: { label: 'Goals', icon: Target },
scale: { label: 'Scale', icon: ArrowUpRight },
intelligence: { label: 'Intelligence', icon: Shield },
};
const ALL_CATEGORIES = ['market', 'forecast', 'goal', 'scale', 'intelligence'] as const;
function categoryMeta(category: string) {
return CATEGORY_META[category] ?? { label: category.charAt(0).toUpperCase() + category.slice(1), icon: Lightbulb };
}
function scoreTone(score: number): { text: string; stroke: string; label: string } {
if (score >= 80) return { text: 'text-emerald-600', stroke: 'stroke-emerald-500', label: 'Healthy' };
if (score >= 50) return { text: 'text-amber-600', stroke: 'stroke-amber-500', label: 'Needs attention' };
return { text: 'text-red-600', stroke: 'stroke-red-500', label: 'At risk' };
}
// Per-category score: 100 - (high*20) - (medium*10), floor 30 — mirrors backend formula.
function categoryScore(insights: CoachingInsight[]): number {
if (insights.length === 0) return 100;
const high = insights.filter((i) => i.severity === 'high').length;
const medium = insights.filter((i) => i.severity === 'medium').length;
return Math.max(30, 100 - high * 20 - medium * 10);
}
// ---------------------------------------------------------------------------
// Score gauge
// ---------------------------------------------------------------------------
function ScoreGauge({ score }: { score: number }) {
const tone = scoreTone(score);
return (
<div className="relative h-28 w-28 shrink-0">
<svg viewBox="0 0 36 36" className="h-full w-full -rotate-90">
<circle cx="18" cy="18" r="15.915" fill="none" stroke="#e5e7eb" strokeWidth="3.2" />
<circle
cx="18"
cy="18"
r="15.915"
fill="none"
strokeWidth="3.2"
strokeDasharray={`${score} 100`}
strokeLinecap="round"
className={`transition-all duration-700 ${tone.stroke}`}
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-3xl font-bold ${tone.text}`}>{score}</span>
<span className="text-[10px] font-medium uppercase tracking-wide text-surface-400">/ 100</span>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Insight card
// ---------------------------------------------------------------------------
function InsightCard({
insight,
onDismiss,
dismissing,
}: {
insight: CoachingInsight;
onDismiss: (id: string) => void;
dismissing: boolean;
}) {
const meta = categoryMeta(insight.category);
const Icon = meta.icon;
return (
<div className={`rounded-xl border p-5 transition-colors ${severityCardStyles[insight.severity]}`}>
<div className="flex items-start gap-4">
<div className={`mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${severityIconStyles[insight.severity]}`}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-surface-900">{insight.title}</h3>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold uppercase tracking-wide ${severityBadgeStyles[insight.severity]}`}>
{insight.severity}
</span>
<span className="rounded-full bg-white/80 px-2 py-0.5 text-xs font-medium text-surface-600 ring-1 ring-surface-200">
{meta.label}
</span>
</div>
<p className="mt-2 text-sm leading-relaxed text-surface-700">{insight.narrative}</p>
{insight.action_url && (
<Link
to={insight.action_url}
className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-brand-600 hover:text-brand-700"
>
Take action <ArrowUpRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
<button
onClick={() => onDismiss(insight.id)}
disabled={dismissing}
className="rounded-lg p-1.5 text-surface-400 transition-colors hover:bg-white/60 hover:text-surface-600 disabled:opacity-40"
title="Dismiss for 24 hours"
aria-label={`Dismiss ${insight.title}`}
>
<X className="h-4 w-4" />
</button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function CoachingPage() {
const [data, setData] = useState<CoachingInsightsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [categoryFilter, setCategoryFilter] = useState<'all' | string>('all');
const [severityFilter, setSeverityFilter] = useState<'all' | CoachingSeverity>('all');
const [dismissingIds, setDismissingIds] = useState<Set<string>>(new Set());
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [prevScore, setPrevScore] = useState<number | null>(null);
const loadInsights = useCallback(async (silent = false) => {
try {
if (silent) setRefreshing(true);
else setLoading(true);
const result = await coachingApi.getInsights();
setData((prev) => {
if (prev) setPrevScore(prev.overall_score);
return result;
});
setLastUpdated(new Date());
setError(null);
} catch (e) {
if (!silent) setError(e instanceof Error ? e.message : 'Failed to load coaching insights');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Initial load + auto-refresh every 5 minutes (matches backend cache TTL)
useEffect(() => {
loadInsights();
const interval = setInterval(() => loadInsights(true), REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [loadInsights]);
// Auto-dismiss toast
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(t);
}, [toast]);
const handleDismiss = async (id: string) => {
setDismissingIds((prev) => new Set(prev).add(id));
try {
await coachingApi.dismissInsight(id);
setData((prev) =>
prev
? { ...prev, insights: prev.insights.map((i) => (i.id === id ? { ...i, dismissed: true } : i)) }
: prev
);
setToast({ message: 'Insight dismissed for 24 hours', type: 'success' });
} catch {
setToast({ message: 'Failed to dismiss insight — please try again', type: 'error' });
} finally {
setDismissingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
};
const activeInsights = useMemo(
() =>
(data?.insights ?? [])
.filter((i) => !i.dismissed)
.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]),
[data]
);
const nextMove = activeInsights[0] ?? null;
const filteredInsights = useMemo(
() =>
activeInsights.filter(
(i) =>
(categoryFilter === 'all' || i.category === categoryFilter) &&
(severityFilter === 'all' || i.severity === severityFilter)
),
[activeInsights, categoryFilter, severityFilter]
);
const presentCategories = useMemo(() => {
const set = new Set(activeInsights.map((i) => i.category));
// Keep canonical order, then any extras the backend introduces
const ordered = ALL_CATEGORIES.filter((c) => set.has(c)) as string[];
for (const c of set) if (!ordered.includes(c)) ordered.push(c);
return ordered;
}, [activeInsights]);
const score = data?.overall_score ?? 100;
const tone = scoreTone(score);
const highCount = activeInsights.filter((i) => i.severity === 'high').length;
const mediumCount = activeInsights.filter((i) => i.severity === 'medium').length;
const lowCount = activeInsights.filter((i) => i.severity === 'low').length;
const dismissedCount = (data?.insights ?? []).filter((i) => i.dismissed).length;
const scoreDelta = prevScore !== null ? score - prevScore : 0;
if (loading && !data) {
return (
<AppLayout title="Smart Coaching">
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-brand-600 border-t-transparent" />
<span className="ml-3 text-sm text-surface-500">Loading coaching insights…</span>
</div>
</AppLayout>
);
}
return (
<AppLayout title="Smart Coaching">
{/* Toast */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-lg px-4 py-3 text-sm font-medium shadow-lg ${
toast.type === 'success' ? 'bg-surface-900 text-white' : 'bg-red-600 text-white'
}`}
>
{toast.type === 'success' ? <CheckCircle2 className="h-4 w-4" /> : <AlertCircle className="h-4 w-4" />}
{toast.message}
</div>
)}
{/* Header row */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold text-surface-900 sm:text-2xl">Smart Coaching</h1>
<p className="mt-1 text-sm text-surface-500">
Data-driven recommendations across your markets, forecasts, and goals
</p>
</div>
<div className="flex items-center gap-3">
{lastUpdated && (
<span className="text-xs text-surface-400">
Updated {lastUpdated.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
)}
<button
onClick={() => loadInsights(true)}
disabled={refreshing}
className="inline-flex items-center gap-1.5 rounded-lg border border-surface-200 bg-white px-3 py-1.5 text-sm font-medium text-surface-600 transition-colors hover:bg-surface-50 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
</div>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 p-8 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-red-400" />
<p className="mt-3 text-sm font-medium text-red-800">{error}</p>
<button
onClick={() => loadInsights()}
className="mt-3 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
Try again
</button>
</div>
) : (
<>
{/* Score + summary */}
<div className="mb-6 grid grid-cols-1 gap-4 lg:grid-cols-3">
{/* Health score */}
<div className="rounded-xl border border-surface-200 bg-white p-6">
<div className="flex items-center gap-5">
<ScoreGauge score={score} />
<div>
<p className="text-sm font-medium text-surface-500">Business Health Score</p>
<p className={`mt-1 text-lg font-semibold ${tone.text}`}>{tone.label}</p>
{prevScore !== null && scoreDelta !== 0 && (
<p
className={`mt-1 inline-flex items-center gap-0.5 text-xs font-medium ${
scoreDelta > 0 ? 'text-emerald-600' : 'text-red-600'
}`}
>
{scoreDelta > 0 ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
{Math.abs(scoreDelta)} pts since last refresh
</p>
)}
<p className="mt-1 text-xs text-surface-400">Recalculated every 5 minutes</p>
</div>
</div>
</div>
{/* Active alerts */}
<div className="rounded-xl border border-surface-200 bg-white p-6">
<p className="text-sm font-medium text-surface-500">Active Recommendations</p>
<p className="mt-2 text-4xl font-bold text-surface-900">{activeInsights.length}</p>
<div className="mt-3 flex items-center gap-3 text-xs font-medium">
<span className="inline-flex items-center gap-1 text-red-600">
<span className="h-2 w-2 rounded-full bg-red-500" /> {highCount} high
</span>
<span className="inline-flex items-center gap-1 text-amber-600">
<span className="h-2 w-2 rounded-full bg-amber-500" /> {mediumCount} medium
</span>
<span className="inline-flex items-center gap-1 text-emerald-600">
<span className="h-2 w-2 rounded-full bg-emerald-500" /> {lowCount} low
</span>
</div>
</div>
{/* Dismissed */}
<div className="rounded-xl border border-surface-200 bg-white p-6">
<p className="text-sm font-medium text-surface-500">Dismissed</p>
<p className="mt-2 text-4xl font-bold text-surface-900">{dismissedCount}</p>
<p className="mt-3 text-xs text-surface-400">Hidden for 24 hours</p>
</div>
</div>
{/* Next Move hero */}
{nextMove && (
<div className="mb-6 overflow-hidden rounded-xl border border-brand-200 bg-gradient-to-r from-brand-50 to-white">
<div className="flex flex-col gap-4 p-6 sm:flex-row sm:items-center">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-brand-100 text-brand-600">
<Lightbulb className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-bold uppercase tracking-wider text-brand-600">Your Next Move</p>
<span className={`rounded-full px-2 py-0.5 text-xs font-semibold uppercase ${severityBadgeStyles[nextMove.severity]}`}>
{nextMove.severity}
</span>
</div>
<h2 className="mt-1 text-lg font-bold text-surface-900">{nextMove.title}</h2>
<p className="mt-1 text-sm text-surface-600">{nextMove.narrative}</p>
</div>
{nextMove.action_url && (
<Link
to={nextMove.action_url}
className="inline-flex shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Take action <ArrowRight className="h-4 w-4" />
</Link>
)}
</div>
</div>
)}
{/* Category scorecards */}
{activeInsights.length > 0 && (
<div className="mb-6">
<h3 className="mb-3 text-sm font-semibold text-surface-700">Category Scorecards</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{ALL_CATEGORIES.map((cat) => {
const catInsights = activeInsights.filter((i) => i.category === cat);
const catScore = categoryScore(catInsights);
const catTone = scoreTone(catScore);
const meta = categoryMeta(cat);
const Icon = meta.icon;
const passing = catScore >= 80;
return (
<button
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? 'all' : cat)}
className={`rounded-xl border bg-white p-4 text-left transition-all hover:shadow-sm ${
categoryFilter === cat ? 'border-brand-400 ring-1 ring-brand-200' : 'border-surface-200'
}`}
>
<div className="flex items-center justify-between">
<Icon className="h-4 w-4 text-surface-400" />
{passing ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : catInsights.some((i) => i.severity === 'high') ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Minus className="h-4 w-4 text-amber-500" />
)}
</div>
<p className="mt-2 text-xs font-medium text-surface-500">{meta.label}</p>
<p className={`text-xl font-bold ${catTone.text}`}>{catScore}</p>
<p className="text-[11px] text-surface-400">
{catInsights.length === 0 ? 'No issues' : `${catInsights.length} alert${catInsights.length !== 1 ? 's' : ''}`}
</p>
</button>
);
})}
</div>
</div>
)}
{/* Filter bar */}
<div className="mb-4 flex flex-wrap items-center gap-2">
<Filter className="h-4 w-4 text-surface-400" />
<button
onClick={() => setCategoryFilter('all')}
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
categoryFilter === 'all' ? 'bg-brand-100 text-brand-700' : 'text-surface-500 hover:bg-surface-100'
}`}
>
All categories
</button>
{presentCategories.map((cat) => {
const meta = categoryMeta(cat);
return (
<button
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? 'all' : cat)}
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
categoryFilter === cat ? 'bg-brand-100 text-brand-700' : 'text-surface-500 hover:bg-surface-100'
}`}
>
{meta.label}
</button>
);
})}
<span className="mx-2 h-4 w-px bg-surface-200" />
{(['all', 'high', 'medium', 'low'] as const).map((sev) => (
<button
key={sev}
onClick={() => setSeverityFilter(sev)}
className={`rounded-full px-3 py-1.5 text-sm font-medium capitalize transition-colors ${
severityFilter === sev ? 'bg-surface-900 text-white' : 'text-surface-500 hover:bg-surface-100'
}`}
>
{sev === 'all' ? 'Any severity' : sev}
</button>
))}
<span className="ml-auto text-xs text-surface-400">
{filteredInsights.length} insight{filteredInsights.length !== 1 ? 's' : ''}
</span>
</div>
{/* Insights feed */}
{filteredInsights.length === 0 ? (
<div className="rounded-xl border border-surface-200 bg-white p-12 text-center">
<CheckCircle2 className="mx-auto h-12 w-12 text-emerald-300" />
<h3 className="mt-4 text-lg font-semibold text-surface-900">
{activeInsights.length === 0 ? 'All clear!' : 'No matching insights'}
</h3>
<p className="mx-auto mt-2 max-w-md text-sm text-surface-500">
{activeInsights.length === 0
? 'No coaching recommendations right now — your metrics are looking good. We re-check every 5 minutes.'
: 'Try adjusting your category or severity filters to see other insights.'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredInsights.map((insight) => (
<InsightCard
key={insight.id}
insight={insight}
onDismiss={handleDismiss}
dismissing={dismissingIds.has(insight.id)}
/>
))}
</div>
)}
</>
)}
</AppLayout>
);
}