MetaDebate / web-ui /components /RetentionChart.tsx
vajeeda's picture
final mvp created
98b952a
Raw
History Blame Contribute Delete
7.18 kB
"use client";
import { useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
type Point = { t: number; before: number; after: number };
const DROP_REASONS: Record<number, string> = {
6: "Weak hook caused early drop-off β€” viewers didn't engage with the opening line",
12: "Mid-section lacks pacing β€” generic advice triggered attention drop",
20: "CTA appeared too early β€” created friction before value was delivered",
30: "Momentum lost after repositioned CTA β€” body needs stronger bridge",
45: "Engagement floor reached β€” retained viewers are highly-interested segment",
60: "End-card CTA fires here β€” drop is expected at natural completion point",
};
function auc(points: Point[], key: "before" | "after"): number {
let area = 0;
for (let i = 1; i < points.length; i++) {
const dt = points[i].t - points[i - 1].t;
area += ((points[i][key] + points[i - 1][key]) / 2) * dt;
}
return area / (points[points.length - 1].t * 100);
}
interface CustomDotProps {
cx?: number;
cy?: number;
payload?: Point;
dataKey?: string;
activePoint?: number | null;
onHover?: (t: number | null) => void;
}
function CustomDot({ cx = 0, cy = 0, payload, dataKey, activePoint, onHover }: CustomDotProps) {
if (!payload || !onHover) return null;
const isActive = activePoint === payload.t;
const color = dataKey === "after" ? "#8b5cf6" : "#4b3a7a";
return (
<circle
cx={cx}
cy={cy}
r={isActive ? 7 : 4}
fill={color}
stroke="#09080f"
strokeWidth={2}
style={{ cursor: "pointer" }}
onMouseEnter={() => onHover(payload.t)}
onMouseLeave={() => onHover(null)}
/>
);
}
export function RetentionChart({ data }: { data: Point[] }) {
const [activePoint, setActivePoint] = useState<number | null>(null);
const aucBefore = auc(data, "before");
const aucAfter = auc(data, "after");
const dropPoint = data.find((p, i) => i > 0 && p.before < data[i - 1].before - 10);
const dropAfterPoint = data.find((p, i) => i > 0 && p.after < data[i - 1].after - 10);
return (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-white">Retention Curve (0–60s)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} onMouseLeave={() => setActivePoint(null)}>
<XAxis
dataKey="t"
tick={{ fill: "#a78bfa", fontSize: 12 }}
label={{ value: "Time (seconds)", position: "insideBottom", offset: -2, fontSize: 11, fill: "#a78bfa" }}
/>
<YAxis
tick={{ fill: "#a78bfa", fontSize: 12 }}
label={{ value: "Viewers (%)", angle: -90, position: "insideLeft", offset: 10, fontSize: 11, fill: "#a78bfa" }}
/>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length) return null;
const t = payload[0].payload.t as number;
const reason = DROP_REASONS[t];
return (
<div className="rounded-xl border border-purple-700/40 bg-[#120f1e] p-3 shadow-md text-xs max-w-[220px]">
<p className="font-semibold mb-1 text-white">t = {t}s</p>
<p className="text-purple-300">Before: {payload.find((p) => p.dataKey === "before")?.value}%</p>
<p className="text-violet-400">After: {payload.find((p) => p.dataKey === "after")?.value}%</p>
{reason && <p className="mt-2 text-purple-400/70 italic">{reason}</p>}
</div>
);
}}
/>
<Line
type="monotone"
dataKey="before"
stroke="#4b3a7a"
strokeWidth={2}
isAnimationActive
animationDuration={800}
dot={(props) => (
<CustomDot {...props} dataKey="before" activePoint={activePoint} onHover={setActivePoint} />
)}
/>
<Line
type="monotone"
dataKey="after"
stroke="#8b5cf6"
strokeWidth={3}
isAnimationActive
animationDuration={1000}
dot={(props) => (
<CustomDot {...props} dataKey="after" activePoint={activePoint} onHover={setActivePoint} />
)}
/>
</LineChart>
</ResponsiveContainer>
</div>
<AnimatePresence>
{activePoint !== null && DROP_REASONS[activePoint] && (
<motion.div
key={activePoint}
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="rounded-xl bg-purple-900/40 border border-purple-700/40 px-4 py-2 text-sm text-purple-200"
>
<span className="font-semibold text-white">t={activePoint}s β€” </span>
{DROP_REASONS[activePoint]}
</motion.div>
)}
</AnimatePresence>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm text-white">Retention Analysis</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3 text-sm">
<div>
<p className="text-xs text-purple-400/70 mb-1">AUC Before β†’ After</p>
<p className="font-bold text-violet-400">
{aucBefore.toFixed(2)} β†’ {aucAfter.toFixed(2)}
</p>
<p className="text-xs text-emerald-400 mt-0.5">
+{(((aucAfter - aucBefore) / aucBefore) * 100).toFixed(0)}% improvement
</p>
</div>
<div>
<p className="text-xs text-purple-400/70 mb-1">First Major Drop</p>
<p className="font-bold text-purple-100">
{dropPoint?.t ?? "β€”"}s β†’ {dropAfterPoint?.t ?? "β€”"}s
</p>
<p className="text-xs text-emerald-400 mt-0.5">Drop point moved later</p>
</div>
<div>
<p className="text-xs text-purple-400/70 mb-1">Explanation</p>
<p className="text-purple-200/70 text-xs">
Hook rewrite improved early engagement by delaying the first major drop-off and creating a stronger open loop in the first 6 seconds.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}