Download src/components/analytics/AnalyticsView.tsx from asnannp/DocDoeAI: direct link, hf CLI and curl.
- Browser
- Download file 16.3 kB
-
https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/analytics/AnalyticsView.tsx
- Command line
-
hf download hf://spaces/asnannp/DocDoeAI/src/components/analytics/AnalyticsView.tsx
-
curl -L -o AnalyticsView.tsx https://huggingface.co/spaces/asnannp/DocDoeAI/resolve/main/src/components/analytics/AnalyticsView.tsx
16.3 kB
| "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 ( | |
| <motion.section | |
| initial={{ opacity: 0, y: 16 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.55, ease: EASE, delay }} | |
| className={cn( | |
| "kb-card-shimmer relative overflow-hidden rounded-[1.35rem] border border-white/[0.09] bg-[#08080d]/90 p-5 shadow-[0_16px_60px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.03)_inset] backdrop-blur-sm", | |
| className, | |
| )} | |
| > | |
| <div className="mb-4 flex items-center gap-2.5"> | |
| <span className="grid size-8 shrink-0 place-items-center rounded-lg bg-white/[0.06] text-white/55"> | |
| <Icon className="size-[15px]" /> | |
| </span> | |
| <div className="min-w-0"> | |
| {eyebrow ? ( | |
| <p className="text-[9px] font-bold uppercase tracking-[0.16em] text-white/30">{eyebrow}</p> | |
| ) : null} | |
| <h2 className="text-[14px] font-bold leading-tight text-white">{title}</h2> | |
| </div> | |
| </div> | |
| {children} | |
| </motion.section> | |
| ); | |
| } | |
| function StatTile({ | |
| label, | |
| value, | |
| delta, | |
| icon: Icon, | |
| delay = 0, | |
| }: { | |
| label: string; | |
| value: string; | |
| delta?: { text: string; positive: boolean }; | |
| icon: LucideIcon; | |
| delay?: number; | |
| }) { | |
| return ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 14 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.5, ease: EASE, delay }} | |
| className="kb-card-shimmer relative overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.025] p-4" | |
| > | |
| <div className="flex items-center justify-between"> | |
| <span className="grid size-8 place-items-center rounded-lg bg-white/[0.06] text-white/50"> | |
| <Icon className="size-4" /> | |
| </span> | |
| {delta ? ( | |
| <span | |
| className={cn( | |
| "text-[11px] font-bold", | |
| delta.positive ? "text-emerald-400/90" : "text-white/40", | |
| )} | |
| > | |
| {delta.text} | |
| </span> | |
| ) : null} | |
| </div> | |
| <p className="mt-3 text-[1.65rem] font-bold leading-none tracking-[-0.02em] text-white">{value}</p> | |
| <p className="mt-1.5 text-[11px] font-medium text-white/40">{label}</p> | |
| </motion.div> | |
| ); | |
| } | |
| /* ─────────────────────────── 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<number, number>(); | |
| 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<StudentDashboard | null>(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<string, number>(); | |
| 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<string, number>(); | |
| 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 ( | |
| <div className="kb-workspace relative min-h-full overflow-y-auto bg-[#030303] px-4 pb-16 pt-6 text-white sm:px-6 lg:px-8"> | |
| {/* Atmosphere */} | |
| <div className="kb-atmosphere pointer-events-none" aria-hidden="true"> | |
| <div className="kb-atmosphere-mesh" /> | |
| <div className="kb-atmosphere-fog kb-atmosphere-fog-top" /> | |
| <div className="kb-atmosphere-grid" /> | |
| <div className="kb-atmosphere-vignette" /> | |
| </div> | |
| <div className="relative z-10 mx-auto w-full max-w-6xl"> | |
| {/* Header */} | |
| <motion.div | |
| initial={{ opacity: 0, y: -10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.5, ease: EASE }} | |
| className="mb-7" | |
| > | |
| <div className="inline-flex items-center gap-2 rounded-full border border-white/[0.12] bg-white/[0.05] px-3.5 py-1.5 text-[11px] font-bold uppercase tracking-[0.12em] text-white/55"> | |
| <Activity className="size-3" /> | |
| Performance analytics | |
| </div> | |
| <h1 className="kb-hero-glow mt-4 text-[2rem] font-bold leading-[1.05] tracking-[-0.03em] text-white sm:text-[2.6rem]"> | |
| Where your marks are coming from | |
| </h1> | |
| <p className="mt-2.5 max-w-2xl text-[14px] leading-[1.7] text-white/50 sm:text-[15px]"> | |
| Live signals from your sources, generations, and study activity — turned into a single readiness picture. | |
| </p> | |
| </motion.div> | |
| {/* Stat tiles */} | |
| <div className="mb-5 grid grid-cols-2 gap-3 lg:grid-cols-4"> | |
| <StatTile | |
| label="Ready sources" | |
| value={`${stats.readyDocuments}/${stats.totalDocuments}`} | |
| icon={BarChart3} | |
| delay={0.05} | |
| /> | |
| <StatTile | |
| label="Video lessons" | |
| value={String(stats.completedVideoJobs)} | |
| delta={stats.activeVideoJobs > 0 ? { text: `${stats.activeVideoJobs} rendering`, positive: true } : undefined} | |
| icon={Sparkles} | |
| delay={0.1} | |
| /> | |
| <StatTile | |
| label="Active study days" | |
| value={String(derived.activeDays)} | |
| delta={{ text: "12-wk", positive: true }} | |
| icon={CalendarRange} | |
| delay={0.15} | |
| /> | |
| <StatTile | |
| label="Knowledge chunks" | |
| value={stats.totalChunks > 999 ? `${(stats.totalChunks / 1000).toFixed(1)}k` : String(stats.totalChunks)} | |
| icon={Activity} | |
| delay={0.2} | |
| /> | |
| </div> | |
| {/* Main grid */} | |
| <div className="grid gap-4 lg:grid-cols-3"> | |
| {/* Readiness gauge */} | |
| <Panel title="Exam readiness" icon={Gauge} eyebrow="Composite score" delay={0.25}> | |
| <div className="flex flex-col items-center"> | |
| <RadialScore | |
| value={derived.readiness} | |
| label={derived.readiness >= 70 ? "Exam-ready" : derived.readiness >= 40 ? "On track" : "Building up"} | |
| sublabel={studyFocus?.exam ?? undefined} | |
| /> | |
| <p className="mt-2 text-center text-[11px] leading-5 text-white/35"> | |
| Blends source readiness, generated lessons, and study activity. | |
| </p> | |
| </div> | |
| </Panel> | |
| {/* Score trajectory */} | |
| <Panel title="Predicted score trajectory" icon={TrendingUp} eyebrow="Projection" className="lg:col-span-2" delay={0.3}> | |
| <AreaTrend data={derived.trend} labels={trendLabels} projectedFrom={derived.realPoints} height={208} /> | |
| <div className="mt-3 flex items-center gap-4 text-[10px] font-medium text-white/35"> | |
| <span className="flex items-center gap-1.5"><span className="h-0.5 w-4 rounded-full bg-white/90" /> Current path</span> | |
| <span className="flex items-center gap-1.5"><span className="h-0.5 w-4 rounded-full border-t border-dashed border-white/45" /> Projected to goal</span> | |
| </div> | |
| </Panel> | |
| {/* Subject performance */} | |
| <Panel title="Subject coverage" icon={BarChart3} eyebrow="Ready material by subject" className="lg:col-span-2" delay={0.35}> | |
| {derived.subjects.length > 0 ? ( | |
| <BarChart data={derived.subjects} /> | |
| ) : ( | |
| <p className="py-6 text-center text-[13px] text-white/40">Upload sources tagged by subject to see coverage.</p> | |
| )} | |
| </Panel> | |
| {/* Time allocation */} | |
| <Panel title="Material mix" icon={PieChart} eyebrow="By type" delay={0.4}> | |
| {derived.allocation.length > 0 ? ( | |
| <Donut segments={derived.allocation} /> | |
| ) : ( | |
| <p className="py-6 text-center text-[13px] text-white/40">No materials yet.</p> | |
| )} | |
| </Panel> | |
| {/* Activity heatmap */} | |
| <Panel title="Study activity" icon={CalendarRange} eyebrow="Last 12 weeks" className="lg:col-span-2" delay={0.45}> | |
| <Heatmap weeks={derived.heatmap} /> | |
| <div className="mt-3 flex items-center gap-2 text-[10px] font-medium text-white/35"> | |
| <span>Less</span> | |
| {[0.04, 0.3, 0.55, 0.8, 1].map((v) => ( | |
| <span | |
| key={v} | |
| className="size-2.5 rounded-[3px]" | |
| style={{ backgroundColor: v < 0.1 ? "rgba(255,255,255,0.04)" : `rgba(255,255,255,${0.15 + v * 0.75})` }} | |
| /> | |
| ))} | |
| <span>More</span> | |
| </div> | |
| </Panel> | |
| {/* Insights */} | |
| <Panel title="AI insights" icon={Lightbulb} eyebrow="What to do next" delay={0.5}> | |
| <ul className="space-y-2.5"> | |
| {insights.map((insight, index) => ( | |
| <li key={index} className="flex items-start gap-2.5 rounded-xl border border-white/[0.06] bg-white/[0.02] px-3 py-2.5"> | |
| <span | |
| className={cn( | |
| "mt-1.5 size-1.5 shrink-0 rounded-full", | |
| insight.tone === "warn" ? "bg-amber-400" : insight.tone === "good" ? "bg-emerald-400" : "bg-white/50", | |
| )} | |
| /> | |
| <span className="text-[12.5px] leading-[1.5] text-white/68">{insight.text}</span> | |
| </li> | |
| ))} | |
| </ul> | |
| </Panel> | |
| </div> | |
| {loading ? ( | |
| <p className="mt-6 text-center text-[12px] text-white/30">Syncing your latest study data…</p> | |
| ) : ( | |
| <p className="mt-6 text-center text-[11px] text-white/25"> | |
| Trajectory is a projection from current signals — not a guarantee. Heatmap and coverage reflect real uploads. | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |