2026-03-29 01:15:37 -04:00

594 lines
20 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 { Textarea } from "@/components/ui/textarea";
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 {
Sheet,
SheetTrigger,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { BookOpen, CheckCircle2 } 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 ParagraphData {
id: string;
text: string;
wordCount: number;
paragraphIndex: number;
companyName: string;
ticker: string | null;
filingType: string;
filingDate: string;
secItem: string;
}
interface ProgressData {
completed: number;
total: number;
}
export default function LabelPage() {
const router = useRouter();
const [paragraph, setParagraph] = useState<ParagraphData | null>(null);
const [progress, setProgress] = useState<ProgressData>({ completed: 0, total: 0 });
const [category, setCategory] = useState<string>("");
const [specificity, setSpecificity] = useState<number>(0);
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [error, setError] = useState<string | null>(null);
const timerStart = useRef<number>(Date.now());
const sessionId = useRef<string>(crypto.randomUUID());
const notesRef = useRef<HTMLTextAreaElement>(null);
const fetchNext = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/label");
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Failed to load");
return;
}
if (data.redirectToWarmup) {
router.push("/label/warmup");
return;
}
if (data.done) {
setDone(true);
setProgress(data.progress);
return;
}
setParagraph(data.paragraph);
setProgress(data.progress);
setCategory("");
setSpecificity(0);
setNotes("");
timerStart.current = Date.now();
} catch {
setError("Network error");
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
fetchNext();
}, [fetchNext]);
const handleSubmit = useCallback(async () => {
if (!category || !specificity || !paragraph || submitting) return;
const durationMs = Date.now() - timerStart.current;
setSubmitting(true);
try {
const res = await fetch("/api/label", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paragraphId: paragraph.id,
contentCategory: category,
specificityLevel: specificity,
notes: notes.trim() || undefined,
sessionId: sessionId.current,
durationMs,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Failed to submit");
return;
}
setProgress(data.progress);
// Immediately fetch next paragraph
await fetchNext();
} catch {
setError("Network error");
} finally {
setSubmitting(false);
}
}, [category, specificity, paragraph, notes, submitting, fetchNext]);
// Keyboard shortcuts
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLTextAreaElement) return;
if (done || loading) 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();
} else if (e.key === "n" || e.key === "N") {
// Focus notes field
e.preventDefault();
notesRef.current?.focus();
}
}
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [category, specificity, done, loading, handleSubmit]);
if (loading && !paragraph) {
return (
<div className="flex flex-1 items-center justify-center bg-background">
<p className="text-muted-foreground">Loading...</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 (done) {
return (
<div className="flex flex-1 items-center justify-center bg-background">
<Card className="w-full max-w-md text-center">
<CardHeader>
<CheckCircle2 className="mx-auto mb-2 size-12 text-green-600" />
<CardTitle>All Done!</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
You have labeled all {progress.total} assigned paragraphs. Thank you!
</p>
<Button
className="mt-6"
onClick={() => router.push("/dashboard")}
>
Back to Dashboard
</Button>
</CardContent>
</Card>
</div>
);
}
if (!paragraph) return null;
return (
<div className="flex flex-1 flex-col bg-background">
{/* Header bar */}
<header className="border-b bg-card px-6 py-3">
<div className="mx-auto flex max-w-5xl items-center justify-between gap-4">
<div className="flex items-center gap-3 overflow-hidden">
<span className="truncate font-medium">
{paragraph.companyName}
{paragraph.ticker ? ` (${paragraph.ticker})` : ""}
</span>
<Badge variant="outline">{paragraph.filingType}</Badge>
<Badge variant="outline">{paragraph.filingDate}</Badge>
<Badge variant="outline">{paragraph.secItem}</Badge>
</div>
<div className="flex items-center gap-4">
<Progress
value={progress.completed}
max={progress.total}
className="w-40"
>
<ProgressLabel>Progress</ProgressLabel>
<ProgressValue>
{() => `${progress.completed} / ${progress.total}`}
</ProgressValue>
</Progress>
</div>
</div>
</header>
{/* Floating codebook button */}
<CodebookSidebar />
{/* Main content */}
<main className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-6">
{/* Paragraph display */}
<Card>
<CardContent className="p-6">
<div className="rounded-lg bg-amber-50 p-6 text-base leading-relaxed dark:bg-amber-950/30">
{paragraph.text}
</div>
<p className="mt-2 text-right text-xs text-muted-foreground">
{paragraph.wordCount} words
</p>
</CardContent>
</Card>
{/* Labeling form */}
<div 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={setCategory}>
{CATEGORIES.map((cat, i) => (
<div key={cat} className="flex items-center gap-3">
<RadioGroupItem value={cat} id={`cat-${i}`} />
<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={(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}`}
/>
<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 button */}
<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>
{/* Notes */}
<Card>
<CardContent className="p-4">
<Label htmlFor="notes" className="mb-2 text-sm">
Notes (optional){" "}
<kbd className="ml-1 rounded border bg-muted px-1 py-0.5 text-xs text-muted-foreground">
N
</kbd>
</Label>
<Textarea
ref={notesRef}
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Any observations, edge cases, or uncertainty..."
rows={2}
className="mt-2 resize-none"
/>
</CardContent>
</Card>
</main>
</div>
);
}
function CodebookSidebar() {
return (
<div className="fixed bottom-6 right-6 z-50">
<Sheet>
<SheetTrigger className="inline-flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<BookOpen className="size-6" />
</SheetTrigger>
<SheetContent side="right" className="w-[420px] sm:max-w-[420px]">
<SheetHeader>
<SheetTitle>Labeling Codebook</SheetTitle>
<SheetDescription>Quick reference for categories and specificity levels</SheetDescription>
</SheetHeader>
<ScrollArea className="flex-1 px-4 pb-4">
<div className="space-y-6">
{/* Categories */}
<section>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Content Categories
</h3>
<div className="space-y-3">
<CategoryDef
num={1}
name="Board Governance"
desc="Board/committee oversight of cyber risk. The board or a board committee is the grammatical subject performing the primary action."
examples="Board receives reports, Audit Committee oversees, directors review"
notList="CISO reports TO board (Management Role), board mentioned only in passing"
/>
<CategoryDef
num={2}
name="Management Role"
desc="Named officers or management teams responsible for cybersecurity. A specific person or management function is the grammatical subject."
examples="CISO leads program, VP of Security manages, management team implements"
notList="Board delegates to CISO (Board Governance if board is subject)"
/>
<CategoryDef
num={3}
name="Risk Management Process"
desc="Internal cybersecurity program mechanics: frameworks, assessments, controls, training, monitoring."
examples="We maintain a program, NIST framework, penetration testing, employee training"
notList="Vendor assessments (Third-Party Risk), incident response actions (Incident Disclosure)"
/>
<CategoryDef
num={4}
name="Third-Party Risk"
desc="Oversight of external parties' cybersecurity: vendors, suppliers, service providers, contractors."
examples="Vendor assessments, third-party audits, supply chain monitoring, SOC 2 requirements"
notList="Internal program using third-party tools (Risk Management Process)"
/>
<CategoryDef
num={5}
name="Incident Disclosure"
desc="Description of what happened in a cybersecurity incident: timeline, impact, response, remediation."
examples="We detected unauthorized access, breach affected N records, incident response activated"
notList="Generic incident response plans (Risk Management Process), hypothetical incidents"
/>
<CategoryDef
num={6}
name="Strategy Integration"
desc="Business/financial consequences of cyber risk: budget, insurance, M&A due diligence, competitive impact, regulatory strategy."
examples="Cyber budget increased, insurance coverage, risk factors, financial impact"
notList="Technical program costs (Risk Management Process)"
/>
<CategoryDef
num={7}
name="None/Other"
desc="Does not fit any of the above categories. Generic corporate language, non-cyber content, or ambiguous."
/>
</div>
</section>
<Separator />
{/* Specificity */}
<section>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Specificity Levels
</h3>
<div className="space-y-3">
<SpecDef
level={1}
name="Generic Boilerplate"
desc="Could appear in any company's filing unchanged. No named frameworks, tools, people, dates, or quantities."
/>
<SpecDef
level={2}
name="Sector-Adapted"
desc="Names a recognized standard or sector practice (e.g., NIST, SOC 2, PCI DSS) but nothing firm-specific."
/>
<SpecDef
level={3}
name="Firm-Specific"
desc="Contains at least one fact from the IS list unique to this company: cybersecurity-specific titles (CISO, CTO), named tools/vendors, specific dates, named committees."
/>
<SpecDef
level={4}
name="Quantified-Verifiable"
desc="Contains 2+ hard verifiable facts: specific dates, dollar amounts, percentages, headcounts, named third parties with specifics."
/>
</div>
</section>
<Separator />
{/* Decision rules */}
<section>
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Key Decision Rules
</h3>
<div className="space-y-2 text-sm">
<Rule title="Board vs Management">
Who is the grammatical subject? Board/committee = Board
Governance. Named officer/team = Management Role.
</Rule>
<Rule title="Person vs Function">
Is the paragraph about the person (credentials, background,
reporting lines) or the function (program activities, tools)?
Person Management Role. Function Risk Management Process.
</Rule>
<Rule title="QV threshold">
Need 2+ independently verifiable facts (dates, dollar amounts,
named firms, headcounts) for Specificity 4.
</Rule>
<Rule title="Materiality disclaimers">
Any "materially affected" assessment = Strategy Integration,
even if boilerplate. Pure cross-reference with no materiality
conclusion = None/Other.
</Rule>
<Rule title="Dual-topic paragraphs">
Choose the category whose content occupies the majority of the
paragraph the primary communicative purpose.
</Rule>
</div>
</section>
<Separator className="my-4" />
<a
href="/codebook"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-sm font-medium text-primary hover:underline"
>
Open full codebook reference
</a>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
);
}
function CategoryDef({
num,
name,
desc,
examples,
notList,
}: {
num: number;
name: string;
desc: string;
examples?: string;
notList?: string;
}) {
return (
<div className="rounded-md border p-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{num}
</Badge>
<span className="font-medium text-sm">{name}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">{desc}</p>
{examples && (
<p className="mt-1 text-xs">
<span className="font-medium text-green-700 dark:text-green-400">IS: </span>
{examples}
</p>
)}
{notList && (
<p className="mt-0.5 text-xs">
<span className="font-medium text-red-700 dark:text-red-400">NOT: </span>
{notList}
</p>
)}
</div>
);
}
function SpecDef({
level,
name,
desc,
}: {
level: number;
name: string;
desc: string;
}) {
return (
<div className="rounded-md border p-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="font-mono text-xs">
{level}
</Badge>
<span className="font-medium text-sm">{name}</span>
</div>
<p className="mt-1.5 text-xs text-muted-foreground leading-relaxed">{desc}</p>
</div>
);
}
function Rule({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-md bg-muted/50 p-2.5">
<span className="font-medium">{title}: </span>
<span className="text-muted-foreground">{children}</span>
</div>
);
}