"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(null); const [open, setOpen] = useState(null); const [detail, setDetail] = useState(null); const [resetting, setResetting] = useState(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

Loading…

; if (rows.length === 0) return (

Nobody has taken this quiz yet.

); return ( <> {can("quizzes:reset") && } {rows.map((p) => ( ))}
Person Score Progress Time StartedActions
{p.completed_at ? ( {p.score} / {p.total} ) : ( — )} {p.completed_at ? ( Finished ) : ( {p.answered} of {p.total} answered )} {fmtMs(p.total_ms)} {fmtDate(p.started_at)}
setOpen(null)} title={open ? `${open.name}'s answers` : ""} > {!detail ? (

Loading…

) : (

{detail.completed_at ? `Scored ${detail.score} of ${detail.total}` : "Still in progress"}{" "} · {fmtMs(detail.total_ms)} in total

{detail.items.map((it, i) => (

{i + 1}.

{fmtMs(it.response_ms)}
    {it.options.map((o) => { const chosen = !!o.id && it.chosen_option_ids.includes(o.id); return (
  • {chosen && ( ← their answer )}
  • ); })}
{it.chosen_option_ids.length === 0 && (

Not answered yet.

)}
))}
)}
); } export function Responses({ quizId }: { quizId: string }) { const [stats, setStats] = useState(null); useEffect(() => { quizzes .responses(quizId) .then(setStats) .catch((err) => { toast.error(errMsg(err, "Could not load responses")); setStats([]); }); }, [quizId]); if (stats === null) return

Loading…

; return (
    {stats.map((q, i) => { const pct = q.answered ? Math.round((q.correct / q.answered) * 100) : 0; return (
  1. {i + 1}. {q.multi && ( Multiple answers )}

    {pct}%{" "} correct · {q.answered} answered

    avg {fmtMs(q.avg_ms)} · median {fmtMs(q.median_ms)}

      {q.options.map((o) => { const share = q.answered ? (o.picks / q.answered) * 100 : 0; return (
    • {o.is_correct && ( ✓ correct )} {o.picks} · {Math.round(share)}%
    • ); })}
  2. ); })}
); }