312 lines
11 KiB
TypeScript
312 lines
11 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* Who took a quiz, and how each question went.
|
|
*
|
|
* Response times are measured by the BFF — from the moment a question was
|
|
* served to the moment its answer arrived — so they can't be massaged by the
|
|
* browser. They include any time the person spent away from the screen.
|
|
*
|
|
* Staff with quizzes:reset can delete one person's attempt, which puts the
|
|
* quiz back to pending for them so they can take it again.
|
|
*/
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
quizzes,
|
|
AdminError,
|
|
type QuizParticipant,
|
|
type QuizQuestionStat,
|
|
type QuizResult,
|
|
} from "@/lib/api";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useConfirm } from "@/components/confirm";
|
|
import { Can, useStaff } from "@/components/staff-context";
|
|
import { Modal } from "@/components/ui/modal";
|
|
import { Table, THead, TH, TR, TD } from "@/components/ui/table";
|
|
import { RichText } from "@/lib/rich-text";
|
|
import { cn, fmtDate } from "@/lib/utils";
|
|
import { fmtMs } from "../labels";
|
|
import { Clamp } from "@/components/ui/clamp";
|
|
|
|
const errMsg = (err: unknown, fallback: string) =>
|
|
err instanceof AdminError ? err.message : fallback;
|
|
|
|
export function Participants({ quizId }: { quizId: string }) {
|
|
const [rows, setRows] = useState<QuizParticipant[] | null>(null);
|
|
const [open, setOpen] = useState<QuizParticipant | null>(null);
|
|
const [detail, setDetail] = useState<QuizResult | null>(null);
|
|
const [resetting, setResetting] = useState<string | null>(null);
|
|
const confirm = useConfirm();
|
|
const { can } = useStaff();
|
|
|
|
const reset = async (p: QuizParticipant) => {
|
|
const ok = await confirm({
|
|
title: `Reset ${p.name}'s attempt?`,
|
|
message:
|
|
"Their answers and score are deleted and the quiz becomes pending for them, so they can take it again. This can't be undone.",
|
|
confirmLabel: "Reset attempt",
|
|
destructive: true,
|
|
});
|
|
if (!ok) return;
|
|
setResetting(p.user_id);
|
|
try {
|
|
await quizzes.resetParticipant(quizId, p.user_id);
|
|
setRows((rs) => rs?.filter((r) => r.user_id !== p.user_id) ?? rs);
|
|
toast.success(`${p.name}'s attempt was reset`);
|
|
} catch (err) {
|
|
toast.error(errMsg(err, "Could not reset the attempt"));
|
|
} finally {
|
|
setResetting(null);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
quizzes
|
|
.participants(quizId)
|
|
.then(setRows)
|
|
.catch((err) => {
|
|
toast.error(errMsg(err, "Could not load participants"));
|
|
setRows([]);
|
|
});
|
|
}, [quizId]);
|
|
|
|
const view = (p: QuizParticipant) => {
|
|
setOpen(p);
|
|
setDetail(null);
|
|
quizzes
|
|
.participant(quizId, p.user_id)
|
|
.then(setDetail)
|
|
.catch((err) => toast.error(errMsg(err, "Could not load their answers")));
|
|
};
|
|
|
|
if (rows === null)
|
|
return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
if (rows.length === 0)
|
|
return (
|
|
<p className="text-sm text-muted-foreground">
|
|
Nobody has taken this quiz yet.
|
|
</p>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<Table>
|
|
<THead>
|
|
<TR>
|
|
<TH>Person</TH>
|
|
<TH>Score</TH>
|
|
<TH>Progress</TH>
|
|
<TH>Time</TH>
|
|
<TH>Started</TH>
|
|
{can("quizzes:reset") && <TH className="text-right">Actions</TH>}
|
|
</TR>
|
|
</THead>
|
|
<tbody>
|
|
{rows.map((p) => (
|
|
<TR key={p.user_id}>
|
|
<TD>
|
|
<button
|
|
type="button"
|
|
onClick={() => view(p)}
|
|
className="flex min-w-0 max-w-full items-center gap-3 text-left hover:underline"
|
|
>
|
|
<Avatar
|
|
src={p.avatar_url ?? null}
|
|
name={p.name}
|
|
className="size-8"
|
|
/>
|
|
<span className="min-w-0">
|
|
<Clamp className="text-sm font-medium">{p.name}</Clamp>
|
|
<Clamp className="text-xs text-muted-foreground">
|
|
{`@${p.handle}`}
|
|
</Clamp>
|
|
</span>
|
|
</button>
|
|
</TD>
|
|
<TD>
|
|
{p.completed_at ? (
|
|
<span className="font-medium">
|
|
{p.score} / {p.total}
|
|
</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</TD>
|
|
<TD>
|
|
{p.completed_at ? (
|
|
<span className="text-xs text-primary">Finished</span>
|
|
) : (
|
|
<span className="text-xs text-muted-foreground">
|
|
{p.answered} of {p.total} answered
|
|
</span>
|
|
)}
|
|
</TD>
|
|
<TD className="tabular-nums">{fmtMs(p.total_ms)}</TD>
|
|
<TD className="text-xs text-muted-foreground">
|
|
{fmtDate(p.started_at)}
|
|
</TD>
|
|
<Can perm="quizzes:reset">
|
|
<TD className="text-right">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
disabled={resetting === p.user_id}
|
|
onClick={() => void reset(p)}
|
|
>
|
|
{resetting === p.user_id ? "Resetting…" : "Reset"}
|
|
</Button>
|
|
</TD>
|
|
</Can>
|
|
</TR>
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
|
|
<Modal
|
|
open={open !== null}
|
|
onClose={() => setOpen(null)}
|
|
title={open ? `${open.name}'s answers` : ""}
|
|
>
|
|
{!detail ? (
|
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
{detail.completed_at
|
|
? `Scored ${detail.score} of ${detail.total}`
|
|
: "Still in progress"}{" "}
|
|
· {fmtMs(detail.total_ms)} in total
|
|
</p>
|
|
{detail.items.map((it, i) => (
|
|
<div key={it.id ?? i} className="space-y-1.5">
|
|
<div className="flex items-start justify-between gap-3 text-sm">
|
|
<p className="min-w-0 font-medium">
|
|
{i + 1}. <RichText text={it.prompt} />
|
|
</p>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{fmtMs(it.response_ms)}
|
|
</span>
|
|
</div>
|
|
<ul className="space-y-1">
|
|
{it.options.map((o) => {
|
|
const chosen = !!o.id && it.chosen_option_ids.includes(o.id);
|
|
return (
|
|
<li
|
|
key={o.id}
|
|
className={cn(
|
|
"rounded-md px-3 py-1.5 text-sm",
|
|
o.is_correct
|
|
? "bg-primary font-medium text-primary-foreground"
|
|
: chosen
|
|
? "bg-destructive/10 text-destructive"
|
|
: "bg-muted/40",
|
|
)}
|
|
>
|
|
<RichText text={o.body} />
|
|
{chosen && (
|
|
<span className="ml-2 text-xs">← their answer</span>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{it.chosen_option_ids.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Not answered yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function Responses({ quizId }: { quizId: string }) {
|
|
const [stats, setStats] = useState<QuizQuestionStat[] | null>(null);
|
|
|
|
useEffect(() => {
|
|
quizzes
|
|
.responses(quizId)
|
|
.then(setStats)
|
|
.catch((err) => {
|
|
toast.error(errMsg(err, "Could not load responses"));
|
|
setStats([]);
|
|
});
|
|
}, [quizId]);
|
|
|
|
if (stats === null)
|
|
return <p className="text-sm text-muted-foreground">Loading…</p>;
|
|
|
|
return (
|
|
<ol className="space-y-4">
|
|
{stats.map((q, i) => {
|
|
const pct = q.answered ? Math.round((q.correct / q.answered) * 100) : 0;
|
|
return (
|
|
<li key={q.id} className="rounded-xl border border-border p-4">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<p className="min-w-0 text-sm font-medium">
|
|
{i + 1}. <RichText text={q.prompt} />
|
|
{q.multi && (
|
|
<span className="ml-2 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground whitespace-nowrap">
|
|
Multiple answers
|
|
</span>
|
|
)}
|
|
</p>
|
|
<div className="shrink-0 text-right text-xs text-muted-foreground">
|
|
<p>
|
|
<span className="font-medium text-foreground">{pct}%</span>{" "}
|
|
correct · {q.answered} answered
|
|
</p>
|
|
<p className="tabular-nums">
|
|
avg {fmtMs(q.avg_ms)} · median {fmtMs(q.median_ms)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<ul className="mt-3 space-y-1.5">
|
|
{q.options.map((o) => {
|
|
const share = q.answered ? (o.picks / q.answered) * 100 : 0;
|
|
return (
|
|
<li
|
|
key={o.id}
|
|
className="relative overflow-hidden rounded-md border border-border"
|
|
>
|
|
<div
|
|
className={cn(
|
|
"absolute inset-y-0 left-0",
|
|
o.is_correct ? "bg-primary/15" : "bg-muted",
|
|
)}
|
|
style={{ width: `${share}%` }}
|
|
aria-hidden
|
|
/>
|
|
<div className="relative flex items-center justify-between gap-3 px-3 py-1.5 text-sm">
|
|
<span
|
|
className={cn(
|
|
o.is_correct && "font-medium text-primary",
|
|
)}
|
|
>
|
|
<RichText text={o.body} />
|
|
{o.is_correct && (
|
|
<span className="ml-2 text-xs">✓ correct</span>
|
|
)}
|
|
</span>
|
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
{o.picks} · {Math.round(share)}%
|
|
</span>
|
|
</div>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
);
|
|
}
|