2026-03-29 00:59:58 -04:00

377 lines
12 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Progress, ProgressLabel, ProgressValue } from "@/components/ui/progress";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { CheckCircle2, XCircle } from "lucide-react";
const CATEGORIES = [
"Board Governance",
"Management Role",
"Risk Management Process",
"Third-Party Risk",
"Incident Disclosure",
"Strategy Integration",
"None/Other",
] as const;
const SPECIFICITY_LABELS = [
"Generic Boilerplate",
"Sector-Adapted",
"Firm-Specific",
"Quantified-Verifiable",
] as const;
interface WarmupParagraph {
id: string;
text: string;
index: number;
total: number;
}
interface FeedbackData {
categoryCorrect: boolean;
specificityCorrect: boolean;
goldCategory: string;
goldSpecificity: number;
explanation: string;
warmupCompleted: number;
done: boolean;
}
export default function WarmupPage() {
const router = useRouter();
const [paragraph, setParagraph] = useState<WarmupParagraph | null>(null);
const [warmupCompleted, setWarmupCompleted] = useState(0);
const [category, setCategory] = useState<string>("");
const [specificity, setSpecificity] = useState<number>(0);
const [feedback, setFeedback] = useState<FeedbackData | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLDivElement>(null);
const fetchNext = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/warmup");
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Failed to load warmup");
return;
}
if (data.done) {
router.push("/label");
return;
}
setParagraph(data.paragraph);
setWarmupCompleted(data.warmupCompleted);
setCategory("");
setSpecificity(0);
setFeedback(null);
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
fetchNext();
}, [fetchNext]);
const handleSubmit = useCallback(async () => {
if (!category || !specificity || !paragraph || feedback || submitting) return;
setSubmitting(true);
try {
const res = await fetch("/api/warmup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
specificity,
warmupIndex: paragraph.index,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Failed to submit");
return;
}
setFeedback(data);
setWarmupCompleted(data.warmupCompleted);
} catch {
setError("Network error");
} finally {
setSubmitting(false);
}
}, [category, specificity, paragraph, feedback, submitting]);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLTextAreaElement) return;
if (feedback) {
// After feedback, Enter or Space advances to next
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (feedback.done) {
router.push("/label");
} else {
fetchNext();
}
}
return;
}
const specKeys: Record<string, number> = { q: 1, w: 2, e: 3, r: 4 };
if (specKeys[e.key.toLowerCase()]) {
e.preventDefault();
setSpecificity(specKeys[e.key.toLowerCase()]);
} else if (!e.shiftKey && e.key >= "1" && e.key <= "7") {
e.preventDefault();
setCategory(CATEGORIES[parseInt(e.key) - 1]);
} else if (e.key === "Enter" && category && specificity) {
e.preventDefault();
handleSubmit();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [category, specificity, feedback, handleSubmit, fetchNext, router]);
if (loading) {
return (
<div className="flex flex-1 items-center justify-center bg-background">
<p className="text-muted-foreground">Loading warmup...</p>
</div>
);
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center bg-background">
<Alert variant="destructive" className="max-w-md">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
</div>
);
}
if (!paragraph) return null;
const allCorrect = feedback?.categoryCorrect && feedback?.specificityCorrect;
return (
<div className="flex flex-1 flex-col bg-background">
{/* Header */}
<header className="border-b bg-white px-6 py-3 dark:bg-zinc-950">
<div className="mx-auto flex max-w-4xl items-center justify-between">
<h1 className="text-lg font-semibold">Warm-up Practice</h1>
<Progress
value={warmupCompleted}
max={paragraph.total}
className="w-48"
>
<ProgressLabel>Progress</ProgressLabel>
<ProgressValue>
{() => `${warmupCompleted} / ${paragraph.total}`}
</ProgressValue>
</Progress>
</div>
</header>
{/* Main content */}
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-6">
{/* Paragraph display */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm text-muted-foreground">
Warm-up {warmupCompleted + (feedback ? 0 : 1)} of{" "}
{paragraph.total}
</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-lg bg-amber-50 p-6 text-base leading-relaxed dark:bg-amber-950/30">
{paragraph.text}
</div>
</CardContent>
</Card>
{/* Labeling form */}
<div ref={formRef} className="grid gap-6 md:grid-cols-2">
{/* Content Category */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Content Category</CardTitle>
</CardHeader>
<CardContent>
<RadioGroup
value={category}
onValueChange={feedback ? undefined : setCategory}
>
{CATEGORIES.map((cat, i) => (
<div key={cat} className="flex items-center gap-3">
<RadioGroupItem
value={cat}
id={`cat-${i}`}
disabled={!!feedback}
/>
<Label
htmlFor={`cat-${i}`}
className="cursor-pointer text-sm"
>
{cat}{" "}
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
{i + 1}
</kbd>
</Label>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
{/* Specificity Level */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm">Specificity Level</CardTitle>
</CardHeader>
<CardContent>
<RadioGroup
value={specificity ? String(specificity) : ""}
onValueChange={
feedback
? undefined
: (v: string) => setSpecificity(parseInt(v))
}
>
{SPECIFICITY_LABELS.map((label, i) => (
<div key={label} className="flex items-center gap-3">
<RadioGroupItem
value={String(i + 1)}
id={`spec-${i}`}
disabled={!!feedback}
/>
<Label
htmlFor={`spec-${i}`}
className="cursor-pointer text-sm"
>
{label}{" "}
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
{["Q", "W", "E", "R"][i]}
</kbd>
</Label>
</div>
))}
</RadioGroup>
</CardContent>
</Card>
</div>
{/* Submit or feedback */}
{!feedback ? (
<Button
onClick={handleSubmit}
disabled={!category || !specificity || submitting}
className="w-full"
size="lg"
>
{submitting ? "Submitting..." : "Submit"}{" "}
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
Enter
</kbd>
</Button>
) : (
<div className="space-y-4">
<Alert
className={
allCorrect
? "border-green-300 bg-green-50 dark:border-green-800 dark:bg-green-950/30"
: "border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950/30"
}
>
{allCorrect ? (
<CheckCircle2 className="text-green-600 dark:text-green-400" />
) : (
<XCircle className="text-red-600 dark:text-red-400" />
)}
<AlertTitle>
{allCorrect
? "Correct!"
: feedback.categoryCorrect
? "Specificity incorrect"
: feedback.specificityCorrect
? "Category incorrect"
: "Both incorrect"}
</AlertTitle>
<AlertDescription className="mt-2 space-y-2">
<p>
<span className="font-medium">Gold category:</span>{" "}
{feedback.goldCategory}
{!feedback.categoryCorrect && (
<span className="ml-2 text-red-600 dark:text-red-400">
(you chose: {category})
</span>
)}
</p>
<p>
<span className="font-medium">Gold specificity:</span>{" "}
{feedback.goldSpecificity} -{" "}
{SPECIFICITY_LABELS[feedback.goldSpecificity - 1]}
{!feedback.specificityCorrect && (
<span className="ml-2 text-red-600 dark:text-red-400">
(you chose: {specificity} -{" "}
{SPECIFICITY_LABELS[specificity - 1]})
</span>
)}
</p>
<p className="mt-3 text-sm leading-relaxed">
{feedback.explanation}
</p>
</AlertDescription>
</Alert>
<Button
onClick={() => {
if (feedback.done) {
router.push("/label");
} else {
fetchNext();
}
}}
className="w-full"
size="lg"
>
{feedback.done ? "Begin Labeling" : "Next"}{" "}
<kbd className="ml-2 rounded border bg-primary-foreground/20 px-1.5 py-0.5 text-xs">
Enter
</kbd>
</Button>
</div>
)}
</main>
</div>
);
}