nathycodes's picture
Deploy Tech Presenter Coach
ec17af3
Raw History Blame Contribute Delete
23 kB
"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<string, { label: string; suffix: string; lowerIsBetter?: boolean }> = {
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<number>(0);
const [weekly, setWeekly] = useState<WeeklyReviewResponse | null>(null);
const [winsNote, setWinsNote] = useState<string>("");
const [focusNote, setFocusNote] = useState<string>("");
const [savingNote, setSavingNote] = useState<boolean>(false);
const [noteSavedMsg, setNoteSavedMsg] = useState<string | null>(null);
const [sessionList, setSessionList] = useState<SessionListItem[]>([]);
const [sessionAId, setSessionAId] = useState<number | null>(null);
const [sessionBId, setSessionBId] = useState<number | null>(null);
const [comparison, setComparison] = useState<SessionComparisonResponse | null>(null);
const [videoAUrl, setVideoAUrl] = useState<string | null>(null);
const [videoBUrl, setVideoBUrl] = useState<string | null>(null);
const videoARef = useRef<HTMLVideoElement | null>(null);
const videoBRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
if (!ready) return;
fetchAll().catch(() => {});
apiFetch<SessionListItem[]>("/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<Blob>(`/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<Blob>(`/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 (
<AppShell eyebrow="Streaks, Weekly Reviews & Comparisons" title="Progress & Recording Lab">
<Card className="bg-white/80">
<CardContent>
<p className="py-8 text-sm text-ink/70">Loading streaks, weekly review & comparison studio…</p>
</CardContent>
</Card>
</AppShell>
);
}
return (
<AppShell
eyebrow="Measurable Presenter Growth"
title="Streaks, Weekly Review & Recording Comparison"
subtitle="See your daily consistency, review week-over-week improvements, and compare Take 1 vs Take 2 side-by-side."
>
<div className="space-y-8">
<OverviewCards overview={overview} loading={!overview} />
{/* Side-by-Side Recording Comparison Studio */}
<Card className="bg-white/90">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="font-display text-xl">
Side-by-Side Recording Comparison
</CardTitle>
<p className="text-xs text-ink/65">
Compare an earlier baseline take against your latest retake to hear and see your growth.
</p>
</div>
{(videoAUrl || videoBUrl) && (
<div className="flex gap-2">
<Button size="sm" onClick={playBothSynchronized}>
▶ Play Both from Start
</Button>
<Button size="sm" variant="outline" onClick={pauseBoth}>
Pause Both
</Button>
</div>
)}
</CardHeader>
<CardContent className="space-y-5">
{sessionList.length === 0 ? (
<div className="rounded-2xl bg-ink/[0.04] p-6 text-center text-sm text-ink/70">
Record at least two takes in the{" "}
<Link href="/record" className="font-semibold text-ember underline">
Camera Studio
</Link>{" "}
to compare your delivery side-by-side.
</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-2">
{/* Session A Selector & Player */}
<div className="space-y-3 rounded-2xl border border-ink/10 bg-cream/55 p-4">
<label className="block space-y-1 text-xs font-semibold uppercase tracking-[0.14em] text-ink/60">
Take A (Baseline / Earlier Take)
<Select
value={sessionAId ? String(sessionAId) : ""}
onChange={(e) => setSessionAId(Number(e.target.value))}
>
{sessionList.map((s) => (
<option key={s.id} value={s.id}>
#{s.id} · {s.title || `${s.mode} Session`} (
{new Date(s.created_at).toLocaleDateString()})
</option>
))}
</Select>
</label>
{videoAUrl ? (
<video
ref={videoARef}
src={videoAUrl}
controls
playsInline
className="aspect-video w-full rounded-xl bg-black object-cover"
/>
) : (
<div className="flex aspect-video items-center justify-center rounded-xl bg-ink/8 px-4 text-center text-xs text-ink/60">
Transcript & telemetry session (no video file attached)
</div>
)}
{comparison?.session_a?.transcript_preview && (
<p className="line-clamp-3 text-xs italic text-ink/70">
&ldquo;{comparison.session_a.transcript_preview}&rdquo;
</p>
)}
</div>
{/* Session B Selector & Player */}
<div className="space-y-3 rounded-2xl border border-ember/25 bg-white p-4">
<label className="block space-y-1 text-xs font-semibold uppercase tracking-[0.14em] text-ember">
Take B (Comparison / Latest Retake)
<Select
value={sessionBId ? String(sessionBId) : ""}
onChange={(e) => setSessionBId(Number(e.target.value))}
>
{sessionList.map((s) => (
<option key={s.id} value={s.id}>
#{s.id} · {s.title || `${s.mode} Session`} (
{new Date(s.created_at).toLocaleDateString()})
</option>
))}
</Select>
</label>
{videoBUrl ? (
<video
ref={videoBRef}
src={videoBUrl}
controls
playsInline
className="aspect-video w-full rounded-xl bg-black object-cover"
/>
) : (
<div className="flex aspect-video items-center justify-center rounded-xl bg-ink/8 px-4 text-center text-xs text-ink/60">
Transcript & telemetry session (no video file attached)
</div>
)}
{comparison?.session_b?.transcript_preview && (
<p className="line-clamp-3 text-xs italic text-ink/70">
&ldquo;{comparison.session_b.transcript_preview}&rdquo;
</p>
)}
</div>
</div>
{/* Metric Deltas */}
{comparison && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{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 (
<div
key={key}
className="rounded-2xl border border-ink/10 bg-white/85 p-3 text-xs"
>
<p className="font-semibold text-ink/60">{meta.label}</p>
<p className="mt-1 font-display text-lg font-semibold text-ink">
{key === "eye_contact_score"
? `${Math.round(item.b * 100)}%`
: `${item.b.toFixed(key === "filler_rate" ? 1 : 0)}${meta.suffix}`}
</p>
<p
className={cn(
"mt-0.5 text-[11px] font-semibold",
neutral
? "text-ink/45"
: improved
? "text-moss"
: "text-amber-700"
)}
>
{item.delta > 0 ? "+" : ""}
{key === "eye_contact_score"
? `${Math.round(item.delta * 100)}%`
: item.delta.toFixed(1)}{" "}
vs Take A
</p>
</div>
);
})}
</div>
{comparison.insights.length > 0 && (
<div className="rounded-2xl border border-moss/20 bg-moss/10 p-4 text-xs text-ink/85">
<p className="font-semibold uppercase tracking-[0.14em] text-moss">
Take-Over-Take Insights
</p>
<ul className="mt-1.5 space-y-1">
{comparison.insights.map((line, idx) => (
<li key={idx}>• {line}</li>
))}
</ul>
</div>
)}
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Weekly Presenter Review + Reflection Journal */}
{weekly && (
<Card className="bg-white/90">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<CardTitle className="font-display text-xl">Weekly Presenter Review</CardTitle>
<Badge>
{weekly.week_start_date} → {weekly.week_end_date}
</Badge>
</div>
<p className="mt-1 text-xs text-ink/60">
Synthesizes your 7-day consistency, pacing shifts, top wins, and reflection journal.
</p>
</div>
<Select
className="w-44"
value={String(weeksAgo)}
onChange={(e) => setWeeksAgo(Number(e.target.value))}
>
<option value="0">Current 7 Days</option>
<option value="1">1 Week Ago</option>
<option value="2">2 Weeks Ago</option>
<option value="3">3 Weeks Ago</option>
</Select>
</CardHeader>
<CardContent className="space-y-5">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className="rounded-2xl bg-cream/70 p-3.5">
<p className="text-xs text-ink/60">Weekly Reps</p>
<p className="mt-1 font-display text-2xl font-semibold">
{weekly.current_week.session_count}
</p>
<p className="text-xs text-ink/55">
{weekly.current_week.active_days} active days ·{" "}
{weekly.current_week.total_minutes}m
</p>
</div>
<div className="rounded-2xl bg-cream/70 p-3.5">
<p className="text-xs text-ink/60">Weekly Avg Score</p>
<p className="mt-1 font-display text-2xl font-semibold">
{Math.round(weekly.current_week.avg_overall_score)}/100
</p>
<p className="text-xs text-moss">
{weekly.deltas.overall_score >= 0 ? "+" : ""}
{weekly.deltas.overall_score} vs prior week
</p>
</div>
<div className="rounded-2xl bg-cream/70 p-3.5">
<p className="text-xs text-ink/60">Avg Speaking Pace</p>
<p className="mt-1 font-display text-2xl font-semibold">
{Math.round(weekly.current_week.avg_wpm)} WPM
</p>
<p className="text-xs text-ink/55">Target band: 125–155 WPM</p>
</div>
<div className="rounded-2xl bg-cream/70 p-3.5">
<p className="text-xs text-ink/60">Fillers / Minute</p>
<p className="mt-1 font-display text-2xl font-semibold">
{weekly.current_week.avg_filler_rate.toFixed(1)}
</p>
<p className="text-xs text-ink/55">
Eye-line: {Math.round(weekly.current_week.avg_eye_contact_pct)}%
</p>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-3 rounded-2xl border border-ink/10 bg-white p-4 text-xs">
<p className="font-semibold uppercase tracking-[0.14em] text-moss">
Highlights & Wins
</p>
<ul className="space-y-1.5 text-ink/80">
{weekly.wins.map((w, i) => (
<li key={i}>✓ {w}</li>
))}
</ul>
{(weekly.recommended_focus || []).length > 0 && (
<>
<p className="pt-2 font-semibold uppercase tracking-[0.14em] text-ember">
Suggested Focus Next Week
</p>
<ul className="space-y-1.5 text-ink/80">
{weekly.recommended_focus.map((f, i) => (
<li key={i}>→ {f}</li>
))}
</ul>
</>
)}
{weekly.current_week.best_session_id && (
<div className="mt-2 rounded-xl bg-amber-50/90 p-3">
<p className="font-semibold text-ink">
Best Take Score: {Math.round(weekly.current_week.best_score || 0)}/100
</p>
<Link
href={`/session/${weekly.current_week.best_session_id}`}
className="mt-1 inline-block font-semibold text-ember hover:underline"
>
Replay Best Session →
</Link>
</div>
)}
</div>
{/* Weekly Reflection Form */}
<form
onSubmit={handleSaveWeeklyReflection}
className="space-y-3 rounded-2xl border border-ink/10 bg-white p-4"
>
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-ink/65">
Weekly Presenter Journal
</p>
<label className="block space-y-1 text-xs font-semibold text-ink/70">
Biggest speaking win this week
<textarea
rows={2}
value={winsNote}
onChange={(e) => setWinsNote(e.target.value)}
placeholder="e.g.Stopped rushing technical demos and paused cleanly between slides."
className="w-full rounded-xl border border-ink/15 p-2.5 text-xs text-ink focus:border-ink focus:outline-none"
/>
</label>
<label className="block space-y-1 text-xs font-semibold text-ink/70">
#1 habit to lock in next week
<textarea
rows={2}
value={focusNote}
onChange={(e) => setFocusNote(e.target.value)}
placeholder="e.g. Deliver 3 Tech News briefings using Bullet Cue Cards only."
className="w-full rounded-xl border border-ink/15 p-2.5 text-xs text-ink focus:border-ink focus:outline-none"
/>
</label>
<div className="flex items-center justify-between">
{noteSavedMsg ? (
<span className="text-xs font-medium text-moss">{noteSavedMsg}</span>
) : (
<span />
)}
<Button type="submit" size="sm" disabled={savingNote}>
{savingNote ? "Saving…" : "Save Weekly Reflection"}
</Button>
</div>
</form>
</div>
</CardContent>
</Card>
)}
<section className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
<ActivityHeatmap days={heatmap} />
<FillerBreakdown fillers={fillers} />
</section>
</div>
</AppShell>
);
}