DocDoeAI / src /components /analytics /AnalyticsView.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
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>
);
}