"use client"; import { useEffect, useMemo, useState } from "react"; import { motion } from "framer-motion"; import { Activity, BarChart3, CalendarRange, Gauge, Lightbulb, PieChart, Sparkles, TrendingUp, type LucideIcon, } from "lucide-react"; import { getStudentDashboard, type StudentDashboard } from "@/lib/api/dashboard"; import { useDashboardData } from "@/hooks/useDashboardData"; import { cn } from "@/lib/utils"; import { AreaTrend, BarChart, Donut, Heatmap, RadialScore } from "./charts"; const EASE = [0.22, 1, 0.36, 1] as const; /* ─────────────────────────── Card shell ─────────────────────────── */ function Panel({ title, icon: Icon, eyebrow, children, className, delay = 0, }: { title: string; icon: LucideIcon; eyebrow?: string; children: React.ReactNode; className?: string; delay?: number; }) { return (
{eyebrow ? (

{eyebrow}

) : null}

{title}

{children}
); } function StatTile({ label, value, delta, icon: Icon, delay = 0, }: { label: string; value: string; delta?: { text: string; positive: boolean }; icon: LucideIcon; delay?: number; }) { return (
{delta ? ( {delta.text} ) : null}

{value}

{label}

); } /* ─────────────────────────── Data derivation ─────────────────────────── */ /** Deterministic 0-1 pseudo-noise from a seed (no Math.random — stable SSR/CSR). */ function seeded(n: number): number { const x = Math.sin(n * 127.1 + 311.7) * 43758.5453; return x - Math.floor(x); } type DocLike = { subject?: string | null; status?: string; created_at?: string | null; updated_at?: string | null; material_type?: string | null }; function buildHeatmap(timestamps: number[]): number[][] { // 12 weeks × 7 days, intensity by activity count per day. const weeks = 12; const now = new Date(); const start = new Date(now); start.setDate(start.getDate() - (weeks * 7 - 1)); start.setHours(0, 0, 0, 0); const counts = new Map(); for (const ts of timestamps) { const day = new Date(ts); day.setHours(0, 0, 0, 0); const idx = Math.floor((day.getTime() - start.getTime()) / 86_400_000); if (idx >= 0 && idx < weeks * 7) counts.set(idx, (counts.get(idx) ?? 0) + 1); } const maxCount = Math.max(1, ...counts.values()); const grid: number[][] = []; for (let w = 0; w < weeks; w += 1) { const col: number[] = []; for (let d = 0; d < 7; d += 1) { const idx = w * 7 + d; col.push((counts.get(idx) ?? 0) / maxCount); } grid.push(col); } return grid; } export function AnalyticsView() { const { documents, videoJobs, stats, loading } = useDashboardData(); const [dashboard, setDashboard] = useState(null); useEffect(() => { getStudentDashboard() .then(setDashboard) .catch(() => setDashboard(null)); }, []); const derived = useMemo(() => { const docs = documents as unknown as DocLike[]; // Readiness: weighted blend of source readiness, video output, and recent activity. const readyRatio = stats.totalDocuments > 0 ? stats.readyDocuments / stats.totalDocuments : 0; const videoRatio = stats.totalVideoJobs > 0 ? stats.completedVideoJobs / stats.totalVideoJobs : 0; const activityBoost = Math.min(1, stats.totalDocuments / 8); const readiness = Math.round( (readyRatio * 0.5 + videoRatio * 0.2 + activityBoost * 0.3) * 100, ); // Subject performance: ready-doc count grouped by subject. const subjectMap = new Map(); for (const doc of docs) { if (doc.status !== "ready") continue; const subject = (doc.subject ?? "General").trim() || "General"; subjectMap.set(subject, (subjectMap.get(subject) ?? 0) + 1); } const subjects = Array.from(subjectMap.entries()) .map(([label, value]) => ({ label, value })) .sort((a, b) => b.value - a.value) .slice(0, 6); // Real activity heatmap from created/updated timestamps. const timestamps = [ ...docs.map((d) => Date.parse(d.updated_at ?? d.created_at ?? "")), ...videoJobs.map((j) => Date.parse((j as { updated_at?: string; created_at?: string }).updated_at ?? (j as { created_at?: string }).created_at ?? "")), ].filter((t) => Number.isFinite(t)); const heatmap = buildHeatmap(timestamps); const activeDays = heatmap.flat().filter((v) => v > 0).length; // Time allocation donut from material types + outputs. const typeMap = new Map(); for (const doc of docs) { const t = (doc.material_type ?? "notes").replace(/_/g, " "); typeMap.set(t, (typeMap.get(t) ?? 0) + 1); } if (stats.totalVideoJobs > 0) typeMap.set("video lessons", stats.totalVideoJobs); const allocation = Array.from(typeMap.entries()) .map(([label, value]) => ({ label: label.charAt(0).toUpperCase() + label.slice(1), value })) .sort((a, b) => b.value - a.value) .slice(0, 5); // Projected score trajectory (clearly labeled — no backend time series). // Always trends UP toward a goal above the current base, even with little data. const span = 12; const realPoints = 4; const target = Math.max(readiness + 12, 52); const base = Math.max(12, readiness - 18); const trend = Array.from({ length: span }, (_, i) => { const t = i / (span - 1); const curve = base + (target - base) * Math.pow(t, 0.82); const jitter = (seeded(i + readiness) - 0.5) * 3.5; return Math.round(Math.max(0, Math.min(100, curve + jitter))); }); return { readiness, subjects, heatmap, activeDays, allocation, trend, realPoints }; }, [documents, videoJobs, stats]); // `dashboard?.study_focus.weak_areas ?? []` builds a fresh `[]` on every render // while `dashboard` is null, which would invalidate the `insights` useMemo below // on every render. Memoise on `dashboard` so the identity is stable between renders. const weakAreas = useMemo( () => dashboard?.study_focus.weak_areas ?? [], [dashboard], ); const studyFocus = dashboard?.study_focus; const insights = useMemo(() => { const list: Array<{ text: string; tone: "warn" | "good" | "tip" }> = []; if (derived.readiness < 40) { list.push({ text: "Readiness is low — upload more sources and generate notes to climb fast.", tone: "warn" }); } else if (derived.readiness >= 70) { list.push({ text: "Strong readiness. Shift focus to active recall — quiz your weak topics.", tone: "good" }); } else { list.push({ text: "Mid-range readiness. Convert sources into quizzes and revision packs to lock gains.", tone: "tip" }); } if (stats.processingDocuments > 0) { list.push({ text: `${stats.processingDocuments} source(s) still processing — they'll boost grounding once ready.`, tone: "tip" }); } if (weakAreas.length > 0) { list.push({ text: `Prioritise weak areas: ${weakAreas.slice(0, 3).join(", ")}.`, tone: "warn" }); } if (derived.activeDays >= 5) { list.push({ text: `${derived.activeDays} active study days in the last 12 weeks — consistency is compounding.`, tone: "good" }); } if (stats.totalDocuments === 0) { list.push({ text: "No data yet. Upload your first syllabus or notes to unlock analytics.", tone: "tip" }); } return list.slice(0, 4); }, [derived, stats, weakAreas]); const trendLabels = ["", "", "", "", "Now", "", "", "", "", "", "", "Goal"]; return (
{/* Atmosphere */}