Spaces:
Paused
Paused
Download apps/web/src/app/progress/page.tsx from nathycodes/presentercoach: direct link, hf CLI and curl.
- Browser
- Download file 23 kB
-
https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/web/src/app/progress/page.tsx
- Command line
-
hf download hf://spaces/nathycodes/presentercoach/apps/web/src/app/progress/page.tsx
-
curl -L -o page.tsx https://huggingface.co/spaces/nathycodes/presentercoach/resolve/main/apps/web/src/app/progress/page.tsx
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"> | |
| “{comparison.session_a.transcript_preview}” | |
| </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"> | |
| “{comparison.session_b.transcript_preview}” | |
| </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> | |
| ); | |
| } | |