"use client"; import { useEffect, useRef, useState, type FormEvent } from "react"; import Link from "next/link"; import { AppShell } from "@/components/app-shell"; import { ActivityHeatmap } from "@/components/dashboard/activity-heatmap"; import { FillerBreakdown } from "@/components/dashboard/filler-breakdown"; import { OverviewCards } from "@/components/dashboard/overview-cards"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select } from "@/components/ui/select"; import { apiFetch } from "@/lib/api"; import { dashboardApi, type SessionComparisonResponse, type WeeklyReviewResponse, } from "@/lib/dashboard"; import { useRequireAuth } from "@/lib/hooks/useRequireAuth"; import { cn } from "@/lib/utils"; import { useDashboardStore } from "@/store/useDashboardStore"; type SessionListItem = { id: number; title?: string; mode: string; created_at: string; duration_ms: number; wpm_avg: number; filler_count: number; video_available?: boolean; curriculum_day?: number | null; score?: { total?: number } | null; }; const COMPARE_LABELS: Record = { overall_score: { label: "Overall Score", suffix: " pts" }, wpm: { label: "Speaking Speed", suffix: " WPM" }, filler_rate: { label: "Fillers / Min", suffix: "/m", lowerIsBetter: true }, delivery_score: { label: "Delivery Score", suffix: " pts" }, speech_score: { label: "Speech Clarity", suffix: " pts" }, eye_contact_score: { label: "Lens Eye-Line", suffix: "" }, }; export default function ProgressPage() { const { ready } = useRequireAuth(); const overview = useDashboardStore((state) => state.overview); const heatmap = useDashboardStore((state) => state.heatmap); const fillers = useDashboardStore((state) => state.fillers); const fetchAll = useDashboardStore((state) => state.fetchAll); const [weeksAgo, setWeeksAgo] = useState(0); const [weekly, setWeekly] = useState(null); const [winsNote, setWinsNote] = useState(""); const [focusNote, setFocusNote] = useState(""); const [savingNote, setSavingNote] = useState(false); const [noteSavedMsg, setNoteSavedMsg] = useState(null); const [sessionList, setSessionList] = useState([]); const [sessionAId, setSessionAId] = useState(null); const [sessionBId, setSessionBId] = useState(null); const [comparison, setComparison] = useState(null); const [videoAUrl, setVideoAUrl] = useState(null); const [videoBUrl, setVideoBUrl] = useState(null); const videoARef = useRef(null); const videoBRef = useRef(null); useEffect(() => { if (!ready) return; fetchAll().catch(() => {}); apiFetch("/sessions") .then((items) => { setSessionList(items); const params = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null; const paramB = params?.get("sessionB") ? Number(params.get("sessionB")) : null; const paramA = params?.get("sessionA") ? Number(params.get("sessionA")) : null; if (items.length >= 2) { const bId = paramB && items.some((s) => s.id === paramB) ? paramB : items[0].id; const fallbackA = items.find((s) => s.id !== bId)?.id ?? items[items.length - 1].id; const aId = paramA && items.some((s) => s.id === paramA) ? paramA : fallbackA; setSessionAId(aId); setSessionBId(bId); } else if (items.length === 1) { setSessionAId(items[0].id); setSessionBId(items[0].id); } }) .catch(() => {}); }, [fetchAll, ready]); useEffect(() => { if (!ready) return; dashboardApi .weeklyReview(weeksAgo) .then((res) => { setWeekly(res); setWinsNote(res.reflection.wins_note || ""); setFocusNote(res.reflection.focus_next_week || ""); }) .catch(() => {}); }, [ready, weeksAgo]); // Fetch side-by-side comparison + media blobs when sessionAId / sessionBId change useEffect(() => { if (!ready || !sessionAId || !sessionBId) return; let active = true; let nextUrlA: string | null = null; let nextUrlB: string | null = null; dashboardApi .compare(sessionAId, sessionBId) .then(async (cmp) => { if (!active) return; setComparison(cmp); if (cmp.session_a.video_available) { const blobA = await apiFetch(`/sessions/${sessionAId}/video`, {}, "blob").catch( () => null ); if (blobA && active) { nextUrlA = URL.createObjectURL(blobA); setVideoAUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return nextUrlA; }); } } else { setVideoAUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); } if (cmp.session_b.video_available) { const blobB = await apiFetch(`/sessions/${sessionBId}/video`, {}, "blob").catch( () => null ); if (blobB && active) { nextUrlB = URL.createObjectURL(blobB); setVideoBUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return nextUrlB; }); } } else { setVideoBUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); } }) .catch(() => {}); return () => { active = false; }; }, [ready, sessionAId, sessionBId]); const handleSaveWeeklyReflection = async (event: FormEvent) => { event.preventDefault(); if (!weekly) return; setSavingNote(true); setNoteSavedMsg(null); try { await dashboardApi.saveWeeklyNote({ week_start_date: weekly.week_start_date, wins_note: winsNote, focus_next_week: focusNote, }); setNoteSavedMsg("Weekly presenter reflection saved."); } finally { setSavingNote(false); } }; const playBothSynchronized = () => { if (videoARef.current) { videoARef.current.currentTime = 0; videoARef.current.play().catch(() => {}); } if (videoBRef.current) { videoBRef.current.currentTime = 0; videoBRef.current.play().catch(() => {}); } }; const pauseBoth = () => { videoARef.current?.pause(); videoBRef.current?.pause(); }; if (!ready) { return (

Loading streaks, weekly review & comparison studio…

); } return (
{/* Side-by-Side Recording Comparison Studio */}
Side-by-Side Recording Comparison

Compare an earlier baseline take against your latest retake to hear and see your growth.

{(videoAUrl || videoBUrl) && (
)}
{sessionList.length === 0 ? (
Record at least two takes in the{" "} Camera Studio {" "} to compare your delivery side-by-side.
) : ( <>
{/* Session A Selector & Player */}
{videoAUrl ? (
{/* Session B Selector & Player */}
{videoBUrl ? (
{/* Metric Deltas */} {comparison && (
{Object.entries(COMPARE_LABELS).map(([key, meta]) => { const item = comparison.comparison[key]; if (!item) return null; const improved = meta.lowerIsBetter ? item.delta < 0 : item.delta > 0; const neutral = Math.abs(item.delta) < 0.01; return (

{meta.label}

{key === "eye_contact_score" ? `${Math.round(item.b * 100)}%` : `${item.b.toFixed(key === "filler_rate" ? 1 : 0)}${meta.suffix}`}

{item.delta > 0 ? "+" : ""} {key === "eye_contact_score" ? `${Math.round(item.delta * 100)}%` : item.delta.toFixed(1)}{" "} vs Take A

); })}
{comparison.insights.length > 0 && (

Take-Over-Take Insights

    {comparison.insights.map((line, idx) => (
  • • {line}
  • ))}
)}
)} )}
{/* Weekly Presenter Review + Reflection Journal */} {weekly && (
Weekly Presenter Review {weekly.week_start_date} → {weekly.week_end_date}

Synthesizes your 7-day consistency, pacing shifts, top wins, and reflection journal.

Weekly Reps

{weekly.current_week.session_count}

{weekly.current_week.active_days} active days ·{" "} {weekly.current_week.total_minutes}m

Weekly Avg Score

{Math.round(weekly.current_week.avg_overall_score)}/100

{weekly.deltas.overall_score >= 0 ? "+" : ""} {weekly.deltas.overall_score} vs prior week

Avg Speaking Pace

{Math.round(weekly.current_week.avg_wpm)} WPM

Target band: 125–155 WPM

Fillers / Minute

{weekly.current_week.avg_filler_rate.toFixed(1)}

Eye-line: {Math.round(weekly.current_week.avg_eye_contact_pct)}%

Highlights & Wins

    {weekly.wins.map((w, i) => (
  • ✓ {w}
  • ))}
{(weekly.recommended_focus || []).length > 0 && ( <>

Suggested Focus Next Week

    {weekly.recommended_focus.map((f, i) => (
  • → {f}
  • ))}
)} {weekly.current_week.best_session_id && (

Best Take Score: {Math.round(weekly.current_week.best_score || 0)}/100

Replay Best Session →
)}
{/* Weekly Reflection Form */}

Weekly Presenter Journal