diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/App.css b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css
index 19a444c17..8a820632d 100644
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.css
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.css
@@ -9,6 +9,14 @@ html, body, #root {
margin: auto;
}
+.appGradientBg {
+ background:
+ radial-gradient(1200px 500px at 10% -10%, rgba(99, 102, 241, 0.18), transparent 60%),
+ radial-gradient(1000px 400px at 110% 20%, rgba(236, 72, 153, 0.14), transparent 60%),
+ linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
+ background-attachment: local;
+}
+
.App-logo {
height: 40vmin;
pointer-events: none;
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx
index 114f9eda6..94ec49094 100644
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx
@@ -63,10 +63,10 @@ function OldAssignmentBuilder() {
);
}
-function AssignmentGrader() {
+function AssignmentGraderOld() {
return (
-
Assignment Grader
+
Assignment Grader (legacy)
@@ -89,6 +89,7 @@ function AppContent() {
>
+ async lazy() {
+ const { Grader } = await import("@components/routes/Grader");
+ return { Component: Grader };
+ },
+ children: [
+ {
+ index: true,
+ async lazy() {
+ const { GraderAssignmentsPage } = await import(
+ "@components/routes/Grader"
+ );
+ return { Component: GraderAssignmentsPage };
+ }
+ },
+ {
+ path: ":assignmentId",
+ async lazy() {
+ const { GraderQuestionsPage } = await import(
+ "@components/routes/Grader"
+ );
+ return { Component: GraderQuestionsPage };
+ }
+ },
+ {
+ path: ":assignmentId/questions/:questionId",
+ async lazy() {
+ const { GraderQuestionPage } = await import(
+ "@components/routes/Grader"
+ );
+ return { Component: GraderQuestionPage };
+ }
+ },
+ {
+ path: ":assignmentId/questions/:questionId/students/:sid",
+ async lazy() {
+ const { GraderQuestionPage } = await import(
+ "@components/routes/Grader"
+ );
+ return { Component: GraderQuestionPage };
+ }
+ },
+ {
+ path: ":assignmentId/questions/:questionId/:sid",
+ async lazy() {
+ const { GraderQuestionPage } = await import(
+ "@components/routes/Grader"
+ );
+ return { Component: GraderQuestionPage };
+ }
+ }
+ ]
+ },
+ {
+ path: "graderOld",
+ element:
},
{
path: "admin",
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js b/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js
index 2c62de185..c2ce98665 100644
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/componentFuncs.js
@@ -99,43 +99,49 @@ export async function renderRunestoneComponent(
window.componentMap[res.divid] = res;
}
}
- // add a button to the preview to allow the user to flag this compenent for review
- let flagButton = document.createElement("button");
- console.log("res", res);
- flagButton.classList.add("flag-for-review");
- flagButton.textContent = "Flag for Review";
- flagButton.addEventListener("click", async function () {
- let data = {
- question_name: res.divid || res.selector_id || res.origOpts.orig.id
- };
- if (!data.question_name) {
- alert("Error: Cannot determine question name to flag for review");
- return;
- }
- console.log("Flagging question for review", data.question_name);
- // Send a POST request to the server to flag the question
- let response = await fetch("/admin/instructor/flag_question", {
- method: "POST",
- body: JSON.stringify(data),
- headers: {
- "Content-Type": "application/json"
+ if (moreOpts.graderMode) {
+ window.componentMap = window.componentMap || {};
+ let key = moreOpts.gradingContainer
+ ? `${moreOpts.gradingContainer} ${res.divid}`
+ : res.divid;
+ window.componentMap[key] = res;
+ }
+ if (!moreOpts.suppressFlagForReview && !moreOpts.graderMode) {
+ let flagButton = document.createElement("button");
+ flagButton.classList.add("flag-for-review");
+ flagButton.textContent = "Flag for Review";
+ flagButton.addEventListener("click", async function () {
+ let data = {
+ question_name: res.divid || res.selector_id || res.origOpts.orig.id
+ };
+ if (!data.question_name) {
+ alert("Error: Cannot determine question name to flag for review");
+ return;
}
- });
- if (response.ok) {
- let resp = await response.json();
- if (resp.success === true) {
- flagButton.textContent = "Question Flagged";
- flagButton.style.backgroundColor = "#22c55e";
- flagButton.style.color = "white";
- flagButton.disabled = true;
+ console.log("Flagging question for review", data.question_name);
+ let response = await fetch("/admin/instructor/flag_question", {
+ method: "POST",
+ body: JSON.stringify(data),
+ headers: {
+ "Content-Type": "application/json"
+ }
+ });
+ if (response.ok) {
+ let resp = await response.json();
+ if (resp.success === true) {
+ flagButton.textContent = "Question Flagged";
+ flagButton.style.backgroundColor = "#22c55e";
+ flagButton.style.color = "white";
+ flagButton.disabled = true;
+ } else {
+ alert("Error: " + resp.message);
+ }
} else {
- alert("Error: " + resp.message);
+ alert("Error: " + response.statusText);
}
- } else {
- alert("Error: " + response.statusText);
- }
- });
- previewRef.current.appendChild(flagButton);
+ });
+ previewRef.current.appendChild(flagButton);
+ }
} catch (e) {
console.log(e);
previewRef.current.innerHTML = `An error occurred while trying to render a ${componentKind}
`;
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css
new file mode 100644
index 000000000..0a6a40dc0
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css
@@ -0,0 +1,487 @@
+
+
+.graderShell {
+ min-height: 100%;
+ padding: 1.25rem 1.5rem 2.5rem;
+
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.25rem;
+ flex-wrap: wrap;
+}
+
+.title {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ font-size: 1.65rem;
+ font-weight: 700;
+ color: #3730a3;
+ letter-spacing: -0.02em;
+}
+
+.breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: #475569;
+ font-size: 0.9rem;
+ margin-bottom: 1rem;
+}
+.breadcrumb a {
+ color: #4f46e5;
+ text-decoration: none;
+}
+.breadcrumb a:hover { text-decoration: underline; }
+
+@keyframes shimmer {
+ 0% { background-position: 0 50%; }
+ 100% { background-position: 200% 50%; }
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.card {
+ position: relative;
+ padding: 1rem 1.1rem;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.75);
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.06);
+ cursor: pointer;
+ transition:
+ transform 0.25s cubic-bezier(.2,.8,.2,1),
+ box-shadow 0.25s ease,
+ border-color 0.25s ease;
+ overflow: hidden;
+ animation: cardIn 0.45s cubic-bezier(.2,.8,.2,1) both;
+}
+.card::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(140deg, rgba(99,102,241,0.08), rgba(236,72,153,0.06));
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+}
+.card:hover {
+ transform: translateY(-4px);
+ border-color: rgba(99, 102, 241, 0.45);
+ box-shadow: 0 14px 30px rgba(79, 70, 229, 0.15);
+}
+.card:hover::before { opacity: 1; }
+
+@keyframes cardIn {
+ from { opacity: 0; transform: translateY(8px) scale(0.98); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+}
+
+.cardTitle {
+ font-weight: 650;
+ font-size: 1.05rem;
+ color: #0f172a;
+ margin-bottom: 0.35rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.qTypeChip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.08rem 0.4rem;
+ border-radius: 999px;
+ font-size: 0.6rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: white;
+ background: linear-gradient(95deg, #6366f1, #8b5cf6);
+ box-shadow: 0 2px 8px rgba(99,102,241,0.25);
+}
+.qTypeChip.mchoice { background: linear-gradient(95deg,#2563eb,#06b6d4); }
+.qTypeChip.fillintheblank { background: linear-gradient(95deg,#10b981,#059669); }
+.qTypeChip.parsonsprob { background: linear-gradient(95deg,#f97316,#ef4444); }
+.qTypeChip.activecode { background: linear-gradient(95deg,#8b5cf6,#ec4899); }
+.qTypeChip.shortanswer { background: linear-gradient(95deg,#0ea5e9,#6366f1); }
+.qTypeChip.clickablearea { background: linear-gradient(95deg,#f59e0b,#d97706); }
+.qTypeChip.dragndrop { background: linear-gradient(95deg,#14b8a6,#0d9488); }
+.qTypeChip.matching { background: linear-gradient(95deg,#a855f7,#7c3aed); }
+.qTypeChip.webwork { background: linear-gradient(95deg,#db2777,#be185d); }
+
+.metaRow {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.4rem;
+ font-size: 0.83rem;
+ color: #475569;
+ margin-top: 0.4rem;
+}
+.metaRow strong { color: #0f172a; font-weight: 600; }
+
+.progress {
+ height: 6px;
+ border-radius: 999px;
+ background: #e2e8f0;
+ overflow: hidden;
+ margin-top: 0.75rem;
+}
+.progressBar {
+ height: 100%;
+ background: linear-gradient(95deg, #6366f1, #ec4899);
+ transition: width 0.6s cubic-bezier(.2,.8,.2,1);
+}
+
+.emptyState {
+ padding: 3rem 1rem;
+ text-align: center;
+ color: #64748b;
+ border: 1px dashed #cbd5e1;
+ border-radius: 16px;
+ background: rgba(255,255,255,0.6);
+}
+
+.tableWrap {
+ background: rgba(255,255,255,0.85);
+ border: 1px solid rgba(148,163,184,0.22);
+ border-radius: 18px;
+ overflow: hidden;
+ box-shadow: 0 6px 22px rgba(15,23,42,0.06);
+ animation: cardIn 0.4s both;
+}
+.tableWrap :global(.p-datatable-thead > tr > th) {
+ background: linear-gradient(180deg, #eef2ff, #f8fafc);
+ font-weight: 700;
+ color: #3730a3;
+}
+
+.compactToggle :global(.p-selectbutton .p-button) {
+ padding: 0.25rem 0.6rem;
+ font-size: 0.78rem;
+ line-height: 1.1;
+}
+.compactToggle :global(.p-selectbutton .p-button .pi) {
+ font-size: 0.78rem;
+}
+
+.tableWrap :global(.p-datatable-thead > tr > th) {
+ padding: 0.4rem 0.6rem;
+ font-size: 0.78rem;
+ line-height: 1.15;
+}
+.tableWrap :global(.p-datatable-thead .p-sortable-column-icon) {
+ font-size: 0.75rem;
+ margin-left: 0.25rem;
+}
+
+.tableWrap :global(.p-datatable-thead > tr.p-filter-row > th) {
+ padding: 0.25rem 0.5rem;
+}
+.tableWrap :global(.p-column-filter-element .p-inputtext),
+.tableWrap :global(.p-column-filter-element .p-dropdown),
+.tableWrap :global(.p-column-filter .p-inputtext) {
+ padding: 0.2rem 0.45rem;
+ font-size: 0.75rem;
+ min-height: 0;
+ height: 26px;
+}
+.tableWrap :global(.p-column-filter-element .p-dropdown .p-dropdown-label) {
+ padding: 0.2rem 0.45rem;
+ font-size: 0.75rem;
+ line-height: 1.1;
+}
+.tableWrap :global(.p-column-filter-element .p-dropdown .p-dropdown-trigger) {
+ width: 1.75rem;
+}
+.tableWrap :global(.p-column-filter-clear-button),
+.tableWrap :global(.p-column-filter-menu-button) {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+
+.tableWrap :global(.p-datatable-tbody > tr > td) {
+ padding: 0.45rem 0.6rem;
+}
+
+.dateFilterWrapper {
+ width: 100%;
+ display: block;
+}
+.dateFilterWrapper :global(.react-datepicker__input-container) {
+ display: block;
+ width: 100%;
+}
+.dateFilterInput {
+ width: 100%;
+ height: 26px;
+ padding: 0.2rem 1.4rem 0.2rem 0.45rem;
+ font-size: 0.75rem;
+ line-height: 1.1;
+ color: #0f172a;
+ background: #fff;
+ border: 1px solid #cbd5e1;
+ border-radius: 6px;
+ outline: none;
+ box-sizing: border-box;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+.dateFilterInput:focus {
+ border-color: #6366f1;
+ box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
+}
+.dateFilterInput::placeholder { color: #94a3b8; }
+
+.dateFilterWrapper :global(.react-datepicker__close-icon) {
+ padding: 0 6px 0 0;
+}
+.dateFilterWrapper :global(.react-datepicker__close-icon::after) {
+ background: #94a3b8;
+ font-size: 11px;
+ height: 14px;
+ width: 14px;
+ line-height: 14px;
+ padding: 0;
+}
+
+.dateFilterPopper {
+ z-index: 9999;
+}
+
+.row :global(tr):hover { background: rgba(99,102,241,0.06) !important; }
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ padding: 0.15rem 0.5rem;
+ border-radius: 999px;
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+.chipCorrect { background: #dcfce7; color: #166534; }
+.chipWrong { background: #fee2e2; color: #991b1b; }
+.chipPartial { background: #fef3c7; color: #92400e; }
+.chipUnknown { background: #e2e8f0; color: #475569; }
+
+.floatBtn {
+ position: fixed;
+ right: 1.25rem;
+ bottom: 1.25rem;
+ z-index: 900;
+}
+
+.dialogBody {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr);
+ gap: 1rem;
+}
+@media (max-width: 900px) {
+ .dialogBody { grid-template-columns: 1fr; }
+}
+
+.previewPane {
+ background: #fff;
+ border-radius: 14px;
+ border: 1px solid #e2e8f0;
+ padding: 1rem;
+ max-height: 70vh;
+ overflow: auto;
+}
+.gradePane {
+ background: linear-gradient(180deg, #f8fafc, #eef2ff);
+ border-radius: 14px;
+ border: 1px solid #e2e8f0;
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ min-height: 0;
+ max-height: 100%;
+ overflow-y: auto;
+}
+
+.historyItem {
+ padding: 0.55rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid #e2e8f0;
+ margin-bottom: 0.4rem;
+ background: #fff;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ cursor: pointer;
+ transition: border-color 0.15s, background 0.15s;
+}
+.historyItem:hover { border-color: #818cf8; background: #eef2ff; }
+.historyItem.active { border-color: #4f46e5; background: #e0e7ff; }
+
+.splitPane {
+ display: flex;
+ gap: 1rem;
+ align-items: stretch;
+ animation: cardIn 0.4s both;
+
+ height: calc(100vh - 200px);
+ min-height: 520px;
+}
+
+.splitMain {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.splitGrid {
+ flex: 1;
+ display: grid;
+
+ grid-template-columns: minmax(0, 1fr) 280px;
+ gap: 1rem;
+ align-items: stretch;
+ min-height: 0;
+}
+@media (max-width: 1100px) {
+ .splitPane {
+ flex-direction: column;
+ height: auto;
+ }
+ .splitGrid { grid-template-columns: 1fr; }
+}
+
+.submissionPane {
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border-radius: 14px;
+ border: 1px solid #e2e8f0;
+ padding: 0.85rem 1rem 1rem;
+ min-width: 0;
+ min-height: 0;
+ overflow: hidden;
+}
+.submissionRenderer {
+ flex: 1;
+ min-height: 0;
+ overflow: auto;
+ padding-top: 0.5rem;
+}
+
+.attemptBar {
+ flex: 0 0 auto;
+ border-bottom: 1px solid #e2e8f0;
+ padding-bottom: 0.55rem;
+}
+.attemptBarRow {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ flex-wrap: wrap;
+}
+.attemptLabel {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: #334155;
+}
+.attemptLabel strong { color: #0f172a; }
+.attemptTimestamp {
+ font-size: 11px;
+ color: #94a3b8;
+ font-variant-numeric: tabular-nums;
+}
+.latestBadge {
+ display: inline-flex;
+ align-items: center;
+ font-size: 10px;
+ font-weight: 700;
+ padding: 0.05rem 0.45rem;
+ border-radius: 999px;
+ background: #eef2ff;
+ color: #4338ca;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+.attemptStepBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 8px;
+ border: 1px solid #e2e8f0;
+ background: #fff;
+ color: #475569;
+ cursor: pointer;
+ transition: background 0.15s, border-color 0.15s;
+}
+.attemptStepBtn:hover:not(:disabled) {
+ border-color: #818cf8;
+ color: #4338ca;
+ background: #eef2ff;
+}
+.attemptStepBtn:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+
+.attemptDetails {
+ flex: 0 0 auto;
+ border-top: 1px solid #e2e8f0;
+ padding-top: 0.55rem;
+ margin-top: 0.5rem;
+}
+.attemptSummary {
+ cursor: pointer;
+ user-select: none;
+ font-size: 12px;
+ color: #475569;
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0.2rem 0;
+}
+.attemptSummary:hover { color: #4338ca; }
+.attemptList {
+ margin-top: 0.5rem;
+ max-height: 240px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+.historyMeta {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 11px;
+ color: #475569;
+ margin-bottom: 4px;
+}
+.historyAnswer {
+ font-size: 11px;
+ color: #0f172a;
+ font-family: ui-monospace, SFMono-Regular, monospace;
+ white-space: pre-wrap;
+ word-break: break-word;
+ line-height: 1.4;
+}
+
+.splitPane .previewPane {
+ max-height: none;
+ height: 100%;
+}
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx
new file mode 100644
index 000000000..861009be4
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx
@@ -0,0 +1,146 @@
+import { Button } from "primereact/button";
+import React, { useState } from "react";
+import { Link, Outlet, useLocation, useParams } from "react-router-dom";
+
+import { useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api";
+import {
+ useGetGraderAnswersQuery,
+ useGetGraderQuestionsQuery
+} from "@store/grader/grader.logic.api";
+
+import { ShortcutsHelpDialog } from "./components/ShortcutsHelpDialog";
+import styles from "./Grader.module.css";
+import { useEnsureEbookConfigForGrader } from "./hooks/useEnsureEbookConfigForGrader";
+import { useGraderTour } from "./hooks/useGraderTour";
+import { usePlatform } from "./hooks/usePlatform";
+import { getQuestionProgress } from "./state/graderSelectors";
+import {
+ DEMO_ASSIGNMENTS,
+ getDemoAnswersFor,
+ getDemoQuestionsFor
+} from "./tour/graderDemoData";
+import {
+ GraderTourProvider,
+ useGraderTourContext
+} from "./tour/GraderTourContext";
+
+export const Grader: React.FC = () => {
+ return (
+
+
+
+ );
+};
+
+const GraderShell: React.FC = () => {
+ useEnsureEbookConfigForGrader();
+ const { startTour } = useGraderTour();
+ const params = useParams();
+ const location = useLocation();
+ const platform = usePlatform();
+ const { data: realAssignments } = useGetAssignmentsQuery();
+ const { isDemo } = useGraderTourContext();
+ const assignments = isDemo ? DEMO_ASSIGNMENTS : realAssignments;
+
+ const assignmentId = params.assignmentId ? Number(params.assignmentId) : undefined;
+ const assignment = assignments?.find((a) => a.id === assignmentId);
+ const questionId = params.questionId ? Number(params.questionId) : undefined;
+
+ const { data: qRealData } = useGetGraderQuestionsQuery(assignmentId ?? 0, {
+ skip: !assignmentId || isDemo
+ });
+ const { data: aRealData } = useGetGraderAnswersQuery(
+ { assignmentId: assignmentId ?? 0, questionId: questionId ?? 0 },
+ { skip: !assignmentId || !questionId || isDemo }
+ );
+ const qData = isDemo && assignmentId ? getDemoQuestionsFor(assignmentId) : qRealData;
+ const aData =
+ isDemo && assignmentId && questionId
+ ? getDemoAnswersFor(assignmentId, questionId)
+ : aRealData;
+ const questionMeta = qData?.questions.find((q) => q.id === questionId);
+ const progress = aData ? getQuestionProgress(aData.answers, questionMeta) : null;
+
+ const [helpOpen, setHelpOpen] = useState(false);
+
+ return (
+
+
+
+ Assignment Grader
+
+
+
+
+
+
+
+
+
+
setHelpOpen(false)}
+ platform={platform}
+ />
+
+ );
+};
+
+export default Grader;
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx
new file mode 100644
index 000000000..c488d20d1
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/AnswerDetailDialog.tsx
@@ -0,0 +1,344 @@
+import { Button } from "primereact/button";
+import { Dialog } from "primereact/dialog";
+import { InputNumber } from "primereact/inputnumber";
+import { InputTextarea } from "primereact/inputtextarea";
+import { Slider } from "primereact/slider";
+import React, { useEffect, useMemo, useState } from "react";
+
+import {
+ GraderStudentAnswer,
+ useGetGraderHistoryQuery,
+ useSaveGradeMutation
+} from "@store/grader/grader.logic.api";
+
+import styles from "../Grader.module.css";
+import { getDemoHistoryFor } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+import { AnswerRenderer } from "./questionTypes/AnswerRenderer";
+
+interface Props {
+ visible: boolean;
+ onHide: () => void;
+ assignmentId: number;
+ questionId: number;
+ questionName: string;
+ questionType: string;
+ htmlsrc?: string;
+ maxPoints: number;
+ student: GraderStudentAnswer | null;
+ answers?: GraderStudentAnswer[];
+ onSelectStudent?: (student: GraderStudentAnswer) => void;
+}
+
+export const AnswerDetailDialog: React.FC = ({
+ visible,
+ onHide,
+ assignmentId,
+ questionId,
+ questionName,
+ questionType,
+ htmlsrc,
+ maxPoints,
+ student,
+ answers,
+ onSelectStudent
+}) => {
+ const skip = !visible || !student;
+ const { isDemo } = useGraderTourContext();
+ const { data: historyData } = useGetGraderHistoryQuery(
+ skip
+ ? { assignmentId: 0, questionId: 0, sid: "" }
+ : { assignmentId, questionId, sid: student!.sid },
+ { skip: skip || isDemo }
+ );
+ const [saveGrade, { isLoading: saving }] = useSaveGradeMutation();
+
+ const history = isDemo && student ? getDemoHistoryFor(student.sid).history : historyData?.history ?? [];
+ const [activeAttempt, setActiveAttempt] = useState(-1);
+ const [points, setPoints] = useState(0);
+ const [comment, setComment] = useState("");
+
+ const liveStudent = useMemo(() => {
+ if (!student) return null;
+ return answers?.find((a) => a.sid === student.sid) ?? student;
+ }, [answers, student]);
+
+ const currentIndex = useMemo(() => {
+ if (!liveStudent || !answers?.length) return -1;
+ return answers.findIndex((a) => a.sid === liveStudent.sid);
+ }, [answers, liveStudent]);
+ const hasPrev = currentIndex > 0;
+ const hasNext = currentIndex >= 0 && !!answers && currentIndex < answers.length - 1;
+ const goPrev = () => {
+ if (hasPrev && answers && onSelectStudent) onSelectStudent(answers[currentIndex - 1]);
+ };
+ const goNext = () => {
+ if (hasNext && answers && onSelectStudent) onSelectStudent(answers[currentIndex + 1]);
+ };
+
+ useEffect(() => {
+ if (!student) return;
+ setPoints(student.score ?? 0);
+ setComment(student.comment ?? "");
+ setActiveAttempt(history.length ? history.length - 1 : -1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [student?.sid, history.length]);
+
+ const currentAnswer = useMemo(() => {
+ if (activeAttempt >= 0 && history[activeAttempt]) {
+ return history[activeAttempt];
+ }
+ return null;
+ }, [activeAttempt, history]);
+
+ const displayedAnswer = (() => {
+ const raw = currentAnswer?.answer ?? liveStudent?.answer ?? "";
+ if (typeof raw === "string") return raw;
+ try {
+ return JSON.stringify(raw);
+ } catch {
+ return String(raw);
+ }
+ })();
+
+ const handleSave = async () => {
+ if (!student) return;
+ await saveGrade({
+ sid: student.sid,
+ div_id: questionName,
+ score: points,
+ comment,
+ questionId,
+ assignmentId
+ });
+ };
+
+ if (!student || !liveStudent) return null;
+
+ return (
+
+ );
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx
new file mode 100644
index 000000000..40d8fcda9
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GradePanel.tsx
@@ -0,0 +1,232 @@
+import { Button } from "primereact/button";
+import { InputNumber } from "primereact/inputnumber";
+import { InputSwitch } from "primereact/inputswitch";
+import { InputTextarea } from "primereact/inputtextarea";
+import React, { forwardRef, useImperativeHandle, useRef } from "react";
+
+import { GraderStudentAnswer } from "@store/grader/grader.logic.api";
+
+import { AutoSaveStatus } from "../hooks/useAutoSaveGrade";
+import styles from "../Grader.module.css";
+import { SaveStatusPill } from "./SaveStatusPill";
+
+interface Props {
+ student?: GraderStudentAnswer | null;
+ maxPoints: number;
+ score: number;
+ comment: string;
+ status: AutoSaveStatus;
+ errorMessage?: string;
+ hasPrev: boolean;
+ hasNext: boolean;
+ positionLabel?: string;
+
+ disabled?: boolean;
+ onScoreChange: (n: number) => void;
+ onCommentChange: (s: string) => void;
+ onCommentBlur: () => void;
+ onScoreBlur: () => void;
+ onPrev: () => void;
+ onNext: () => void;
+ onRetry: () => void;
+
+ autoAdvance?: boolean;
+ onAutoAdvanceChange?: (value: boolean) => void;
+}
+
+export interface GradePanelHandle {
+ focusGrade: () => void;
+ focusComment: () => void;
+}
+
+export const GradePanel = forwardRef(function GradePanel(
+ props,
+ ref
+) {
+ const {
+ student,
+ maxPoints,
+ score,
+ comment,
+ status,
+ errorMessage,
+ hasPrev,
+ hasNext,
+ positionLabel,
+ disabled,
+ onScoreChange,
+ onCommentChange,
+ onCommentBlur,
+ onScoreBlur,
+ onPrev,
+ onNext,
+ onRetry,
+ autoAdvance,
+ onAutoAdvanceChange
+ } = props;
+
+ const scoreInputRef = useRef(null);
+ const commentRef = useRef(null);
+
+ useImperativeHandle(ref, () => ({
+ focusGrade: () => scoreInputRef.current?.focus(),
+ focusComment: () => commentRef.current?.focus()
+ }));
+
+ const isDisabled = disabled || !student;
+ const studentName = student
+ ? `${student.first_name || ""} ${student.last_name || ""}`.trim() || student.sid
+ : "No student selected";
+
+ return (
+
+
+
+
+
+ {studentName}
+
+
+ {student ? (
+ <>
+
+ {student.sid}
+
+
+ {" "}
+ · {student.attempts} attempt{student.attempts === 1 ? "" : "s"}
+
+ {positionLabel && · {positionLabel}}
+ >
+ ) : (
+ No submissions for this question
+ )}
+
+
+
+
+
+ {onAutoAdvanceChange && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {
+ scoreInputRef.current = el ?? null;
+ }}
+ value={score}
+ onValueChange={(e) => onScoreChange(e.value ?? 0)}
+ onBlur={onScoreBlur}
+ min={0}
+ max={maxPoints}
+ showButtons
+ buttonLayout="horizontal"
+ disabled={isDisabled}
+ style={{ flex: 1 }}
+ inputStyle={{ width: "100%", textAlign: "center" }}
+ decrementButtonClassName="p-button-secondary"
+ incrementButtonClassName="p-button-secondary"
+ decrementButtonIcon="pi pi-minus"
+ incrementButtonIcon="pi pi-plus"
+ />
+
+
+
+
+
+ onCommentChange(e.target.value)}
+ onBlur={onCommentBlur}
+ rows={4}
+ autoResize
+ disabled={isDisabled}
+ placeholder={
+ isDisabled
+ ? "No submissions to comment on yet"
+ : "Leave feedback the student will see on their results page"
+ }
+ style={{ width: "100%" }}
+ />
+
+
+ );
+});
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx
new file mode 100644
index 000000000..7719ccad2
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SaveStatusPill.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+
+import {
+ AutoSaveStatus
+} from "../hooks/useAutoSaveGrade";
+
+const STYLES: Record = {
+ idle: { display: "none" },
+ dirty: { background: "#f1f5f9", color: "#475569", borderColor: "#cbd5e1" },
+ saving: { background: "#eef2ff", color: "#4338ca", borderColor: "#c7d2fe" },
+ saved: { background: "#dcfce7", color: "#166534", borderColor: "#bbf7d0" },
+ error: { background: "#fee2e2", color: "#991b1b", borderColor: "#fecaca" }
+};
+
+const ICONS: Record = {
+ idle: "",
+ dirty: "pi pi-circle",
+ saving: "pi pi-spin pi-spinner",
+ saved: "pi pi-check",
+ error: "pi pi-exclamation-triangle"
+};
+
+const LABELS: Record = {
+ idle: "",
+ dirty: "Unsaved",
+ saving: "Saving…",
+ saved: "Saved",
+ error: "Save failed"
+};
+
+interface Props {
+ status: AutoSaveStatus;
+ onRetry?: () => void;
+ errorMessage?: string;
+}
+
+export const SaveStatusPill: React.FC = ({ status, onRetry, errorMessage }) => {
+ if (status === "idle") return null;
+ const style: React.CSSProperties = {
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 6,
+ padding: "0.18rem 0.55rem",
+ borderRadius: 999,
+ fontSize: 12,
+ fontWeight: 600,
+ border: "1px solid",
+ transition: "background 0.2s, color 0.2s",
+ ...STYLES[status]
+ };
+ return (
+
+
+ {LABELS[status]}
+ {status === "error" && onRetry && (
+
+ Retry
+
+ )}
+
+ );
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx
new file mode 100644
index 000000000..d656bbcd9
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx
@@ -0,0 +1,144 @@
+import { Dialog } from "primereact/dialog";
+import React from "react";
+
+import { Platform } from "../hooks/usePlatform";
+
+interface Props {
+ visible: boolean;
+ onHide: () => void;
+ platform: Platform;
+}
+
+interface Row {
+ keys: string[];
+ label: string;
+}
+
+const SECTIONS: Array<{ title: string; rows: Row[] }> = [
+ {
+ title: "Navigation",
+ rows: [
+ { keys: ["J"], label: "Next student (rolls over to next question)" },
+ { keys: ["K"], label: "Previous student (rolls over to previous question)" },
+ { keys: ["↓"], label: "Next student" },
+ { keys: ["↑"], label: "Previous student" },
+ { keys: ["→"], label: "Next attempt" },
+ { keys: ["←"], label: "Previous attempt" }
+ ]
+ },
+ {
+ title: "Focus & input",
+ rows: [
+ { keys: ["G"], label: "Focus grade input" },
+ { keys: ["C"], label: "Focus comment box" },
+ { keys: ["H"], label: "Toggle 'hide graded' filter" }
+ ]
+ },
+ {
+ title: "Help",
+ rows: [{ keys: ["?"], label: "Open this dialog" }]
+ }
+];
+
+const Kbd: React.FC<{ children: React.ReactNode }> = ({ children }) => (
+
+ {children}
+
+);
+
+export const ShortcutsHelpDialog: React.FC = ({ visible, onHide }) => {
+ return (
+
+ );
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css
new file mode 100644
index 000000000..34c9b60ae
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css
@@ -0,0 +1,146 @@
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ width: 280px;
+ flex: 0 0 280px;
+ background: rgba(255, 255, 255, 0.85);
+ border: 1px solid rgba(148, 163, 184, 0.22);
+ border-radius: 16px;
+ overflow: hidden;
+ max-height: calc(100vh - 220px);
+ min-height: 420px;
+ box-shadow: 0 6px 22px rgba(15, 23, 42, 0.06);
+}
+
+.header {
+ padding: 0.75rem 0.85rem;
+ border-bottom: 1px solid #e2e8f0;
+ background: linear-gradient(180deg, #eef2ff, #f8fafc);
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.headerRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 14px;
+ color: #3730a3;
+}
+.counter {
+ font-variant-numeric: tabular-nums;
+ font-size: 12px;
+ color: #64748b;
+ font-weight: 600;
+}
+
+.legendRow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.55rem;
+ font-size: 11px;
+ color: #475569;
+}
+.legend {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ font-variant-numeric: tabular-nums;
+}
+
+.controls {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.4rem;
+}
+.controls > * {
+ width: 100%;
+}
+.controls :global(.p-inputtext) {
+ padding: 0.35rem 0.5rem;
+ width: 100%;
+}
+.controls :global(.p-togglebutton) {
+ width: 100%;
+ justify-content: center;
+ min-height: 0;
+ padding: 0.2rem 0;
+ font-size: 12px;
+ line-height: 1.1;
+}
+.controls :global(.p-togglebutton .p-button-label) {
+ font-size: 12px;
+ line-height: 1.1;
+ padding: 0;
+}
+.controls :global(.p-togglebutton .p-button-icon) {
+ font-size: 12px;
+}
+
+.list {
+ list-style: none;
+ margin: 0;
+ padding: 0.4rem;
+ overflow-y: auto;
+ flex: 1;
+}
+
+.item {
+ display: flex;
+ align-items: center;
+ gap: 0.6rem;
+ padding: 0.5rem 0.6rem;
+ border-radius: 10px;
+ cursor: pointer;
+ transition: background 0.12s, border-color 0.12s;
+ border: 1px solid transparent;
+}
+.item:hover {
+ background: rgba(99, 102, 241, 0.08);
+}
+.item.active {
+ background: #eef2ff;
+ border-color: #818cf8;
+}
+
+.itemBody {
+ flex: 1;
+ min-width: 0;
+}
+.itemName {
+ font-weight: 600;
+ color: #0f172a;
+ font-size: 13px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.itemMeta {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+ font-size: 11px;
+ color: #64748b;
+ margin-top: 2px;
+ font-variant-numeric: tabular-nums;
+}
+.itemSid {
+ font-family: ui-monospace, SFMono-Regular, monospace;
+}
+
+.dot {
+ font-size: 14px;
+ width: 16px;
+ display: inline-flex;
+ justify-content: center;
+}
+
+.empty {
+ padding: 1.2rem 0.75rem;
+ color: #94a3b8;
+ font-size: 12px;
+ text-align: center;
+}
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx
new file mode 100644
index 000000000..ced9d74b0
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx
@@ -0,0 +1,182 @@
+import { InputText } from "primereact/inputtext";
+import { ProgressBar } from "primereact/progressbar";
+import { ToggleButton } from "primereact/togglebutton";
+import React, { useMemo, useRef } from "react";
+
+import { GraderStudentAnswer } from "@store/grader/grader.logic.api";
+
+import {
+ getQuestionProgress,
+ getStudentStatus,
+ statusColor,
+ statusIcon,
+ statusLabel,
+ StudentGradingStatus
+} from "../state/graderSelectors";
+import styles from "./StudentListSidebar.module.css";
+
+interface Props {
+ answers: ReadonlyArray;
+ question?: { autograde?: string };
+ activeSid?: string;
+ dirtySids?: ReadonlySet;
+ onSelect: (sid: string) => void;
+ hideGraded: boolean;
+ onToggleHideGraded: (v: boolean) => void;
+}
+
+const studentName = (s: GraderStudentAnswer) =>
+ `${s.first_name || ""} ${s.last_name || ""}`.trim() || s.sid;
+
+export const StudentListSidebar: React.FC = ({
+ answers,
+ question,
+ activeSid,
+ dirtySids,
+ onSelect,
+ hideGraded,
+ onToggleHideGraded
+}) => {
+ const [filter, setFilter] = React.useState("");
+ const progress = useMemo(
+ () => getQuestionProgress(answers, question, { dirtySids }),
+ [answers, question, dirtySids]
+ );
+
+ const filtered = useMemo(() => {
+ const q = filter.trim().toLowerCase();
+ return answers.filter((a) => {
+ const status = getStudentStatus(a, question, { dirtySids });
+ if (hideGraded && (status === "graded" || status === "autograded")) return false;
+ if (!q) return true;
+ return (
+ studentName(a).toLowerCase().includes(q) ||
+ a.sid.toLowerCase().includes(q)
+ );
+ });
+ }, [answers, filter, hideGraded, question, dirtySids]);
+
+ const listRef = useRef(null);
+
+ return (
+
+ );
+};
+
+const StatusDot: React.FC<{ status: StudentGradingStatus }> = ({ status }) => (
+
+
+
+);
+
+const Legend: React.FC<{ status: StudentGradingStatus; count: number }> = ({
+ status,
+ count
+}) => {
+ if (count === 0) return null;
+ return (
+
+
+ {count}
+
+ );
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx
new file mode 100644
index 000000000..8e4642c52
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/SubmissionPane.tsx
@@ -0,0 +1,217 @@
+import { Slider } from "primereact/slider";
+import React, {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState
+} from "react";
+
+import {
+ GraderAnswerHistoryItem,
+ GraderStudentAnswer,
+ useGetGraderHistoryQuery
+} from "@store/grader/grader.logic.api";
+
+import styles from "../Grader.module.css";
+import { getDemoHistoryFor } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+import { AnswerRenderer } from "./questionTypes/AnswerRenderer";
+
+interface Props {
+ assignmentId: number;
+ questionId: number;
+ questionName: string;
+ questionType: string;
+ htmlsrc?: string;
+ student: GraderStudentAnswer;
+}
+
+export interface SubmissionPaneHandle {
+
+ prevAttempt: () => void;
+
+ nextAttempt: () => void;
+}
+
+const formatAnswer = (a: GraderAnswerHistoryItem["answer"]) => {
+ if (typeof a === "string") return a;
+ if (a == null) return "";
+ try {
+ return JSON.stringify(a);
+ } catch {
+ return String(a);
+ }
+};
+
+const correctChip = (
+ h: Pick
+): { label: string; cls: string } | null => {
+ if (h.correct === true) return { label: "correct", cls: styles.chipCorrect };
+ if (h.correct === false && (h.percent ?? 0) > 0)
+ return { label: "partial", cls: styles.chipPartial };
+ if (h.correct === false) return { label: "wrong", cls: styles.chipWrong };
+ return null;
+};
+
+export const SubmissionPane = forwardRef(
+ function SubmissionPane(
+ { assignmentId, questionId, questionName, questionType, htmlsrc, student },
+ ref
+ ) {
+ const { isDemo } = useGraderTourContext();
+ const { data: historyData } = useGetGraderHistoryQuery(
+ { assignmentId, questionId, sid: student.sid },
+ { skip: isDemo }
+ );
+
+ const history: GraderAnswerHistoryItem[] = isDemo
+ ? getDemoHistoryFor(student.sid).history
+ : historyData?.history ?? [];
+
+ const [activeAttempt, setActiveAttempt] = useState(-1);
+
+ useEffect(() => {
+ setActiveAttempt(history.length ? history.length - 1 : -1);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [student.sid, history.length]);
+
+ const currentAnswer = useMemo(
+ () => (activeAttempt >= 0 && history[activeAttempt]) || null,
+ [activeAttempt, history]
+ );
+
+ const displayedAnswer = formatAnswer(currentAnswer?.answer ?? student.answer ?? "");
+ const chip = correctChip(currentAnswer ?? student);
+ const totalAttempts = history.length;
+ const isLatest = activeAttempt === totalAttempts - 1;
+
+ const goPrevAttempt = () => setActiveAttempt((i) => Math.max(0, i - 1));
+ const goNextAttempt = () =>
+ setActiveAttempt((i) => Math.min(totalAttempts - 1, i + 1));
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ prevAttempt: goPrevAttempt,
+ nextAttempt: goNextAttempt
+ }),
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [totalAttempts]
+ );
+
+ return (
+
+
+
+
+
+
+
+ {totalAttempts > 0
+ ? `Attempt ${activeAttempt + 1} of ${totalAttempts}`
+ : "No attempts"}
+
+ {isLatest && totalAttempts > 1 && (
+ latest
+ )}
+
+ {chip && {chip.label}}
+ {currentAnswer?.timestamp && (
+
+ {new Date(currentAnswer.timestamp).toLocaleString()}
+
+ )}
+
+
+
+
+ = totalAttempts - 1}
+ aria-label="Next attempt"
+ title="Next attempt"
+ >
+
+
+
+ {totalAttempts > 1 && (
+
setActiveAttempt(e.value as number)}
+ min={0}
+ max={totalAttempts - 1}
+ step={1}
+ style={{ marginTop: 4 }}
+ />
+ )}
+
+
+
+
+ {totalAttempts > 0 && (
+
+
+ All attempts ({totalAttempts})
+
+
+ {history.map((h, idx) => {
+ const c = correctChip(h);
+ const active = idx === activeAttempt;
+ const raw = formatAnswer(h.answer);
+ return (
+
setActiveAttempt(idx)}
+ title={h.timestamp || ""}
+ >
+
+ #{idx + 1}
+ {h.timestamp && (
+ {new Date(h.timestamp).toLocaleString()}
+ )}
+ {c && {c.label}}
+ {idx === totalAttempts - 1 && totalAttempts > 1 && (
+ latest
+ )}
+
+
+ {raw.slice(0, 200) || "(empty)"}
+ {raw.length > 200 ? "…" : ""}
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+ }
+);
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx
new file mode 100644
index 000000000..d71a70b5e
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.tsx
@@ -0,0 +1,58 @@
+import { SelectButton } from "primereact/selectbutton";
+import React from "react";
+
+import styles from "../Grader.module.css";
+
+export type GraderViewMode = "cards" | "table";
+
+const VIEW_OPTIONS: { label: string; value: GraderViewMode; icon: string }[] = [
+ { label: "Cards", value: "cards", icon: "pi pi-th-large" },
+ { label: "Table", value: "table", icon: "pi pi-table" }
+];
+
+interface ViewModeToggleProps {
+ value: GraderViewMode;
+ onChange: (mode: GraderViewMode) => void;
+ ariaLabel?: string;
+ tourId?: string;
+}
+
+export const ViewModeToggle: React.FC = ({
+ value,
+ onChange,
+ ariaLabel = "Toggle view",
+ tourId
+}) => (
+
+ {
+ if (e.value) onChange(e.value as GraderViewMode);
+ }}
+ options={VIEW_OPTIONS}
+ optionLabel="label"
+ optionValue="value"
+ allowEmpty={false}
+ itemTemplate={(option: {
+ label: string;
+ value: GraderViewMode;
+ icon: string;
+ }) => (
+
+
+ {option.label}
+
+ )}
+ aria-label={ariaLabel}
+ />
+
+);
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx
new file mode 100644
index 000000000..fcc9fcf78
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ActiveCodeAnswerView.tsx
@@ -0,0 +1,33 @@
+import React from "react";
+
+import { QuestionPreviewHeader } from "./RunestonePreview";
+import { AnswerRendererProps } from "./types";
+
+export const ActiveCodeAnswerView: React.FC = (props) => {
+ const { answer } = props;
+
+ return (
+
+
+
Submitted source
+
+ {answer || "(empty)"}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx
new file mode 100644
index 000000000..dcebde5fa
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/AnswerRenderer.tsx
@@ -0,0 +1,87 @@
+import React from "react";
+
+import { ActiveCodeAnswerView } from "./ActiveCodeAnswerView";
+import { DefaultAnswerView } from "./DefaultAnswerView";
+import { FitbAnswerView } from "./FitbAnswerView";
+import { McqAnswerView } from "./McqAnswerView";
+import { ParsonsAnswerView } from "./ParsonsAnswerView";
+import { RunestoneGraderPreview } from "./RunestoneGraderPreview";
+import { ShortAnswerView } from "./ShortAnswerView";
+import { AnswerRendererProps } from "./types";
+
+const RUNESTONE_GRADER_TYPES = new Set([
+ "mchoice",
+ "clickablearea",
+ "dragndrop",
+ "fillintheblank",
+ "shortanswer",
+ "parsonsprob",
+ "matching",
+ "activecode",
+ "actex",
+ "codelens",
+ "hparsons",
+ "lp",
+ "webwork",
+ "selectquestion"
+]);
+
+export const AnswerRenderer: React.FC<
+ AnswerRendererProps & { questionType: string }
+> = (props) => {
+ const {
+ questionType,
+ htmlsrc,
+ questionName,
+ sid,
+ history,
+ activeAttemptIndex
+ } = props;
+
+ const hasIndex =
+ typeof activeAttemptIndex === "number" && activeAttemptIndex >= 0;
+ const isLatestAttempt =
+ hasIndex && activeAttemptIndex === history.length - 1;
+
+ const attempt =
+ hasIndex && !isLatestAttempt ? history[activeAttemptIndex!] : null;
+
+ const interactive =
+ htmlsrc && RUNESTONE_GRADER_TYPES.has(questionType) ? (
+
+
+ Question: {questionName}
+
+
+
+ ) : null;
+
+ if (interactive) return <>{interactive}>;
+
+ switch (questionType) {
+ case "mchoice":
+ case "clickablearea":
+ case "dragndrop":
+ return ;
+ case "fillintheblank":
+ return ;
+ case "shortanswer":
+ return ;
+ case "parsonsprob":
+ return ;
+ case "activecode":
+ case "codelens":
+ case "actex":
+ return ;
+ default:
+ return ;
+ }
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx
new file mode 100644
index 000000000..78699a969
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/DefaultAnswerView.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+import { AnswerRendererProps } from "./types";
+
+export const DefaultAnswerView: React.FC = ({ answer }) => {
+ return (
+
+
Student answer
+
+ {answer || "(empty)"}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx
new file mode 100644
index 000000000..c2e1e439e
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/FitbAnswerView.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+
+import { QuestionPreviewHeader } from "./RunestonePreview";
+import { AnswerRendererProps } from "./types";
+
+export const FitbAnswerView: React.FC = (props) => {
+ const { answer } = props;
+ let values: string[] = [];
+ try {
+ const parsed = JSON.parse(answer);
+ values = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
+ } catch {
+ values = answer ? answer.split(",") : [];
+ }
+
+ return (
+
+
+
Blanks
+
+ {values.length === 0 && - (empty)
}
+ {values.map((v, i) => (
+ -
+
+ {v || "(empty)"}
+
+
+ ))}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx
new file mode 100644
index 000000000..b0878a0ef
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/McqAnswerView.tsx
@@ -0,0 +1,40 @@
+import React from "react";
+
+import { QuestionPreviewHeader } from "./RunestonePreview";
+import { AnswerRendererProps } from "./types";
+
+export const McqAnswerView: React.FC = (props) => {
+ const { answer, correct } = props;
+ const selected = (answer || "").split(",").filter(Boolean);
+
+ return (
+
+
+
+ Selected option{selected.length > 1 ? "s" : ""}
+
+
+ {selected.length === 0 ? (
+ (no selection)
+ ) : (
+ selected.map((s) => (
+
+ Option {s}
+
+ ))
+ )}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx
new file mode 100644
index 000000000..35e33bf03
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ParsonsAnswerView.tsx
@@ -0,0 +1,61 @@
+import React from "react";
+
+import { QuestionPreviewHeader } from "./RunestonePreview";
+import { AnswerRendererProps } from "./types";
+
+export const ParsonsAnswerView: React.FC = (props) => {
+ const { answer } = props;
+ const blocks = (answer || "")
+ .split("-")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ return (
+
+
+
+ Reconstructed block order ({blocks.length})
+
+
+ {blocks.length === 0 && (
+
(no blocks submitted)
+ )}
+ {blocks.map((b, i) => (
+
+
+ {i + 1}
+
+ {b}
+
+ ))}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx
new file mode 100644
index 000000000..f696d9124
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestoneGraderPreview.tsx
@@ -0,0 +1,205 @@
+import { MathJaxWrapper } from "@components/routes/AssignmentBuilder/MathJaxWrapper";
+import { MathJax } from "better-react-mathjax";
+import React, { useEffect, useReducer, useRef } from "react";
+
+import { renderRunestoneComponent } from "@/componentFuncs";
+import { GraderAnswerHistoryItem } from "@store/grader/grader.logic.api";
+
+interface Props {
+ htmlsrc?: string;
+ divId: string;
+ sid: string;
+
+ attempt?: GraderAnswerHistoryItem | null;
+
+ attemptId?: number | string;
+
+ deadline?: string;
+}
+
+export const RunestoneGraderPreview: React.FC = ({
+ htmlsrc,
+ divId,
+ sid,
+ attempt,
+ deadline
+}) => {
+ const ref = useRef(null);
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
+
+ useEffect(() => {
+ if (!ref.current || !htmlsrc) return;
+
+ try {
+ const cfg = (window as any).eBookConfig || {};
+ if (cfg.email && cfg.course) {
+ localStorage.removeItem(`${cfg.email}:${cfg.course}:${divId}-given`);
+ }
+ } catch {
+
+ }
+
+ ref.current.innerHTML = htmlsrc;
+
+ const useFetch = !attempt;
+ const opts: Record = {
+ graderactive: true,
+ graderMode: true,
+ suppressFlagForReview: true,
+ sid,
+
+ assessmentTaken: useFetch,
+ useRunestoneServices: true,
+ gradingContainer: ref.current.id || undefined
+ };
+ if (deadline) {
+ opts.deadline = deadline;
+ opts.enforceDeadline = true;
+ opts.rawdeadline = deadline;
+ opts.tzoff = new Date().getTimezoneOffset() / 60;
+ }
+
+ let cancelled = false;
+ renderRunestoneComponent(ref, opts)
+ .then(async () => {
+ if (cancelled) return;
+ forceUpdate();
+ if (useFetch) return;
+
+ const cmKey = (opts.gradingContainer ? `${opts.gradingContainer} ` : "") + divId;
+ const componentMap = (window as any).componentMap || {};
+ const inst = componentMap[cmKey] || componentMap[divId];
+ if (!inst) return;
+ try {
+ if (typeof inst.checkServerComplete?.then === "function") {
+ await inst.checkServerComplete;
+ }
+
+ if (inst.addingScrubber) {
+ for (let i = 0; i < 50 && inst.addingScrubber; i++) {
+ await new Promise((r) => setTimeout(r, 20));
+ }
+ }
+ if (cancelled) return;
+
+ const rawAnswer = attempt!.answer;
+ const codeString =
+ typeof rawAnswer === "string"
+ ? rawAnswer
+ : rawAnswer == null
+ ? ""
+ : (() => {
+ try {
+ return JSON.stringify(rawAnswer);
+ } catch {
+ return String(rawAnswer);
+ }
+ })();
+
+ const isCodeEditor =
+ inst.editor && typeof inst.editor.setValue === "function";
+
+ if (isCodeEditor && (!inst.restoreAnswers || attempt!.source === "code_table")) {
+
+ inst.editor.acEditEvent = false;
+ inst.editor.setValue(codeString);
+ if (typeof inst.setLockedRegions === "function") {
+ try { inst.setLockedRegions(); } catch {}
+ }
+ if (typeof inst.setHighlightLines === "function") {
+ try { inst.setHighlightLines(); } catch {}
+ }
+
+ try {
+ if (Array.isArray(inst.history) && inst.history.length) {
+ const idx = inst.history.findIndex((h: string) => h === codeString);
+ const $ = (window as any).$;
+ if (idx >= 0 && $ && inst.scrubber) {
+ $(inst.scrubber).slider("value", idx);
+ if (typeof inst.slideit === "function") {
+ try { inst.slideit(null); } catch {}
+ }
+ }
+ }
+ } catch { /* noop */ }
+ inst.attempted = true;
+ } else if (typeof inst.restoreAnswers === "function") {
+ inst.restoreAnswers({
+ answer: rawAnswer ?? "",
+ correct: attempt!.correct ?? null,
+ percent: attempt!.percent ?? null,
+ timestamp: attempt!.timestamp,
+ sid
+ });
+ inst.attempted = true;
+ if (typeof inst.decorateStatus === "function") {
+ inst.decorateStatus();
+ }
+ }
+ } catch (err) {
+ // eslint-disable-next-line no-console
+ console.warn("[RunestoneGraderPreview] restore failed", err);
+ }
+ })
+ .catch(() => undefined);
+
+ return () => {
+ cancelled = true;
+ };
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [htmlsrc, sid, divId, attempt?.id]);
+
+ if (!htmlsrc) {
+ return (
+
+ No rendered question preview available.
+
+ );
+ }
+
+ const hostId = `grader-preview-${divId}-${sid}-${attempt?.id ?? "latest"}`;
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default RunestoneGraderPreview;
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx
new file mode 100644
index 000000000..c83da8229
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/RunestonePreview.tsx
@@ -0,0 +1,85 @@
+import { MathJaxWrapper } from "@components/routes/AssignmentBuilder/MathJaxWrapper";
+import { MathJax } from "better-react-mathjax";
+import React, { useEffect, useReducer, useRef } from "react";
+
+import { renderRunestoneComponent } from "@/componentFuncs";
+
+import { AnswerRendererProps } from "./types";
+
+export const RunestonePreview: React.FC<{ htmlsrc?: string; divId: string }> = ({
+ htmlsrc,
+ divId
+}) => {
+ const ref = useRef(null);
+ const [, forceUpdate] = useReducer((x) => x + 1, 0);
+
+ useEffect(() => {
+ if (!ref.current || !htmlsrc) return;
+ ref.current.innerHTML = htmlsrc;
+ renderRunestoneComponent(ref, { isCalledFromBuilder: true, graderactive: false })
+ .then(forceUpdate)
+ .catch(() => undefined);
+
+ }, [htmlsrc, divId]);
+
+ if (!htmlsrc) {
+ return (
+
+ No rendered question preview available.
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export const QuestionPreviewHeader: React.FC<
+ Pick
+> = ({ questionName }) => (
+
+
+
+ Question: {questionName}
+
+
+);
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx
new file mode 100644
index 000000000..bd663bc3e
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/ShortAnswerView.tsx
@@ -0,0 +1,32 @@
+import React from "react";
+
+import { QuestionPreviewHeader } from "./RunestonePreview";
+import { AnswerRendererProps } from "./types";
+
+export const ShortAnswerView: React.FC = (props) => {
+ const { answer } = props;
+
+ return (
+
+
+
Student response
+
+ {answer || (empty response)}
+
+
+ );
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts
new file mode 100644
index 000000000..77d1a2367
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/questionTypes/types.ts
@@ -0,0 +1,15 @@
+import { GraderAnswerHistoryItem } from "@store/grader/grader.logic.api";
+
+export interface AnswerRendererProps {
+ htmlsrc?: string;
+ answer: string;
+ correct?: boolean | null;
+ percent?: number | null;
+ history: GraderAnswerHistoryItem[];
+ questionName: string;
+ questionId: number;
+ sid: string;
+
+ activeAttemptIndex?: number;
+}
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts
new file mode 100644
index 000000000..cfb0f527e
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useAutoSaveGrade.ts
@@ -0,0 +1,226 @@
+import debounce from "lodash/debounce";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { useSaveGradeMutation } from "@store/grader/grader.logic.api";
+
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+export type AutoSaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
+
+export interface AutoSavedInfo {
+ sid: string;
+ questionId: number;
+ questionName: string;
+
+ previous: { score: number; comment: string };
+
+ next: { score: number; comment: string };
+}
+
+interface Args {
+ sid?: string;
+
+ assignmentId: number;
+ questionId: number;
+ questionName: string;
+
+ maxPoints: number;
+ initialScore: number;
+ initialComment: string;
+
+ onSaved?: (info: AutoSavedInfo) => void;
+}
+
+interface Result {
+ status: AutoSaveStatus;
+ score: number;
+ comment: string;
+ setScore: (n: number) => void;
+ setComment: (s: string) => void;
+
+ flush: () => Promise;
+
+ saveNow: () => Promise;
+
+ revertTo: (prev: { score: number; comment: string }) => void;
+
+ isDirty: boolean;
+ lastSavedAt?: number;
+ errorMessage?: string;
+}
+
+const SAVED_VISIBLE_MS = 1500;
+const DEBOUNCE_MS = 700;
+
+export const useAutoSaveGrade = (args: Args): Result => {
+ const {
+ sid,
+ assignmentId,
+ questionId,
+ questionName,
+ maxPoints,
+ initialScore,
+ initialComment,
+ onSaved
+ } = args;
+ const { isDemo } = useGraderTourContext();
+ const [saveGrade] = useSaveGradeMutation();
+
+ const [score, setScoreState] = useState(initialScore);
+ const [comment, setCommentState] = useState(initialComment);
+ const [status, setStatus] = useState("idle");
+ const [errorMessage, setErrorMessage] = useState();
+ const [lastSavedAt, setLastSavedAt] = useState();
+
+ const sidRef = useRef(sid);
+
+ const lastSavedRef = useRef<{ score: number; comment: string }>({
+ score: initialScore,
+ comment: initialComment
+ });
+ useEffect(() => {
+ sidRef.current = sid;
+ setScoreState(initialScore);
+ setCommentState(initialComment);
+ setStatus("idle");
+ setErrorMessage(undefined);
+ lastSavedRef.current = { score: initialScore, comment: initialComment };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [sid]);
+
+ const valuesRef = useRef({ score, comment });
+ useEffect(() => {
+ valuesRef.current = { score, comment };
+ }, [score, comment]);
+
+ const onSavedRef = useRef(onSaved);
+ useEffect(() => {
+ onSavedRef.current = onSaved;
+ }, [onSaved]);
+
+ const persist = useCallback(async () => {
+ const targetSid = sidRef.current;
+ if (!targetSid) return;
+ const { score: s, comment: c } = valuesRef.current;
+ const prev = lastSavedRef.current;
+
+ if (s === prev.score && c === prev.comment) {
+ setStatus("idle");
+ return;
+ }
+ setStatus("saving");
+ try {
+ if (isDemo) {
+ await new Promise((r) => setTimeout(r, 250));
+ } else {
+ await saveGrade({
+ sid: targetSid,
+ div_id: questionName,
+ score: s,
+ comment: c,
+ questionId,
+ assignmentId
+ }).unwrap();
+ }
+ setStatus("saved");
+ setLastSavedAt(Date.now());
+ setErrorMessage(undefined);
+ const previous = { ...prev };
+ lastSavedRef.current = { score: s, comment: c };
+ onSavedRef.current?.({
+ sid: targetSid,
+ questionId,
+ questionName,
+ previous,
+ next: { score: s, comment: c }
+ });
+ } catch (e: any) {
+ setStatus("error");
+ setErrorMessage(e?.data?.detail || e?.message || "Save failed");
+ }
+ }, [isDemo, saveGrade, questionName, questionId, assignmentId]);
+
+ const debounced = useMemo(
+ () => debounce(persist, DEBOUNCE_MS, { leading: false, trailing: true }),
+ [persist]
+ );
+
+ useEffect(() => {
+ if (status !== "saved") return;
+ const t = setTimeout(() => {
+ setStatus((s) => (s === "saved" ? "idle" : s));
+ }, SAVED_VISIBLE_MS);
+ return () => clearTimeout(t);
+ }, [status, lastSavedAt]);
+
+ useEffect(() => () => debounced.cancel(), [debounced]);
+
+ const setScore = useCallback(
+ (n: number) => {
+ const clamped = Math.max(0, Math.min(maxPoints, isFinite(n) ? n : 0));
+ setScoreState(clamped);
+ setStatus("dirty");
+ debounced();
+ },
+ [debounced, maxPoints]
+ );
+
+ const setComment = useCallback(
+ (s: string) => {
+ setCommentState(s);
+ setStatus("dirty");
+ debounced();
+ },
+ [debounced]
+ );
+
+ const flush = useCallback(async () => {
+ if (status === "dirty") {
+ debounced.cancel();
+ await persist();
+ }
+ }, [status, debounced, persist]);
+
+ const saveNow = useCallback(async () => {
+ debounced.cancel();
+ await persist();
+ }, [debounced, persist]);
+
+ const revertTo = useCallback(
+ (prev: { score: number; comment: string }) => {
+ debounced.cancel();
+ setScoreState(prev.score);
+ setCommentState(prev.comment);
+ lastSavedRef.current = { score: prev.score, comment: prev.comment };
+ valuesRef.current = { score: prev.score, comment: prev.comment };
+ setStatus("idle");
+ setErrorMessage(undefined);
+ },
+ [debounced]
+ );
+
+ useEffect(() => {
+ const handler = (e: BeforeUnloadEvent) => {
+ if (status === "dirty" || status === "saving") {
+ e.preventDefault();
+ e.returnValue = "";
+ }
+ };
+ window.addEventListener("beforeunload", handler);
+ return () => window.removeEventListener("beforeunload", handler);
+ }, [status]);
+
+ return {
+ status,
+ score,
+ comment,
+ setScore,
+ setComment,
+ flush,
+ saveNow,
+ revertTo,
+ isDirty: status === "dirty" || status === "saving",
+ lastSavedAt,
+ errorMessage
+ };
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts
new file mode 100644
index 000000000..2bebe29ae
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useEnsureEbookConfigForGrader.ts
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+
+export function useEnsureEbookConfigForGrader() {
+ useEffect(() => {
+ const w = window as any;
+ if (!w.eBookConfig) w.eBookConfig = {};
+ const cfg = w.eBookConfig;
+ cfg.useRunestoneServices = cfg.useRunestoneServices !== false;
+ cfg.isLoggedIn = cfg.isLoggedIn !== false;
+ cfg.new_server_prefix = cfg.new_server_prefix ?? "/ns";
+ cfg.app = cfg.app ?? "/runestone";
+ cfg.python3 = cfg.python3 !== false;
+ if (!cfg.course) cfg.course = "";
+ if (!cfg.basecourse) cfg.basecourse = "";
+ if (!cfg.email) cfg.email = cfg.username ?? "instructor";
+ }, []);
+}
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts
new file mode 100644
index 000000000..fa0d4488c
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts
@@ -0,0 +1,101 @@
+import { useEffect } from "react";
+
+import { detectPlatform } from "./usePlatform";
+
+export interface GraderHotkeyHandlers {
+
+ next?: () => void;
+
+ prev?: () => void;
+
+ nextAttempt?: () => void;
+
+ prevAttempt?: () => void;
+
+ focusGrade?: () => void;
+
+ focusComment?: () => void;
+
+ toggleHideGraded?: () => void;
+
+ openHelp?: () => void;
+}
+
+interface Options {
+ enabled?: boolean;
+}
+
+const isInputTarget = (t: EventTarget | null): boolean => {
+ if (!(t instanceof HTMLElement)) return false;
+ const tag = t.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
+ return t.isContentEditable;
+};
+
+export const useGraderHotkeys = (
+ handlers: GraderHotkeyHandlers,
+ { enabled = true }: Options = {}
+) => {
+ useEffect(() => {
+ if (!enabled) return;
+ const platform = detectPlatform();
+ const isMod = (e: KeyboardEvent) =>
+ platform === "mac" ? e.metaKey : e.ctrlKey;
+
+ const onKeyDown = (e: KeyboardEvent) => {
+
+ if (e.key === "?" && !isMod(e)) {
+ if (!isInputTarget(e.target)) {
+ e.preventDefault();
+ handlers.openHelp?.();
+ }
+ return;
+ }
+
+ if (isInputTarget(e.target) || isMod(e) || e.altKey) return;
+
+ switch (e.key) {
+ case "j":
+ case "ArrowDown":
+ if (handlers.next) {
+ e.preventDefault();
+ handlers.next();
+ }
+ break;
+ case "k":
+ case "ArrowUp":
+ if (handlers.prev) {
+ e.preventDefault();
+ handlers.prev();
+ }
+ break;
+ case "ArrowRight":
+ if (handlers.nextAttempt) {
+ e.preventDefault();
+ handlers.nextAttempt();
+ }
+ break;
+ case "ArrowLeft":
+ if (handlers.prevAttempt) {
+ e.preventDefault();
+ handlers.prevAttempt();
+ }
+ break;
+ case "g":
+ handlers.focusGrade?.();
+ e.preventDefault();
+ break;
+ case "c":
+ handlers.focusComment?.();
+ e.preventDefault();
+ break;
+ case "h":
+ handlers.toggleHideGraded?.();
+ break;
+ }
+ };
+
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [enabled, handlers]);
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts
new file mode 100644
index 000000000..fdfb3ab71
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderPrefs.ts
@@ -0,0 +1,54 @@
+import { useCallback, useEffect, useState } from "react";
+
+export interface GraderPrefs {
+
+ autoAdvance: boolean;
+}
+
+const STORAGE_KEY = "rs-grader-prefs-v1";
+
+const DEFAULT_PREFS: GraderPrefs = {
+ autoAdvance: false
+};
+
+const read = (): GraderPrefs => {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return { ...DEFAULT_PREFS };
+ const parsed = JSON.parse(raw) as Partial;
+ return { ...DEFAULT_PREFS, ...parsed };
+ } catch {
+ return { ...DEFAULT_PREFS };
+ }
+};
+
+const write = (prefs: GraderPrefs) => {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
+ } catch {
+
+ }
+};
+
+export const useGraderPrefs = () => {
+ const [prefs, setPrefsState] = useState(() => read());
+
+ const updatePrefs = useCallback((patch: Partial) => {
+ setPrefsState((prev) => {
+ const next = { ...prev, ...patch };
+ write(next);
+ return next;
+ });
+ }, []);
+
+ useEffect(() => {
+ const onStorage = (e: StorageEvent) => {
+ if (e.key === STORAGE_KEY) setPrefsState(read());
+ };
+ window.addEventListener("storage", onStorage);
+ return () => window.removeEventListener("storage", onStorage);
+ }, []);
+
+ return { prefs, updatePrefs };
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts
new file mode 100644
index 000000000..0ae16ec8a
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderTour.ts
@@ -0,0 +1,135 @@
+import { driver, Driver } from "driver.js";
+import "driver.js/dist/driver.css";
+import { useCallback, useEffect, useRef } from "react";
+import { useNavigate } from "react-router-dom";
+
+import {
+ DEMO_ANSWERS,
+ DEMO_ASSIGNMENT_ID,
+ DEMO_QUESTION_ID,
+ DEMO_STUDENT_SID
+} from "../tour/graderDemoData";
+import {
+ GRADER_TOUR_STEPS,
+ TourRoute,
+ TourStepConfig
+} from "../tour/graderTourConfig";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+const TOUR_ROUTES: Record = {
+ assignments: "/grader",
+ questions: `/grader/${DEMO_ASSIGNMENT_ID}`,
+
+ answers: `/grader/${DEMO_ASSIGNMENT_ID}/questions/${DEMO_QUESTION_ID}`,
+ student: `/grader/${DEMO_ASSIGNMENT_ID}/questions/${DEMO_QUESTION_ID}/students/${DEMO_STUDENT_SID}`
+};
+
+const waitForElement = (
+ selector: string,
+ timeout = 4000
+): Promise =>
+ new Promise((resolve) => {
+ const existing = document.querySelector(selector);
+ if (existing) return resolve(existing);
+
+ const started = Date.now();
+ const observer = new MutationObserver(() => {
+ const el = document.querySelector(selector);
+ if (el) {
+ observer.disconnect();
+ resolve(el);
+ } else if (Date.now() - started > timeout) {
+ observer.disconnect();
+ resolve(null);
+ }
+ });
+ observer.observe(document.body, { childList: true, subtree: true });
+ setTimeout(() => {
+ observer.disconnect();
+ resolve(document.querySelector(selector));
+ }, timeout);
+ });
+
+export const useGraderTour = () => {
+ const navigate = useNavigate();
+ const { setIsDemo, setDemoSelected } = useGraderTourContext();
+ const driverRef = useRef(null);
+
+ const prepareStep = useCallback(
+ async (step: TourStepConfig) => {
+ const targetPath = TOUR_ROUTES[step.route];
+ if (window.location.pathname !== targetPath) {
+ navigate(targetPath);
+ }
+ if (step.openDialog) {
+ setDemoSelected(DEMO_ANSWERS.answers[0]);
+ } else if (step.route !== "student") {
+ setDemoSelected(null);
+ }
+ await waitForElement(step.element);
+ },
+ [navigate, setDemoSelected]
+ );
+
+ const startTour = useCallback(async () => {
+
+ driverRef.current?.destroy();
+
+ setIsDemo(true);
+ setDemoSelected(null);
+ navigate("/grader");
+
+ await waitForElement(GRADER_TOUR_STEPS[0].element);
+
+ const cleanup = () => {
+ setIsDemo(false);
+ setDemoSelected(null);
+ navigate("/grader");
+ };
+
+ const d = driver({
+ showProgress: true,
+ animate: true,
+ allowClose: true,
+ smoothScroll: true,
+ popoverClass: "grader-tour-popover",
+ onDestroyed: cleanup,
+ steps: GRADER_TOUR_STEPS.map((step, idx) => ({
+ element: step.element,
+ popover: {
+ title: step.title,
+ description: step.description,
+ side: step.side,
+ align: step.align,
+ onNextClick: async (_el, _step, opts) => {
+ const next = GRADER_TOUR_STEPS[idx + 1];
+ if (next) await prepareStep(next);
+ opts.driver.moveNext();
+ },
+ onPrevClick: async (_el, _step, opts) => {
+ const prev = GRADER_TOUR_STEPS[idx - 1];
+ if (prev) await prepareStep(prev);
+ opts.driver.movePrevious();
+ }
+ }
+ }))
+ });
+
+ driverRef.current = d;
+ d.drive();
+ }, [navigate, prepareStep, setDemoSelected, setIsDemo]);
+
+ useEffect(
+ () => () => {
+
+ driverRef.current?.destroy();
+ const overlays = document.querySelectorAll(
+ ".driver-overlay, .driver-popover"
+ );
+ overlays.forEach((e) => e.remove());
+ },
+ []
+ );
+
+ return { startTour };
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts
new file mode 100644
index 000000000..eb4519b18
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/usePlatform.ts
@@ -0,0 +1,19 @@
+import { useMemo } from "react";
+
+export type Platform = "mac" | "other";
+
+export const detectPlatform = (): Platform => {
+ if (typeof navigator === "undefined") return "other";
+ const ua = navigator.userAgent || "";
+ const platform =
+ // @ts-expect-error - userAgentData not in lib.dom yet
+ navigator.userAgentData?.platform || navigator.platform || "";
+ if (/Mac|iPhone|iPad|iPod/i.test(`${platform} ${ua}`)) return "mac";
+ return "other";
+};
+
+export const usePlatform = (): Platform => useMemo(detectPlatform, []);
+
+export const modKeyLabel = (p: Platform) => (p === "mac" ? "⌘" : "Ctrl");
+export const altKeyLabel = (p: Platform) => (p === "mac" ? "⌥" : "Alt");
+export const shiftKeyLabel = "⇧";
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts
new file mode 100644
index 000000000..8a04d148d
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useStudentNavigation.ts
@@ -0,0 +1,158 @@
+import { useCallback, useMemo } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+
+import {
+ GraderQuestionStats,
+ GraderStudentAnswer
+} from "@store/grader/grader.logic.api";
+
+import {
+ findFirstUngradedSid,
+ findNextUngradedSid
+} from "../state/graderSelectors";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+export interface StudentNavigation {
+ current?: GraderStudentAnswer;
+ currentIndex: number;
+ total: number;
+ hasPrev: boolean;
+ hasNext: boolean;
+ prev: () => void;
+ next: () => void;
+ goTo: (sid: string) => void;
+
+ goNextUngraded: () => boolean;
+ firstUngraded?: string;
+}
+
+interface Options {
+ answers: ReadonlyArray;
+ question?: { autograde?: string };
+
+ dirtySids?: ReadonlySet;
+
+ questions?: ReadonlyArray;
+}
+
+export const useStudentNavigation = (opts: Options): StudentNavigation => {
+ const { answers, question, dirtySids, questions } = opts;
+ const params = useParams();
+ const navigate = useNavigate();
+ const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext();
+
+ const aid = Number(params.assignmentId);
+ const qid = Number(params.questionId);
+ const sidParam = params.sid;
+ const activeSid = isDemo ? demoSelected?.sid : sidParam;
+
+ const currentIndex = useMemo(() => {
+ if (!activeSid) return -1;
+ return answers.findIndex((a) => a.sid === activeSid);
+ }, [activeSid, answers]);
+
+ const current = currentIndex >= 0 ? answers[currentIndex] : undefined;
+ const total = answers.length;
+ const hasPrevStudent = currentIndex > 0;
+ const hasNextStudent = currentIndex >= 0 && currentIndex < total - 1;
+
+ const questionIndex = useMemo(() => {
+ if (!questions || !qid) return -1;
+ return questions.findIndex((q) => q.id === qid);
+ }, [questions, qid]);
+ const prevQuestion =
+ questionIndex > 0 ? questions![questionIndex - 1] : undefined;
+ const nextQuestion =
+ questionIndex >= 0 && questions && questionIndex < questions.length - 1
+ ? questions[questionIndex + 1]
+ : undefined;
+
+ const hasPrev = hasPrevStudent || !!prevQuestion;
+ const hasNext = hasNextStudent || !!nextQuestion;
+
+ const goTo = useCallback(
+ (sid: string) => {
+ if (isDemo) {
+ const row = answers.find((a) => a.sid === sid) ?? null;
+ setDemoSelected(row);
+ return;
+ }
+ navigate(`/grader/${aid}/questions/${qid}/students/${encodeURIComponent(sid)}`);
+ },
+ [aid, qid, answers, isDemo, navigate, setDemoSelected]
+ );
+
+ const goToQuestion = useCallback(
+ (targetQid: number, opts?: { selectLast?: boolean }) => {
+ if (isDemo) return;
+ navigate(`/grader/${aid}/questions/${targetQid}`, {
+
+ state: opts?.selectLast ? { selectLast: true } : undefined
+ });
+ },
+ [aid, isDemo, navigate]
+ );
+
+ const prev = useCallback(() => {
+ if (hasPrevStudent) {
+ goTo(answers[currentIndex - 1].sid);
+ } else if (prevQuestion) {
+
+ goToQuestion(prevQuestion.id, { selectLast: true });
+ }
+ }, [hasPrevStudent, currentIndex, answers, goTo, prevQuestion, goToQuestion]);
+
+ const next = useCallback(() => {
+ if (hasNextStudent) {
+ goTo(answers[currentIndex + 1].sid);
+ } else if (nextQuestion) {
+ goToQuestion(nextQuestion.id);
+ }
+ }, [hasNextStudent, currentIndex, answers, goTo, nextQuestion, goToQuestion]);
+
+ const goNextUngraded = useCallback((): boolean => {
+ const nextSid = findNextUngradedSid(answers, currentIndex, question, {
+ dirtySids
+ });
+ if (nextSid) {
+ goTo(nextSid);
+ return true;
+ }
+ if (hasNextStudent) {
+ goTo(answers[currentIndex + 1].sid);
+ return true;
+ }
+ if (nextQuestion) {
+ goToQuestion(nextQuestion.id);
+ return true;
+ }
+ return false;
+ }, [
+ answers,
+ currentIndex,
+ question,
+ dirtySids,
+ goTo,
+ hasNextStudent,
+ nextQuestion,
+ goToQuestion
+ ]);
+
+ const firstUngraded = useMemo(
+ () => findFirstUngradedSid(answers, question, { dirtySids }) ?? undefined,
+ [answers, question, dirtySids]
+ );
+
+ return {
+ current,
+ currentIndex,
+ total,
+ hasPrev,
+ hasNext,
+ prev,
+ next,
+ goTo,
+ goNextUngraded,
+ firstUngraded
+ };
+};
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts
new file mode 100644
index 000000000..fc5e7090a
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useViewModeStorage.ts
@@ -0,0 +1,31 @@
+import { useCallback, useEffect, useState } from "react";
+
+export function useViewModeStorage(
+ storageKey: string,
+ allowedModes: readonly TMode[],
+ defaultMode: TMode
+): [TMode, (mode: TMode) => void] {
+ const readInitial = useCallback((): TMode => {
+ if (typeof window === "undefined") return defaultMode;
+ const stored = window.localStorage.getItem(storageKey);
+ return stored && (allowedModes as readonly string[]).includes(stored)
+ ? (stored as TMode)
+ : defaultMode;
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [storageKey]);
+
+ const [mode, setModeState] = useState(readInitial);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ window.localStorage.setItem(storageKey, mode);
+ }, [storageKey, mode]);
+
+ const setMode = useCallback((next: TMode) => {
+ setModeState(next);
+ }, []);
+
+ return [mode, setMode];
+}
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts
new file mode 100644
index 000000000..5109a2d64
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts
@@ -0,0 +1,6 @@
+export { Grader } from "./Grader";
+export { default as GraderAssignmentsPage } from "./pages/GraderAssignmentsPage";
+export { default as GraderQuestionsPage } from "./pages/GraderQuestionsPage";
+export { default as GraderAnswersPage } from "./pages/GraderAnswersPage";
+export { default as GraderQuestionPage } from "./pages/GraderQuestionPage";
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx
new file mode 100644
index 000000000..bdba4f8df
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAnswersPage.tsx
@@ -0,0 +1,210 @@
+import { Column } from "primereact/column";
+import { DataTable } from "primereact/datatable";
+import { ProgressSpinner } from "primereact/progressspinner";
+import React, { useState } from "react";
+import { useParams } from "react-router-dom";
+
+import {
+ GraderStudentAnswer,
+ useGetGraderAnswersQuery,
+ useGetGraderQuestionsQuery
+} from "@store/grader/grader.logic.api";
+
+import { AnswerDetailDialog } from "../components/AnswerDetailDialog";
+import styles from "../Grader.module.css";
+import { getDemoAnswersFor, getDemoQuestionsFor } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+export const GraderAnswersPage: React.FC = () => {
+ const { assignmentId, questionId, sid } = useParams();
+ const aid = Number(assignmentId);
+ const qid = Number(questionId);
+
+ const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext();
+
+ const { data: qRealData } = useGetGraderQuestionsQuery(aid, {
+ skip: !aid || isDemo
+ });
+ const { data: realData, isLoading } = useGetGraderAnswersQuery(
+ { assignmentId: aid, questionId: qid },
+ { skip: !aid || !qid || isDemo }
+ );
+
+ const data = isDemo ? getDemoAnswersFor(aid, qid) ?? undefined : realData;
+ const qData = isDemo ? getDemoQuestionsFor(aid) ?? undefined : qRealData;
+
+ const [selected, setSelected] = useState(null);
+
+ const effectiveSelected = isDemo ? demoSelected : selected;
+
+ const questionMeta = qData?.questions.find((q) => q.id === qid);
+
+ const didAutoOpenRef = React.useRef(false);
+ React.useEffect(() => {
+ if (isDemo) return;
+ if (didAutoOpenRef.current) return;
+ if (sid && data?.answers.length) {
+ const match = data.answers.find((a) => a.sid === sid) || null;
+ if (match) {
+ setSelected(match);
+ didAutoOpenRef.current = true;
+ }
+ }
+ }, [sid, data, isDemo]);
+
+ if (!data && isLoading) {
+ return (
+
+ );
+ }
+
+ if (!data) {
+ return Could not load student answers.
;
+ }
+
+ const closeDialog = () => {
+ if (isDemo) setDemoSelected(null);
+ else setSelected(null);
+ };
+
+ return (
+ <>
+
+
{
+ const row = e.data as GraderStudentAnswer;
+ if (isDemo) setDemoSelected(row);
+ else setSelected(row);
+ }}
+ rowHover
+ className={styles.row}
+ emptyMessage="No student answers yet."
+ >
+ (
+
+
+ {row.first_name || ""} {row.last_name || ""}
+
+
+ {row.sid}
+
+
+ )}
+ />
+ (
+
+ {row.answer || (empty)}
+
+ )}
+ />
+ (
+ {row.attempts}
+ )}
+ />
+ {
+ if (row.correct == null) {
+ return n/a;
+ }
+ if (row.correct) {
+ return correct;
+ }
+ if ((row.percent ?? 0) > 0) {
+ return partial;
+ }
+ return wrong;
+ }}
+ />
+ (
+
+ {row.score != null ? row.score : "—"}
+
+ {" "}
+ / {row.max_points}
+
+
+ )}
+ />
+
+ row.comment ? (
+
+ {row.comment.slice(0, 80)}
+ {row.comment.length > 80 ? "…" : ""}
+
+ ) : (
+ —
+ )
+ }
+ />
+ (
+
+ )}
+ />
+
+
+
+ {
+ if (isDemo) setDemoSelected(row);
+ else setSelected(row);
+ }}
+ />
+ >
+ );
+};
+
+export default GraderAnswersPage;
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx
new file mode 100644
index 000000000..a66ce29c7
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx
@@ -0,0 +1,336 @@
+import { FilterMatchMode } from "primereact/api";
+import { Column } from "primereact/column";
+import { DataTable, DataTableFilterMeta } from "primereact/datatable";
+import { ProgressSpinner } from "primereact/progressspinner";
+import React, { useState } from "react";
+import DatePicker from "react-datepicker";
+import "react-datepicker/dist/react-datepicker.css";
+import { useNavigate } from "react-router-dom";
+
+import { useGetAssignmentsQuery } from "@store/assignment/assignment.logic.api";
+
+import {
+ GraderViewMode,
+ ViewModeToggle
+} from "../components/ViewModeToggle";
+import styles from "../Grader.module.css";
+import { useViewModeStorage } from "../hooks/useViewModeStorage";
+import { DEMO_ASSIGNMENTS } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+const VIEW_MODES = ["cards", "table"] as const satisfies readonly GraderViewMode[];
+const VIEW_MODE_STORAGE_KEY = "grader.assignmentsViewMode";
+
+const formatDate = (iso?: string | null) => {
+ if (!iso) return "No due date";
+ try {
+ return new Date(iso).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric"
+ });
+ } catch {
+ return iso;
+ }
+};
+
+const buildInitialFilters = (): DataTableFilterMeta => ({
+ name: { value: null, matchMode: FilterMatchMode.CONTAINS },
+ description: { value: null, matchMode: FilterMatchMode.CONTAINS },
+ points: { value: null, matchMode: FilterMatchMode.EQUALS }
+});
+
+const matchesDateRange = (
+ dueDate: Date | null,
+ range: [Date | null, Date | null] | null
+): boolean => {
+ if (!range) return true;
+ const [from, to] = range;
+ if (!from && !to) return true;
+ if (!dueDate) return false;
+ const t = dueDate.getTime();
+ if (from) {
+ const start = new Date(from);
+ start.setHours(0, 0, 0, 0);
+ if (t < start.getTime()) return false;
+ }
+ if (to) {
+ const end = new Date(to);
+ end.setHours(23, 59, 59, 999);
+ if (t > end.getTime()) return false;
+ }
+ return true;
+};
+
+export const GraderAssignmentsPage: React.FC = () => {
+ const navigate = useNavigate();
+ const { data: realAssignments, isLoading } = useGetAssignmentsQuery();
+ const { isDemo } = useGraderTourContext();
+ const assignments = isDemo ? DEMO_ASSIGNMENTS : realAssignments;
+
+ const [viewMode, setViewMode] = useViewModeStorage(
+ VIEW_MODE_STORAGE_KEY,
+ VIEW_MODES,
+ "cards"
+ );
+
+ const [filters, setFilters] = useState(buildInitialFilters);
+
+ const [dateRange, setDateRange] = useState<[Date | null, Date | null] | null>(
+ null
+ );
+
+ const [first, setFirst] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(25);
+
+ const [sortField, setSortField] = useState("duedateDate");
+ const [sortOrder, setSortOrder] = useState<1 | -1 | 0 | null | undefined>(1);
+
+ if (!assignments && isLoading) {
+ return (
+
+ );
+ }
+
+ if (!assignments?.length) {
+ return (
+
+
+
No assignments yet
+
Create an assignment in the Assignment Builder to start grading.
+
+ );
+ }
+
+ const allRows = assignments.map((a) => ({
+ ...a,
+ duedateDate: a.duedate ? new Date(a.duedate) : null,
+ duedateDisplay: formatDate(a.duedate)
+ }));
+
+ const rows = dateRange
+ ? allRows.filter((r) => matchesDateRange(r.duedateDate, dateRange))
+ : allRows;
+
+ const viewToggle = (
+
+ );
+
+ if (viewMode === "table") {
+ return (
+ <>
+ {viewToggle}
+
+
{
+ setFirst(e.first);
+ setRowsPerPage(e.rows);
+ }}
+ rowsPerPageOptions={[10, 25, 50, 100]}
+ sortField={sortField}
+ sortOrder={sortOrder}
+ onSort={(e) => {
+ setSortField(e.sortField);
+ setSortOrder(e.sortOrder as 1 | -1 | 0 | null | undefined);
+ setFirst(0);
+ }}
+ selectionMode="single"
+ onRowClick={(e) => {
+ const row = e.data as (typeof rows)[number];
+ navigate(`/grader/${row.id}`);
+ }}
+ rowHover
+ className={styles.row}
+ emptyMessage="No assignments match the current filters."
+ filters={filters}
+ onFilter={(e) => {
+ setFilters(e.filters);
+ setFirst(0);
+ }}
+ filterDisplay="row"
+ >
+ (
+
+
+ {row.name}
+
+ )}
+ />
+
+ row.description ? (
+
+ {row.description}
+
+ ) : (
+ —
+ )
+ }
+ />
+ {
+ const [startDate, endDate] = dateRange ?? [null, null];
+ return (
+ {
+ const [from, to] =
+ (dates as [Date | null, Date | null]) ?? [null, null];
+ setDateRange(!from && !to ? null : [from, to]);
+ setFirst(0);
+ }}
+ isClearable
+ dateFormat="MMM d, yyyy"
+ placeholderText="Pick range"
+ className={styles.dateFilterInput}
+ wrapperClassName={styles.dateFilterWrapper}
+ popperClassName={styles.dateFilterPopper}
+ portalId="root"
+ />
+ );
+ }}
+ style={{ width: 240 }}
+ body={(row) => (
+
+
+ {row.duedateDisplay}
+
+ )}
+ />
+ (
+
+ {row.points ?? 0}
+ pts
+
+ )}
+ />
+ (
+
+ )}
+ />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {viewToggle}
+
+ {assignments.map((a, idx) => (
+ navigate(`/grader/${a.id}`)}
+ style={{ textAlign: "left" }}
+ >
+
+
+ {a.name}
+
+ {a.description && (
+
+ {a.description}
+
+ )}
+
+
+ {formatDate(a.duedate)}
+
+
+ {a.points ?? 0} pts
+
+
+
+ ))}
+
+ >
+ );
+};
+
+export default GraderAssignmentsPage;
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx
new file mode 100644
index 000000000..c0b40a425
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx
@@ -0,0 +1,387 @@
+import { Button } from "primereact/button";
+import { ProgressSpinner } from "primereact/progressspinner";
+import { Toast } from "primereact/toast";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+
+import {
+ GraderStudentAnswer,
+ useGetGraderAnswersQuery,
+ useGetGraderQuestionsQuery,
+ useSaveGradeMutation
+} from "@store/grader/grader.logic.api";
+
+import { GradePanel, GradePanelHandle } from "../components/GradePanel";
+import { ShortcutsHelpDialog } from "../components/ShortcutsHelpDialog";
+import { StudentListSidebar } from "../components/StudentListSidebar";
+import { SubmissionPane, SubmissionPaneHandle } from "../components/SubmissionPane";
+import { AutoSavedInfo, useAutoSaveGrade } from "../hooks/useAutoSaveGrade";
+import { useGraderHotkeys } from "../hooks/useGraderHotkeys";
+import { useGraderPrefs } from "../hooks/useGraderPrefs";
+import { usePlatform } from "../hooks/usePlatform";
+import { useStudentNavigation } from "../hooks/useStudentNavigation";
+import styles from "../Grader.module.css";
+import { getDemoAnswersFor, getDemoQuestionsFor } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+export const GraderQuestionPage: React.FC = () => {
+ const { assignmentId, questionId, sid } = useParams();
+ const aid = Number(assignmentId);
+ const qid = Number(questionId);
+ const navigate = useNavigate();
+ const location = useLocation();
+ const platform = usePlatform();
+
+ const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext();
+
+ const { data: qRealData } = useGetGraderQuestionsQuery(aid, {
+ skip: !aid || isDemo
+ });
+ const { data: realData, isLoading } = useGetGraderAnswersQuery(
+ { assignmentId: aid, questionId: qid },
+ { skip: !aid || !qid || isDemo }
+ );
+ const data = isDemo ? getDemoAnswersFor(aid, qid) ?? undefined : realData;
+ const qData = isDemo ? getDemoQuestionsFor(aid) ?? undefined : qRealData;
+ const questionMeta = qData?.questions.find((q) => q.id === qid);
+
+ const answers: ReadonlyArray = data?.answers ?? [];
+ const activeSid = isDemo ? demoSelected?.sid : sid;
+
+ const [dirtySids, setDirtySids] = useState>(new Set());
+
+ const nav = useStudentNavigation({
+ answers,
+ question: questionMeta,
+ dirtySids,
+ questions: qData?.questions
+ });
+
+ useEffect(() => {
+ if (isDemo) return;
+ if (sid || !data) return;
+ const selectLast = (location.state as { selectLast?: boolean } | null)?.selectLast === true;
+ let targetSid: string | undefined;
+ if (selectLast && answers.length) {
+ targetSid = answers[answers.length - 1].sid;
+ } else if (nav.firstUngraded) {
+ targetSid = nav.firstUngraded;
+ } else if (answers.length) {
+ targetSid = answers[0].sid;
+ }
+ if (targetSid) {
+ navigate(
+ `/grader/${aid}/questions/${qid}/students/${encodeURIComponent(targetSid)}`,
+
+ { replace: true, state: null }
+ );
+ }
+ }, [isDemo, sid, data, nav.firstUngraded, answers, aid, qid, navigate, location.state]);
+
+ const student = nav.current;
+
+ const { prefs, updatePrefs } = useGraderPrefs();
+ const toastRef = useRef(null);
+ const [revertGrade] = useSaveGradeMutation();
+
+ const advanceTimerRef = useRef(null);
+ const cancelPendingAdvance = useCallback(() => {
+ if (advanceTimerRef.current != null) {
+ window.clearTimeout(advanceTimerRef.current);
+ advanceTimerRef.current = null;
+ }
+ }, []);
+
+ const answersRef = useRef(answers);
+ useEffect(() => {
+ answersRef.current = answers;
+ }, [answers]);
+ const navRef = useRef(nav);
+ useEffect(() => {
+ navRef.current = nav;
+ }, [nav]);
+ const prefsRef = useRef(prefs);
+ useEffect(() => {
+ prefsRef.current = prefs;
+ }, [prefs]);
+ const questionNameRef = useRef(data?.question.name ?? "");
+ useEffect(() => {
+ questionNameRef.current = data?.question.name ?? "";
+ }, [data?.question.name]);
+
+ const autoSaveRef = useRef | null>(null);
+
+ const undoSave = useCallback(
+ async (info: AutoSavedInfo) => {
+ cancelPendingAdvance();
+ toastRef.current?.clear();
+ try {
+ if (!isDemo) {
+ await revertGrade({
+ sid: info.sid,
+ div_id: info.questionName,
+ score: info.previous.score,
+ comment: info.previous.comment,
+ questionId: info.questionId,
+ assignmentId: aid
+ }).unwrap();
+ }
+ } catch {
+
+ }
+
+ navRef.current.goTo(info.sid);
+
+ autoSaveRef.current?.revertTo(info.previous);
+ },
+ [cancelPendingAdvance, isDemo, revertGrade, aid]
+ );
+
+ const handleSaved = useCallback(
+ (info: AutoSavedInfo) => {
+ const row = answersRef.current.find((a) => a.sid === info.sid);
+ const displayName =
+ row && (row.first_name || row.last_name)
+ ? `${row.first_name ?? ""} ${row.last_name ?? ""}`.trim()
+ : info.sid;
+
+ toastRef.current?.replace({
+ severity: "success",
+ content: (
+
+
+
+
Saved {displayName}
+
+ {info.next.score} pt{info.next.score === 1 ? "" : "s"}
+ {info.next.comment ? " · with comment" : ""}
+
+
+
undoSave(info)}
+ />
+
+ ),
+ life: 5000
+ });
+
+ if (prefsRef.current.autoAdvance && navRef.current.hasNext) {
+ cancelPendingAdvance();
+ advanceTimerRef.current = window.setTimeout(() => {
+ advanceTimerRef.current = null;
+
+ navRef.current.goNextUngraded();
+ }, 650);
+ }
+ },
+ [cancelPendingAdvance, undoSave]
+ );
+
+ useEffect(() => () => cancelPendingAdvance(), [cancelPendingAdvance, qid]);
+
+ const autoSave = useAutoSaveGrade({
+ sid: student?.sid,
+ assignmentId: aid,
+ questionId: qid,
+ questionName: data?.question.name ?? "",
+ maxPoints: data?.question.max_points ?? 0,
+ initialScore: student?.score ?? 0,
+ initialComment: student?.comment ?? "",
+ onSaved: handleSaved
+ });
+
+ useEffect(() => {
+ autoSaveRef.current = autoSave;
+ }, [autoSave]);
+
+ useEffect(() => {
+ if (!student?.sid) return;
+ const id = student.sid;
+ if (autoSave.status === "dirty" || autoSave.status === "saving") {
+
+ cancelPendingAdvance();
+ setDirtySids((prev) => {
+ if (prev.has(id)) return prev;
+ const next = new Set(prev);
+ next.add(id);
+ return next;
+ });
+ } else {
+ setDirtySids((prev) => {
+ if (!prev.has(id)) return prev;
+ const next = new Set(prev);
+ next.delete(id);
+ return next;
+ });
+ }
+ }, [autoSave.status, student?.sid]);
+
+ const [help, setHelp] = useState(false);
+ const [hideGraded, setHideGraded] = useState(false);
+ const gradePanelRef = useRef(null);
+ const submissionRef = useRef(null);
+
+ useGraderHotkeys({
+ next: async () => {
+ cancelPendingAdvance();
+ await autoSave.flush();
+ nav.next();
+ },
+ prev: async () => {
+ cancelPendingAdvance();
+ await autoSave.flush();
+ nav.prev();
+ },
+ nextAttempt: () => submissionRef.current?.nextAttempt(),
+ prevAttempt: () => submissionRef.current?.prevAttempt(),
+ focusGrade: () => gradePanelRef.current?.focusGrade(),
+ focusComment: () => gradePanelRef.current?.focusComment(),
+ toggleHideGraded: () => setHideGraded((v) => !v),
+ openHelp: () => setHelp(true)
+ });
+
+ const selectSid = async (nextSid: string) => {
+ if (nextSid === activeSid) return;
+ cancelPendingAdvance();
+
+ await autoSave.flush();
+ if (isDemo) {
+ const row = answers.find((a) => a.sid === nextSid);
+ setDemoSelected(row ?? null);
+ } else {
+ navigate(
+ `/grader/${aid}/questions/${qid}/students/${encodeURIComponent(nextSid)}`
+ );
+ }
+ };
+
+ const positionLabel = useMemo(() => {
+ if (nav.currentIndex < 0) return undefined;
+ return `${nav.currentIndex + 1} / ${nav.total}`;
+ }, [nav.currentIndex, nav.total]);
+
+ if (!data && isLoading) {
+ return (
+
+ );
+ }
+ if (!data) {
+ return Could not load student answers.
;
+ }
+
+ return (
+ <>
+
+
+
+
+ {!student ? (
+
+
+ {answers.length === 0 ? (
+
No student answers yet.
+ ) : (
+ Select a student from the list to start grading.
+ )}
+
+
{}}
+ onCommentChange={() => {}}
+ onScoreBlur={() => {}}
+ onCommentBlur={() => {}}
+ onPrev={() => nav.prev()}
+ onNext={() => nav.next()}
+ onRetry={() => {}}
+ />
+
+ ) : (
+
+
+ {
+ cancelPendingAdvance();
+ await autoSave.flush();
+ nav.prev();
+ }}
+ onNext={async () => {
+ cancelPendingAdvance();
+ await autoSave.flush();
+ nav.next();
+ }}
+ onRetry={autoSave.saveNow}
+ autoAdvance={prefs.autoAdvance}
+ onAutoAdvanceChange={(v) => updatePrefs({ autoAdvance: v })}
+ />
+
+ )}
+
+
+
+
+
+ setHelp(false)}
+ platform={platform}
+ />
+ >
+ );
+};
+
+export default GraderQuestionPage;
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx
new file mode 100644
index 000000000..9ca8ab029
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx
@@ -0,0 +1,453 @@
+import { FilterMatchMode } from "primereact/api";
+import { Column } from "primereact/column";
+import { DataTable, DataTableFilterMeta } from "primereact/datatable";
+import { Dropdown } from "primereact/dropdown";
+import { ProgressSpinner } from "primereact/progressspinner";
+import React, { useMemo, useState } from "react";
+import { useNavigate, useParams } from "react-router-dom";
+
+import { useGetGraderQuestionsQuery } from "@store/grader/grader.logic.api";
+
+import {
+ GraderViewMode,
+ ViewModeToggle
+} from "../components/ViewModeToggle";
+import styles from "../Grader.module.css";
+import { useViewModeStorage } from "../hooks/useViewModeStorage";
+import { getDemoQuestionsFor } from "../tour/graderDemoData";
+import { useGraderTourContext } from "../tour/GraderTourContext";
+
+const friendlyType = (t: string) => {
+ const map: Record = {
+ mchoice: "Multiple choice",
+ fillintheblank: "Fill in the blank",
+ parsonsprob: "Parsons",
+ activecode: "Active code",
+ shortanswer: "Short answer",
+ clickablearea: "Clickable",
+ dragndrop: "Drag & drop",
+ codelens: "Codelens",
+ matching: "Matching",
+ webwork: "WeBWorK"
+ };
+ return map[t] || t;
+};
+
+const MANUALLY_SCORED_TYPES = new Set(["shortanswer"]);
+
+const PARTIAL_CREDIT_TYPES = new Set([
+ "dragndrop",
+ "clickablearea",
+ "matching",
+ "fillintheblank",
+ "parsonsprob",
+ "microparsons",
+ "hparsons"
+]);
+
+const VIEW_MODES = ["cards", "table"] as const satisfies readonly GraderViewMode[];
+const VIEW_MODE_STORAGE_KEY = "grader.questionsViewMode";
+
+const buildInitialFilters = (): DataTableFilterMeta => ({
+ name: { value: null, matchMode: FilterMatchMode.CONTAINS },
+ question_type: { value: null, matchMode: FilterMatchMode.EQUALS },
+ answered_count: { value: null, matchMode: FilterMatchMode.EQUALS },
+ correct_count: { value: null, matchMode: FilterMatchMode.EQUALS },
+ points: { value: null, matchMode: FilterMatchMode.EQUALS }
+});
+
+type QuestionRow = ReturnType extends infer T
+ ? T extends { questions: (infer Q)[] | undefined }
+ ? Q
+ : never
+ : never;
+
+interface QuestionStats {
+ correctPct: number;
+ pointsPct: number;
+ isManual: boolean;
+ usePartial: boolean;
+ correctLabel: string;
+ correctTooltip: string;
+ avgTooltip: string;
+}
+
+const computeStats = (q: QuestionRow): QuestionStats => {
+ const isManual = MANUALLY_SCORED_TYPES.has(q.question_type);
+ const usePartial =
+ PARTIAL_CREDIT_TYPES.has(q.question_type) && q.avg_percent != null;
+
+ const correctPct = usePartial
+ ? (q.avg_percent ?? 0) * 100
+ : q.answered_count > 0
+ ? (q.correct_count / q.answered_count) * 100
+ : 0;
+
+ const pointsPct = q.points > 0 ? (q.average_score / q.points) * 100 : 0;
+ const avgDenominator = q.graded_count ?? 0;
+
+ const correctLabel = usePartial
+ ? "avg. credit"
+ : isManual
+ ? "fully scored"
+ : "fully correct";
+ const correctTooltip = usePartial
+ ? `Mean partial credit across all answers (${q.question_type})`
+ : isManual
+ ? "Students whose graded score equals the question's max points"
+ : "Students who submitted a fully correct answer at least once";
+ const avgTooltip = avgDenominator
+ ? `Average across ${avgDenominator} graded student${
+ avgDenominator === 1 ? "" : "s"
+ }`
+ : "No graded submissions yet";
+
+ return {
+ correctPct,
+ pointsPct,
+ isManual,
+ usePartial,
+ correctLabel,
+ correctTooltip,
+ avgTooltip
+ };
+};
+
+export const GraderQuestionsPage: React.FC = () => {
+ const { assignmentId } = useParams();
+ const id = Number(assignmentId);
+ const navigate = useNavigate();
+ const { isDemo } = useGraderTourContext();
+ const { data: realData, isLoading, isError } = useGetGraderQuestionsQuery(id, {
+ skip: !id || isDemo,
+ refetchOnMountOrArgChange: true
+ });
+ const data = isDemo ? getDemoQuestionsFor(id) ?? undefined : realData;
+
+ const [viewMode, setViewMode] = useViewModeStorage(
+ VIEW_MODE_STORAGE_KEY,
+ VIEW_MODES,
+ "cards"
+ );
+
+ const [filters, setFilters] = useState(buildInitialFilters);
+
+ const [first, setFirst] = useState(0);
+ const [rowsPerPage, setRowsPerPage] = useState(25);
+
+ const [sortField, setSortField] = useState(undefined);
+ const [sortOrder, setSortOrder] = useState<1 | -1 | 0 | null | undefined>(null);
+
+ const typeOptions = useMemo(() => {
+ if (!data) return [] as { label: string; value: string }[];
+ const unique = Array.from(new Set(data.questions.map((q) => q.question_type)));
+ return unique
+ .map((t) => ({ label: friendlyType(t), value: t }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ }, [data]);
+
+ if (!data && isLoading) {
+ return (
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+
+
Could not load questions
+ {isError &&
The server returned an error.
}
+
+ );
+ }
+
+ if (!data.questions.length) {
+ return (
+
+
No gradable questions in this assignment.
+
+ );
+ }
+
+ const viewToggle = (
+
+ );
+
+ if (viewMode === "table") {
+ return (
+ <>
+ {viewToggle}
+
+
{
+ setFirst(e.first);
+ setRowsPerPage(e.rows);
+ }}
+ rowsPerPageOptions={[10, 25, 50, 100]}
+ sortField={sortField}
+ sortOrder={sortOrder}
+ onSort={(e) => {
+ setSortField(e.sortField);
+ setSortOrder(e.sortOrder as 1 | -1 | 0 | null | undefined);
+ setFirst(0);
+ }}
+ selectionMode="single"
+ onRowClick={(e) => {
+ const row = e.data as QuestionRow;
+ navigate(`/grader/${id}/questions/${row.id}`);
+ }}
+ rowHover
+ className={styles.row}
+ emptyMessage="No questions match the current filters."
+ filters={filters}
+ onFilter={(e) => {
+ setFilters(e.filters);
+ setFirst(0);
+ }}
+ filterDisplay="row"
+ >
+ (
+
+ {row.name}
+
+ )}
+ />
+ (
+ options.filterApplyCallback(e.value)}
+ placeholder="Any"
+ showClear
+ style={{ width: "100%", minWidth: 0 }}
+ />
+ )}
+ body={(row: QuestionRow) => (
+
+ {friendlyType(row.question_type)}
+
+ )}
+ />
+ (
+
+
+ {row.answered_count}
+
+ )}
+ />
+ {
+ const stats = computeStats(row);
+ return (
+
+ {row.correct_count}
+
+ {" "}
+ {stats.correctLabel}
+
+
+ );
+ }}
+ />
+ {
+ const stats = computeStats(row);
+ return (
+
+ {row.average_score}
+ / {row.points}
+
+ );
+ }}
+ />
+ (
+
+ {row.points}
+
+ )}
+ />
+ {
+ const stats = computeStats(row);
+ return (
+
+
+ {Math.round(stats.correctPct)}%{" "}
+ {stats.usePartial ? "credit" : "correct"}
+
+
+
+ );
+ }}
+ />
+ (
+
+ )}
+ />
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+ {viewToggle}
+
+ {data.questions.map((q) => {
+ const stats = computeStats(q);
+ return (
+ navigate(`/grader/${id}/questions/${q.id}`)}
+ style={{ textAlign: "left" }}
+ >
+
+
+ {q.name}
+
+
+ {friendlyType(q.question_type)}
+
+
+
+
+ {q.answered_count} answered
+
+
+ {" "}
+ {q.correct_count} {stats.correctLabel}
+
+
+
+
+ {q.average_score} / {q.points}
+
+
+ {Math.round(stats.correctPct)}%{" "}
+ {stats.usePartial ? "credit" : "correct"}
+
+
+
+
+ );
+ })}
+
+ >
+ );
+};
+
+export default GraderQuestionsPage;
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts
new file mode 100644
index 000000000..899916e29
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/state/graderSelectors.ts
@@ -0,0 +1,119 @@
+import {
+ GraderQuestionStats,
+ GraderStudentAnswer
+} from "@store/grader/grader.logic.api";
+
+export type StudentGradingStatus =
+ | "graded"
+ | "autograded"
+ | "in_progress"
+ | "pending"
+ | "no_submission";
+
+const isAutogradeManual = (autograde?: string) =>
+ !autograde || autograde === "manual";
+
+export const getStudentStatus = (
+ s: GraderStudentAnswer,
+ q?: { autograde?: string },
+ opts: { dirtySids?: ReadonlySet } = {}
+): StudentGradingStatus => {
+ if (opts.dirtySids?.has(s.sid)) return "in_progress";
+
+ if (s.attempts === 0 && s.score == null) return "no_submission";
+
+ if (isAutogradeManual(q?.autograde)) {
+ if (s.score != null || (s.comment && s.comment.length > 0)) return "graded";
+ return "pending";
+ }
+
+ if (s.score != null) {
+
+ if (s.comment && s.comment.length > 0) return "graded";
+ return "autograded";
+ }
+ return "pending";
+};
+
+export interface QuestionProgress {
+ total: number;
+ graded: number;
+ autograded: number;
+ pending: number;
+ noSubmission: number;
+ inProgress: number;
+
+ donePct: number;
+}
+
+export const getQuestionProgress = (
+ answers: ReadonlyArray,
+ q?: { autograde?: string },
+ opts: { dirtySids?: ReadonlySet } = {}
+): QuestionProgress => {
+ const out: QuestionProgress = {
+ total: answers.length,
+ graded: 0,
+ autograded: 0,
+ pending: 0,
+ noSubmission: 0,
+ inProgress: 0,
+ donePct: 0
+ };
+ for (const a of answers) {
+ const st = getStudentStatus(a, q, opts);
+ if (st === "graded") out.graded++;
+ else if (st === "autograded") out.autograded++;
+ else if (st === "pending") out.pending++;
+ else if (st === "no_submission") out.noSubmission++;
+ else if (st === "in_progress") out.inProgress++;
+ }
+ const done = out.graded + out.autograded;
+ out.donePct = out.total ? (done / out.total) * 100 : 0;
+ return out;
+};
+
+export const findNextUngradedSid = (
+ answers: ReadonlyArray,
+ fromIndex: number,
+ q?: { autograde?: string },
+ opts: { dirtySids?: ReadonlySet } = {}
+): string | null => {
+ for (let i = fromIndex + 1; i < answers.length; i++) {
+ const st = getStudentStatus(answers[i], q, opts);
+ if (st === "pending" || st === "in_progress") return answers[i].sid;
+ }
+ return null;
+};
+
+export const findFirstUngradedSid = (
+ answers: ReadonlyArray,
+ q?: { autograde?: string },
+ opts: { dirtySids?: ReadonlySet } = {}
+): string | null => findNextUngradedSid(answers, -1, q, opts);
+
+export const statusLabel: Record = {
+ graded: "Graded",
+ autograded: "Auto-graded",
+ in_progress: "In progress",
+ pending: "Pending",
+ no_submission: "No submission"
+};
+
+export const statusIcon: Record = {
+ graded: "pi pi-check-circle",
+ autograded: "pi pi-bolt",
+ in_progress: "pi pi-spin pi-sync",
+ pending: "pi pi-circle",
+ no_submission: "pi pi-minus-circle"
+};
+
+export const statusColor: Record = {
+ graded: "#16a34a",
+ autograded: "#0ea5e9",
+ in_progress: "#f59e0b",
+ pending: "#94a3b8",
+ no_submission: "#cbd5e1"
+};
+
+export type { GraderQuestionStats };
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx
new file mode 100644
index 000000000..0c0bfd131
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/GraderTourContext.tsx
@@ -0,0 +1,41 @@
+import React, { createContext, useContext, useMemo, useState } from "react";
+
+import type { GraderStudentAnswer } from "@store/grader/grader.logic.api";
+
+interface GraderTourContextValue {
+ isDemo: boolean;
+ demoSelected: GraderStudentAnswer | null;
+ setDemoSelected: (s: GraderStudentAnswer | null) => void;
+ setIsDemo: (v: boolean) => void;
+}
+
+const Ctx = createContext(null);
+
+export const GraderTourProvider: React.FC<{ children: React.ReactNode }> = ({
+ children
+}) => {
+ const [isDemo, setIsDemo] = useState(false);
+ const [demoSelected, setDemoSelected] =
+ useState(null);
+
+ const value = useMemo(
+ () => ({ isDemo, setIsDemo, demoSelected, setDemoSelected }),
+ [isDemo, demoSelected]
+ );
+
+ return {children};
+};
+
+export const useGraderTourContext = (): GraderTourContextValue => {
+ const v = useContext(Ctx);
+ if (!v) {
+ return {
+ isDemo: false,
+ demoSelected: null,
+ setDemoSelected: () => {},
+ setIsDemo: () => {}
+ };
+ }
+ return v;
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts
new file mode 100644
index 000000000..3cf2a61c6
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderDemoData.ts
@@ -0,0 +1,495 @@
+import type { Assignment } from "@/types/assignment";
+import type {
+ GraderAnswersResponse,
+ GraderHistoryResponse,
+ GraderQuestionsResponse,
+ GraderStudentAnswer
+} from "@store/grader/grader.logic.api";
+
+export const DEMO_ASSIGNMENT_ID = 900001;
+export const DEMO_ALT_ASSIGNMENT_ID = 900002;
+export const DEMO_ALT2_ASSIGNMENT_ID = 900003;
+
+export const DEMO_QUESTION_ID = 910001;
+export const DEMO_ACTIVECODE_QID = 910002;
+export const DEMO_PARSONS_QID = 910003;
+export const DEMO_FITB_QID = 910004;
+export const DEMO_SHORT_QID = 910005;
+export const DEMO_CLICKABLE_QID = 910006;
+export const DEMO_DND_QID = 910007;
+export const DEMO_CODELENS_QID = 910008;
+export const DEMO_MATCHING_QID = 910009;
+export const DEMO_WEBWORK_QID = 910010;
+
+export const DEMO_STUDENT_SID = "demo-student-1";
+
+export const DEMO_MCHOICE_HTMLSRC = `
+
+
+
Question: Which loop iterates exactly five times and prints the numbers 0 through 4?
+
+
+
+`;
+
+const iso = (d: Date) => d.toISOString();
+const daysFromNow = (n: number) => {
+ const d = new Date();
+ d.setDate(d.getDate() + n);
+ return iso(d);
+};
+
+const baseAssignment = {
+ updated_date: null,
+ visible_on: null,
+ hidden_on: null,
+ visible: true,
+ is_peer: false,
+ is_timed: false,
+ nofeedback: false,
+ nopause: false,
+ time_limit: null,
+ peer_async_visible: false,
+ exercises: [] as [],
+ all_assignments: [] as [],
+ search_results: [] as [],
+ isAuthorized: true,
+ released: true,
+ selectedAssignments: [] as [],
+ course: 0,
+ threshold_pct: null,
+ allow_self_autograde: null,
+ from_source: false,
+ current_index: 0,
+ enforce_due: false
+};
+
+export const DEMO_ASSIGNMENTS: Assignment[] = [
+ {
+ ...baseAssignment,
+ id: DEMO_ASSIGNMENT_ID,
+ name: "Demo – Week 3: Loops & Functions",
+ description:
+ "Sample assignment used by the tour — covers every question type supported by the grader.",
+ duedate: daysFromNow(4),
+ points: 40,
+ kind: "Regular",
+ question_count: 10
+ },
+ {
+ ...baseAssignment,
+ id: DEMO_ALT_ASSIGNMENT_ID,
+ name: "Demo – Week 4: Lists & Dictionaries",
+ description:
+ "Second demo assignment, shown so the picker grid is not a lone card.",
+ duedate: daysFromNow(11),
+ points: 30,
+ kind: "Regular",
+ question_count: 6
+ },
+ {
+ ...baseAssignment,
+ id: DEMO_ALT2_ASSIGNMENT_ID,
+ name: "Demo – Peer review: Algorithms",
+ description: "A third demo so the grid renders at least one row comfortably.",
+ duedate: daysFromNow(18),
+ points: 20,
+ is_peer: true,
+ peer_async_visible: true,
+ kind: "Peer",
+ question_count: 4
+ }
+];
+
+export const DEMO_QUESTIONS: GraderQuestionsResponse = {
+ assignment: {
+ id: DEMO_ASSIGNMENT_ID,
+ name: DEMO_ASSIGNMENTS[0].name,
+ description: DEMO_ASSIGNMENTS[0].description,
+ duedate: DEMO_ASSIGNMENTS[0].duedate,
+ points: DEMO_ASSIGNMENTS[0].points
+ },
+ questions: [
+ {
+ id: DEMO_QUESTION_ID,
+ name: "q_loops_mchoice",
+ question_type: "mchoice",
+ points: 5,
+ autograde: "all_or_nothing",
+ which_to_grade: "best_answer",
+ answered_count: 22,
+ correct_count: 17,
+ average_score: 3.9
+ },
+ {
+ id: DEMO_ACTIVECODE_QID,
+ name: "q_reverse_list_ac",
+ question_type: "activecode",
+ points: 8,
+ autograde: "unittest",
+ which_to_grade: "last_answer",
+ answered_count: 20,
+ correct_count: 11,
+ average_score: 5.2
+ },
+ {
+ id: DEMO_PARSONS_QID,
+ name: "q_swap_parsons",
+ question_type: "parsonsprob",
+ points: 4,
+ autograde: "manual",
+ which_to_grade: "best_answer",
+ answered_count: 18,
+ correct_count: 14,
+ average_score: 3.4
+ },
+ {
+ id: DEMO_FITB_QID,
+ name: "q_syntax_fitb",
+ question_type: "fillintheblank",
+ points: 3,
+ autograde: "all_or_nothing",
+ which_to_grade: "first_answer",
+ answered_count: 24,
+ correct_count: 19,
+ average_score: 2.6
+ },
+ {
+ id: DEMO_SHORT_QID,
+ name: "q_explain_shortanswer",
+ question_type: "shortanswer",
+ points: 6,
+ autograde: "manual",
+ which_to_grade: "manual",
+ answered_count: 15,
+ correct_count: 0,
+ average_score: 3.1
+ },
+ {
+ id: DEMO_CLICKABLE_QID,
+ name: "q_find_bug_click",
+ question_type: "clickablearea",
+ points: 3,
+ autograde: "all_or_nothing",
+ which_to_grade: "best_answer",
+ answered_count: 19,
+ correct_count: 12,
+ average_score: 2.0
+ },
+ {
+ id: DEMO_DND_QID,
+ name: "q_order_steps_dnd",
+ question_type: "dragndrop",
+ points: 4,
+ autograde: "all_or_nothing",
+ which_to_grade: "best_answer",
+ answered_count: 17,
+ correct_count: 10,
+ average_score: 2.6
+ },
+ {
+ id: DEMO_CODELENS_QID,
+ name: "q_trace_codelens",
+ question_type: "codelens",
+ points: 3,
+ autograde: "interact",
+ which_to_grade: "last_answer",
+ answered_count: 13,
+ correct_count: 13,
+ average_score: 3.0
+ },
+ {
+ id: DEMO_MATCHING_QID,
+ name: "q_match_concepts",
+ question_type: "matching",
+ points: 2,
+ autograde: "all_or_nothing",
+ which_to_grade: "best_answer",
+ answered_count: 16,
+ correct_count: 9,
+ average_score: 1.3
+ },
+ {
+ id: DEMO_WEBWORK_QID,
+ name: "q_calc_webwork",
+ question_type: "webwork",
+ points: 2,
+ autograde: "all_or_nothing",
+ which_to_grade: "best_answer",
+ answered_count: 8,
+ correct_count: 5,
+ average_score: 1.4
+ }
+ ]
+};
+
+const firstNames = [
+ "Alex", "Bob", "Carmen", "Dana", "Elena", "Felix", "Grace", "Hassan",
+ "Ivy", "Jin", "Kai", "Lola", "Maxim", "Nina", "Oleg", "Pia",
+ "Quentin", "Rita", "Sergey", "Tara"
+];
+const lastNames = [
+ "Kowalski", "Müller", "Rossi", "Dupont", "Svensson", "Jones", "Reyes", "Khan",
+ "Singh", "ONeil", "Ng", "Petrov", "Tanaka", "Hernández", "Lind", "Ahmadi",
+ "Wójcik", "Silva", "Haddad", "Park"
+];
+
+const mchoiceAnswer = (i: number) => {
+ const choices = ["0", "1", "2", "3"];
+ return choices[i % choices.length];
+};
+
+export const DEMO_ANSWERS: GraderAnswersResponse = {
+ question: {
+ id: DEMO_QUESTION_ID,
+ name: "q_loops_mchoice",
+ question_type: "mchoice",
+ htmlsrc: DEMO_MCHOICE_HTMLSRC,
+ max_points: 5
+ },
+ answers: Array.from({ length: 20 }, (_, i): GraderStudentAnswer => {
+ const correct = i % 3 !== 2;
+ const partial = !correct && i % 5 === 0;
+ const attempts = (i % 4) + 1;
+ return {
+ sid: i === 0 ? DEMO_STUDENT_SID : `demo-student-${i + 1}`,
+ first_name: firstNames[i],
+ last_name: lastNames[i],
+ email: `${firstNames[i].toLowerCase()}@demo.edu`,
+ answer: mchoiceAnswer(i),
+ correct: correct,
+ percent: correct ? 1 : partial ? 0.5 : 0,
+ timestamp: iso(new Date(Date.now() - i * 3600_000)),
+ attempts,
+ score: correct ? 5 : partial ? 3 : i % 2 === 0 ? null : 1,
+ comment:
+ i === 0
+ ? "Nice reasoning! Consider off-by-one cases next time."
+ : i === 1
+ ? "Partial — rechecked after lab."
+ : i % 5 === 0
+ ? "Please show your work next submission."
+ : null,
+ max_points: 5
+ };
+ })
+};
+
+export const DEMO_HISTORY: GraderHistoryResponse = {
+ history: [
+ {
+ id: 1,
+ answer: "2",
+ correct: false,
+ percent: 0,
+ timestamp: iso(new Date(Date.now() - 26 * 3600_000)),
+ source: "mchoice"
+ },
+ {
+ id: 2,
+ answer: "3",
+ correct: false,
+ percent: 0,
+ timestamp: iso(new Date(Date.now() - 20 * 3600_000)),
+ source: "mchoice"
+ },
+ {
+ id: 3,
+ answer: "1",
+ correct: false,
+ percent: 0.5,
+ timestamp: iso(new Date(Date.now() - 8 * 3600_000)),
+ source: "mchoice"
+ },
+ {
+ id: 4,
+ answer: "1",
+ correct: false,
+ percent: 0.5,
+ timestamp: iso(new Date(Date.now() - 4 * 3600_000)),
+ source: "mchoice"
+ },
+ {
+ id: 5,
+ answer: "0",
+ correct: true,
+ percent: 1,
+ timestamp: iso(new Date(Date.now() - 3600_000)),
+ source: "mchoice"
+ }
+ ],
+ useinfo: [
+ {
+ id: 1,
+ timestamp: iso(new Date(Date.now() - 26.1 * 3600_000)),
+ event: "mChoice",
+ act: "answer:2:no"
+ },
+ {
+ id: 2,
+ timestamp: iso(new Date(Date.now() - 20.1 * 3600_000)),
+ event: "mChoice",
+ act: "answer:3:no"
+ },
+ {
+ id: 3,
+ timestamp: iso(new Date(Date.now() - 8.1 * 3600_000)),
+ event: "mChoice",
+ act: "answer:1:partial"
+ },
+ {
+ id: 4,
+ timestamp: iso(new Date(Date.now() - 4.1 * 3600_000)),
+ event: "mChoice",
+ act: "answer:1:partial"
+ },
+ {
+ id: 5,
+ timestamp: iso(new Date(Date.now() - 1.1 * 3600_000)),
+ event: "mChoice",
+ act: "answer:0:correct"
+ }
+ ]
+};
+
+export const getDemoHistoryFor = (sid: string): GraderHistoryResponse => {
+ const student = DEMO_ANSWERS.answers.find((a) => a.sid === sid);
+ if (!student) return { history: [], useinfo: [] };
+ if (sid === DEMO_STUDENT_SID) return DEMO_HISTORY;
+
+ const total = Math.max(1, student.attempts);
+ const baseTs = Date.now();
+ const distractors = ["1", "2", "3"].filter((v) => v !== student.answer);
+ const finalAnswer = student.answer ?? "0";
+ const finalCorrect = student.correct ?? false;
+ const finalPercent = student.percent ?? (finalCorrect ? 1 : 0);
+
+ const history = Array.from({ length: total }, (_, i) => {
+ const isLast = i === total - 1;
+ const attemptAnswer = isLast
+ ? finalAnswer
+ : distractors[i % distractors.length] ?? "1";
+ const attemptCorrect = isLast ? finalCorrect : false;
+ const attemptPercent = isLast ? finalPercent : i === total - 2 ? 0.5 : 0;
+ return {
+ id: i + 1,
+ answer: attemptAnswer,
+ correct: attemptCorrect,
+ percent: attemptPercent,
+ timestamp: iso(new Date(baseTs - (total - i) * 3 * 3600_000)),
+ source: "mchoice"
+ };
+ });
+
+ const useinfo = history.map((h, i) => ({
+ id: i + 1,
+ timestamp: h.timestamp,
+ event: "mChoice",
+ act: `answer:${h.answer}:${
+ h.correct ? "correct" : (h.percent ?? 0) > 0 ? "partial" : "no"
+ }`
+ }));
+
+ return { history, useinfo };
+};
+
+export const getDemoQuestionsFor = (
+ aid: number
+): GraderQuestionsResponse | null => {
+ if (aid === DEMO_ASSIGNMENT_ID) return DEMO_QUESTIONS;
+ if (aid === DEMO_ALT_ASSIGNMENT_ID) {
+ return {
+ assignment: {
+ id: DEMO_ALT_ASSIGNMENT_ID,
+ name: DEMO_ASSIGNMENTS[1].name,
+ description: DEMO_ASSIGNMENTS[1].description,
+ duedate: DEMO_ASSIGNMENTS[1].duedate,
+ points: DEMO_ASSIGNMENTS[1].points
+ },
+ questions: DEMO_QUESTIONS.questions.slice(0, 6).map((q, i) => ({
+ ...q,
+ id: q.id + 10_000,
+ name: `alt_${q.name}`,
+ answered_count: Math.max(0, q.answered_count - 4 - i)
+ }))
+ };
+ }
+ if (aid === DEMO_ALT2_ASSIGNMENT_ID) {
+ return {
+ assignment: {
+ id: DEMO_ALT2_ASSIGNMENT_ID,
+ name: DEMO_ASSIGNMENTS[2].name,
+ description: DEMO_ASSIGNMENTS[2].description,
+ duedate: DEMO_ASSIGNMENTS[2].duedate,
+ points: DEMO_ASSIGNMENTS[2].points
+ },
+ questions: DEMO_QUESTIONS.questions.slice(0, 4).map((q) => ({
+ ...q,
+ id: q.id + 20_000,
+ name: `peer_${q.name}`
+ }))
+ };
+ }
+ return null;
+};
+
+export const getDemoAnswersFor = (
+ aid: number,
+ qid: number
+): GraderAnswersResponse | null => {
+ if (aid === DEMO_ASSIGNMENT_ID && qid === DEMO_QUESTION_ID) return DEMO_ANSWERS;
+ const qMeta = getDemoQuestionsFor(aid)?.questions.find((q) => q.id === qid);
+ if (!qMeta) return null;
+ return {
+ question: {
+ id: qMeta.id,
+ name: qMeta.name,
+ question_type: qMeta.question_type,
+ htmlsrc: qMeta.question_type === "mchoice" ? DEMO_MCHOICE_HTMLSRC : undefined,
+ max_points: qMeta.points
+ },
+ answers: DEMO_ANSWERS.answers.map((a) => ({ ...a, max_points: qMeta.points }))
+ };
+};
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts
new file mode 100644
index 000000000..1e3fe9678
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts
@@ -0,0 +1,226 @@
+import { Alignment, Side } from "driver.js";
+
+export type TourRoute = "assignments" | "questions" | "answers" | "student";
+
+export interface TourStepConfig {
+
+ element: string;
+ title: string;
+ description: string;
+ side: Side;
+ align: Alignment;
+
+ route: TourRoute;
+
+ openDialog?: boolean;
+}
+
+export const GRADER_TOUR_STEPS: TourStepConfig[] = [
+ {
+ route: "assignments",
+ element: '[data-tour="grader-title"]',
+ title: "Welcome to the Grader",
+ description:
+ "This quick tour walks through every feature. We've loaded a demo course so you can see each view — even if your real course is empty.",
+ side: "bottom",
+ align: "start"
+ },
+ {
+ route: "assignments",
+ element: '[data-tour="grader-shortcuts-btn"]',
+ title: "Keyboard shortcuts",
+ description:
+ "Power-users: open the cheatsheet at any time, or just hit ? on the keyboard. J / K (or ↑ / ↓) walk through students and roll over to the neighbouring question when you hit the end of the list.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "assignments",
+ element: '[data-tour="grader-take-tour-btn"]',
+ title: "Tour button",
+ description:
+ "You can re-open this tour at any time with the 'Take tour' button in the top-right corner.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "assignments",
+ element: '[data-tour="grader-breadcrumb"]',
+ title: "Breadcrumbs",
+ description:
+ "The breadcrumb always reflects where you are. Inside a question it also shows a graded/total chip so you can see your progress at a glance.",
+ side: "bottom",
+ align: "start"
+ },
+ {
+ route: "assignments",
+ element: '[data-tour="grader-assignment-picker"]',
+ title: "Pick an assignment",
+ description:
+ "Every assignment in the course is listed as a card with its due date and total points. Click a card to drill in.",
+ side: "top",
+ align: "start"
+ },
+ {
+ route: "assignments",
+ element: '[data-tour="grader-assignment-card"]',
+ title: "Assignment card",
+ description:
+ "Each card shows the name, a short description, due date and total points. Clicking opens that assignment's question list.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-question-grid"]',
+ title: "Questions overview",
+ description:
+ "A grid of every gradable question in the assignment. The demo course includes one of every supported type so you can see the colour coding.",
+ side: "bottom",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-question-card"]',
+ title: "Question card",
+ description:
+ "Each card shows the question name, a colour-coded type tag, and four aggregated metrics.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-q-answered"]',
+ title: "Answered — number of students",
+ description:
+ "How many students submitted at least one attempt. For ActiveCode this also includes students who only ran the code, so the card matches what you'll see on the per-question screen.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-q-correct"]',
+ title: "Fully correct / fully scored — depends on type",
+ description:
+ "Students whose LAST attempt is a complete solution. The label changes with the question type:\n• Auto-graded (mchoice, activecode, codelens, webwork, splice, …): `correct = TRUE` (or `percent ≥ 1`) on the latest answer → shown as 'fully correct'.\n• Manually graded (shortanswer, parsons with `autograde=manual`, …): the instructor's score in `question_grades` equals the question's max points → shown as 'fully scored'.\n• Partial-credit types (dragndrop, clickablearea, matching, fillintheblank, parsons): we instead show 'avg. credit' — see the next step.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-q-average"]',
+ title: "Average score — based on the gradebook",
+ description:
+ "Mean of `question_grades.score` across students who already have a graded row.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-q-percent"]',
+ title: "% correct or % credit — depends on type",
+ description:
+ "For binary types this is `correct / answered` — the share of students whose last attempt is fully correct.\nFor partial-credit types (dragndrop, clickablearea, matching, fillintheblank, parsons) we switch to '% credit', which is the average `percent` (0–100%) over the LATEST attempt of every student. This is honest about partially-correct answers — '50% credit' on Drag-and-drop means the class on average got half the items right, not zero.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "questions",
+ element: '[data-tour="grader-q-progress"]',
+ title: "Progress bar — average / max points",
+ description:
+ "Visual ratio of `average score / max points` for the question. Empty bar means nobody is graded yet; a full bar means the average submission already earned the maximum.",
+ side: "bottom",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-split-pane"]',
+ title: "Unified grading view",
+ description:
+ "Everything you need on one screen: student list on the left, the submission preview in the middle, the grade panel on the right.",
+ side: "top",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-student-sidebar"]',
+ title: "Student sidebar",
+ description:
+ "All students who attempted the question. Coloured icons show progress: green ✓ already graded, blue ⚡ auto-graded, grey ◯ pending. Filter, search, or hit H to hide already-graded students.",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-preview-pane"]',
+ title: "Answer preview",
+ description:
+ "The question is rendered exactly as the student saw it. Different question types get specialised renderers (MCQ, Parsons, ActiveCode, Fill-in-the-blank, etc.).",
+ side: "right",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-history"]',
+ title: "Attempt history",
+ description:
+ "Every submission is recorded chronologically. Drag the slider to replay an earlier attempt right inside the preview pane.",
+ side: "bottom",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-grade-panel"]',
+ title: "Grade panel",
+ description:
+ "Score and comment for the active student. Changes are auto-saved when you tab out of a field — watch the small status pill in the top-right.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-points-input"]',
+ title: "Award points",
+ description:
+ "Enter the score (clamped to the maximum). Press G anywhere to jump back to this field.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-comment-input"]',
+ title: "Leave feedback",
+ description:
+ "Add a short comment the student will see on their results page.",
+ side: "left",
+ align: "start"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-next"]',
+ title: "Manual prev / next",
+ description:
+ "Walk through students with the Prev / Next buttons (or J / K on the keyboard). When you reach the last student in a question, Next rolls over to the first ungraded student of the next question; Prev does the same backwards. Changes are auto-saved as you type — there is no save button.",
+ side: "left",
+ align: "end"
+ },
+ {
+ route: "student",
+ openDialog: true,
+ element: '[data-tour="grader-auto-advance"]',
+ title: "Auto-advance after save",
+ description:
+ "Flip this switch to keep your hands on the keyboard. Once a student's grade is auto-saved (and you stop editing for a moment), the Grader jumps to the next ungraded student automatically — perfect for long batches. A short Undo toast lets you roll back the move and the save if you change your mind.",
+ side: "left",
+ align: "end"
+ }
+];
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts
index c819da3b9..cc727037a 100644
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts
@@ -56,6 +56,11 @@ export const buildNavBar = (eBookConfig: EBookConfig, navigate?: (path: string)
// icon: "pi pi-gauge",
// command: () => navigateToPath("grader")
// },
+ {
+ label: "Grader",
+ icon: "pi pi-check-square",
+ command: () => navigateToPath("grader")
+ },
{
label: "Assignment Builder",
icon: "pi pi-file-edit",
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts
index 0abaf111d..6baf75426 100644
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts
@@ -9,6 +9,7 @@ import { datasetSlice } from "@store/dataset/dataset.logic";
import { datasetApi } from "@store/dataset/dataset.logic.api";
import { exercisesSlice } from "@store/exercises/exercises.logic";
import { exercisesApi } from "@store/exercises/exercises.logic.api";
+import { graderApi } from "@store/grader/grader.logic.api";
import { readingsSlice } from "@store/readings/readings.logic";
import { readingsApi } from "@store/readings/readings.logic.api";
import { searchExercisesSlice } from "@store/searchExercises/searchExercises.logic";
@@ -48,7 +49,8 @@ const reducersMap = {
[readingsApi.reducerPath]: readingsApi.reducer,
[exercisesApi.reducerPath]: exercisesApi.reducer,
[datasetApi.reducerPath]: datasetApi.reducer,
- [datafileApi.reducerPath]: datafileApi.reducer
+ [datafileApi.reducerPath]: datafileApi.reducer,
+ [graderApi.reducerPath]: graderApi.reducer
};
export type RootState = StateType;
@@ -64,7 +66,8 @@ export const setupStore = (preloadedState?: Partial) => {
readingsApi.middleware,
exercisesApi.middleware,
datasetApi.middleware,
- datafileApi.middleware
+ datafileApi.middleware,
+ graderApi.middleware
);
}
});
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts
new file mode 100644
index 000000000..6aebddcb7
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/grader/grader.logic.api.ts
@@ -0,0 +1,196 @@
+import { createApi } from "@reduxjs/toolkit/query/react";
+
+import { baseQuery } from "@/store/baseQuery";
+import { DetailResponse } from "@/types/api";
+
+export interface GraderAssignment {
+ id: number;
+ name: string;
+ description?: string;
+ duedate?: string;
+ points?: number;
+}
+
+export interface GraderQuestionStats {
+ id: number;
+ name: string;
+ question_type: string;
+ htmlsrc?: string;
+ points: number;
+ autograde?: string;
+ which_to_grade?: string;
+
+ answered_count: number;
+
+ correct_count: number;
+
+ graded_count?: number;
+
+ average_score: number;
+
+ avg_percent?: number | null;
+}
+
+export interface GraderStudentAnswer {
+ sid: string;
+ first_name?: string;
+ last_name?: string;
+ email?: string;
+ answer: string;
+ correct?: boolean | null;
+ percent?: number | null;
+ timestamp?: string;
+ attempts: number;
+ score?: number | null;
+ comment?: string | null;
+ max_points: number;
+}
+
+export interface GraderAnswerHistoryItem {
+ id: number;
+
+ answer: string | Record | any[] | null;
+ correct?: boolean | null;
+ percent?: number | null;
+ timestamp?: string;
+ source?: string | null;
+}
+
+export interface GraderUseinfoItem {
+ id: number;
+ timestamp?: string;
+ event: string;
+ act: string;
+}
+
+export interface GraderQuestionsResponse {
+ assignment: GraderAssignment;
+ questions: GraderQuestionStats[];
+}
+
+export interface GraderAnswersResponse {
+ question: {
+ id: number;
+ name: string;
+ question_type: string;
+ htmlsrc?: string;
+ max_points: number;
+ };
+ answers: GraderStudentAnswer[];
+}
+
+export interface GraderHistoryResponse {
+ history: GraderAnswerHistoryItem[];
+ useinfo: GraderUseinfoItem[];
+}
+
+export interface GraderSavePayload {
+ sid: string;
+ div_id: string;
+ score: number;
+ comment?: string;
+
+ questionId: number;
+
+ assignmentId: number;
+}
+
+export const graderApi = createApi({
+ reducerPath: "graderApi",
+ baseQuery,
+ keepUnusedDataFor: 30,
+ tagTypes: ["GraderQuestions", "GraderAnswers"],
+ endpoints: (build) => ({
+ getGraderQuestions: build.query({
+ query: (assignmentId) => ({
+ method: "GET",
+ url: `/assignment/instructor/grader/assignments/${assignmentId}/questions`
+ }),
+ providesTags: (_res, _err, id) => [{ type: "GraderQuestions", id }],
+ transformResponse: (r: DetailResponse) => r.detail
+ }),
+ getGraderAnswers: build.query<
+ GraderAnswersResponse,
+ { assignmentId: number; questionId: number }
+ >({
+ query: ({ assignmentId, questionId }) => ({
+ method: "GET",
+ url: `/assignment/instructor/grader/questions/answers?assignment_id=${assignmentId}&question_id=${questionId}`
+ }),
+ providesTags: (_res, _err, { questionId }) => [
+ { type: "GraderAnswers", id: questionId }
+ ],
+ transformResponse: (r: DetailResponse) => r.detail
+ }),
+ getGraderHistory: build.query<
+ GraderHistoryResponse,
+ { assignmentId: number; questionId: number; sid: string }
+ >({
+ query: ({ assignmentId, questionId, sid }) => ({
+ method: "GET",
+ url: `/assignment/instructor/grader/questions/history?assignment_id=${assignmentId}&question_id=${questionId}&sid=${encodeURIComponent(
+ sid
+ )}`
+ }),
+ transformResponse: (r: DetailResponse) => r.detail
+ }),
+ saveGrade: build.mutation({
+ query: ({ questionId: _questionId, assignmentId: _assignmentId, ...body }) => ({
+ method: "POST",
+ url: "/assignment/instructor/grader/grade",
+ body
+ }),
+
+ onQueryStarted: async (
+ { sid, score, comment, questionId, assignmentId },
+ { dispatch, queryFulfilled, getState }
+ ) => {
+ const state: any = getState();
+ const cacheEntries = Object.values(
+ state[graderApi.reducerPath]?.queries ?? {}
+ ) as Array<{ endpointName?: string; originalArgs?: any }>;
+ const matching = cacheEntries.filter(
+ (e) =>
+ e.endpointName === "getGraderAnswers" &&
+ e.originalArgs?.questionId === questionId
+ );
+ const patches = matching.map((entry) =>
+ dispatch(
+ graderApi.util.updateQueryData(
+ "getGraderAnswers",
+ entry.originalArgs as { assignmentId: number; questionId: number },
+ (draft) => {
+ const row = draft.answers.find((a) => a.sid === sid);
+ if (row) {
+ row.score = score;
+ if (comment !== undefined) row.comment = comment;
+ }
+ }
+ )
+ )
+ );
+ try {
+ await queryFulfilled;
+
+ if (assignmentId) {
+ dispatch(
+ graderApi.util.invalidateTags([
+ { type: "GraderQuestions", id: assignmentId }
+ ])
+ );
+ }
+ } catch {
+ patches.forEach((p) => p.undo());
+ }
+ }
+ })
+ })
+});
+
+export const {
+ useGetGraderQuestionsQuery,
+ useGetGraderAnswersQuery,
+ useGetGraderHistoryQuery,
+ useSaveGradeMutation
+} = graderApi;
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts
index 69157bc45..1e9b7ff42 100755
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts
@@ -9,6 +9,7 @@ import { datasetSlice } from "@store/dataset/dataset.logic";
import { datasetApi } from "@store/dataset/dataset.logic.api";
import { exercisesSlice } from "@store/exercises/exercises.logic";
import { exercisesApi } from "@store/exercises/exercises.logic.api";
+import { graderApi } from "@store/grader/grader.logic.api";
import { readingsSlice } from "@store/readings/readings.logic";
import { readingsApi } from "@store/readings/readings.logic.api";
import { searchExercisesSlice } from "@store/searchExercises/searchExercises.logic";
@@ -29,7 +30,8 @@ const reducersMap = {
[readingsApi.reducerPath]: readingsApi.reducer,
[exercisesApi.reducerPath]: exercisesApi.reducer,
[datasetApi.reducerPath]: datasetApi.reducer,
- [datafileApi.reducerPath]: datafileApi.reducer
+ [datafileApi.reducerPath]: datafileApi.reducer,
+ [graderApi.reducerPath]: graderApi.reducer
};
export const rootReducer = combineReducers(reducersMap);
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts
index da0800904..51b696f4d 100755
--- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts
+++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts
@@ -4,6 +4,7 @@ import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExerc
import { datafileApi } from "@store/datafile/datafile.logic.api";
import { datasetApi } from "@store/dataset/dataset.logic.api";
import { exercisesApi } from "@store/exercises/exercises.logic.api";
+import { graderApi } from "@store/grader/grader.logic.api";
import { readingsApi } from "@store/readings/readings.logic.api";
import { rootReducer, RootState } from "@store/rootReducer";
@@ -18,7 +19,8 @@ export const setupStore = (preloadedState?: Partial) => {
readingsApi.middleware,
exercisesApi.middleware,
datasetApi.middleware,
- datafileApi.middleware
+ datafileApi.middleware,
+ graderApi.middleware
);
}
});
diff --git a/bases/rsptx/assignment_server_api/core.py b/bases/rsptx/assignment_server_api/core.py
index ad3cb6186..a985e612f 100644
--- a/bases/rsptx/assignment_server_api/core.py
+++ b/bases/rsptx/assignment_server_api/core.py
@@ -22,6 +22,7 @@
from .routers import student
from .routers import instructor
from .routers import peer
+from .routers import grader
from rsptx.exceptions.core import add_exception_handlers
from rsptx.logging import rslogger
from rsptx.templates import template_folder
@@ -57,6 +58,7 @@
auth_manager.attach_middleware(app)
app.include_router(student.router)
+app.include_router(grader.router)
app.include_router(instructor.router)
app.include_router(peer.router)
diff --git a/bases/rsptx/assignment_server_api/routers/assignment_summary.py b/bases/rsptx/assignment_server_api/routers/assignment_summary.py
index f030c0c45..e4747efba 100644
--- a/bases/rsptx/assignment_server_api/routers/assignment_summary.py
+++ b/bases/rsptx/assignment_server_api/routers/assignment_summary.py
@@ -143,7 +143,7 @@ def create_assignment_summary(assignment_id, course, dburl):
merged.iloc[0, 3:] = merged.iloc[0, 3:].apply(
lambda x: (
"{:.2f}".format(float(x.split("(")[0]))
- if type(x) is str and "(" in x
+ if isinstance(x, str) and "(" in x
else "{:.2f}".format(float(x))
)
)
diff --git a/bases/rsptx/assignment_server_api/routers/grader.py b/bases/rsptx/assignment_server_api/routers/grader.py
new file mode 100644
index 000000000..350c9b9eb
--- /dev/null
+++ b/bases/rsptx/assignment_server_api/routers/grader.py
@@ -0,0 +1,622 @@
+from typing import Any, List, Optional
+
+from fastapi import APIRouter, Depends, Query, Request, status
+from pydantic import BaseModel
+from sqlalchemy import select, func, and_, or_
+
+from rsptx.auth.session import auth_manager
+from rsptx.db.async_session import async_session
+from rsptx.db.crud import (
+ fetch_assignment_questions,
+ fetch_course,
+ fetch_course_instructors,
+ fetch_one_assignment,
+ fetch_question_grade,
+ fetch_users_for_course,
+ create_question_grade_entry,
+ update_question_grade_entry,
+)
+from rsptx.db.models import (
+ Code,
+ QuestionGrade,
+ Useinfo,
+ runestone_component_dict,
+)
+from rsptx.endpoint_validators import instructor_role_required
+from rsptx.logging import rslogger
+from rsptx.response_helpers.core import make_json_response
+
+
+router = APIRouter(
+ prefix="/instructor/grader",
+ tags=["grader"],
+)
+
+
+QTYPE_TO_TABLE = {
+ "mchoice": "mchoice_answers",
+ "fillintheblank": "fitb_answers",
+ "parsonsprob": "parsons_answers",
+ "activecode": "unittest_answers",
+ "actex": "unittest_answers",
+ "shortanswer": "shortanswer_answers",
+ "clickablearea": "clickablearea_answers",
+ "dragndrop": "dragndrop_answers",
+ "codelens": "codelens_answers",
+ "matching": "matching_answers",
+ "webwork": "webwork_answers",
+ "hparsons": "microparsons_answers",
+ "microparsons": "microparsons_answers",
+ "splice": "splice_answers",
+}
+
+
+CODE_TABLE_TYPES = {"activecode", "actex", "codelens"}
+
+
+def _answer_table_for(question_type: str):
+ table_name = QTYPE_TO_TABLE.get(question_type)
+ if not table_name:
+ return None
+ rcd = runestone_component_dict.get(table_name)
+ if not rcd:
+ return None
+ return rcd.model
+
+
+class GraderQuestionStats(BaseModel):
+ id: int
+ name: str
+ question_type: str
+ htmlsrc: Optional[str] = None
+ points: int
+ autograde: Optional[str] = None
+ which_to_grade: Optional[str] = None
+
+ answered_count: int
+
+ correct_count: int
+
+ graded_count: int
+
+ average_score: float
+
+ avg_percent: Optional[float] = None
+
+
+class GraderStudentAnswer(BaseModel):
+ sid: str
+ first_name: Optional[str] = None
+ last_name: Optional[str] = None
+ email: Optional[str] = None
+ answer: Optional[str] = None
+ correct: Optional[bool] = None
+ percent: Optional[float] = None
+ timestamp: Optional[str] = None
+ attempts: int = 0
+ score: Optional[float] = None
+ comment: Optional[str] = None
+ max_points: int = 0
+
+
+class GraderAnswerHistoryItem(BaseModel):
+ id: int
+
+ answer: Optional[Any] = None
+ correct: Optional[bool] = None
+ percent: Optional[float] = None
+ timestamp: Optional[str] = None
+ source: Optional[str] = None
+
+
+class GradeUpdatePayload(BaseModel):
+ sid: str
+ div_id: str
+ score: float
+ comment: Optional[str] = ""
+
+
+@router.get("/assignments/{assignment_id}/questions")
+@instructor_role_required()
+async def list_assignment_questions(
+ assignment_id: int, request: Request, user=Depends(auth_manager)
+):
+ """Return the list of questions in the assignment along with aggregate
+ student answer statistics. Only non-instructor answers are counted.
+ """
+ course = await fetch_course(user.course_name)
+ assignment = await fetch_one_assignment(assignment_id)
+ if not assignment or assignment.course != course.id:
+ return make_json_response(
+ status=status.HTTP_404_NOT_FOUND, detail="Assignment not found"
+ )
+
+ instructor_ids = {
+ u.username for u in await fetch_course_instructors(course.course_name)
+ }
+
+ rows = await fetch_assignment_questions(assignment_id)
+
+ row_list = [r for r in rows]
+ questions: List[GraderQuestionStats] = []
+ instructor_list = list(instructor_ids) if instructor_ids else []
+ for row in row_list:
+ q = row.Question
+ aq = row.AssignmentQuestion
+ tbl = _answer_table_for(q.question_type)
+
+ answered_count = 0
+ correct_count = 0
+ graded_count = 0
+ average_score = 0.0
+ avg_percent: Optional[float] = None
+ max_points = aq.points or 0
+
+ try:
+ async with async_session() as session:
+
+ base_clauses = []
+ latest_ids_q = None
+ if tbl is not None:
+ base_clauses = [
+ tbl.div_id == q.name,
+ tbl.course_name == course.course_name,
+ ]
+ if instructor_list:
+ base_clauses.append(tbl.sid.notin_(instructor_list))
+ latest_ids_q = (
+ select(func.max(tbl.id))
+ .where(and_(*base_clauses))
+ .group_by(tbl.sid)
+ )
+
+ answered_sids: set = set()
+ if tbl is not None:
+ res = await session.execute(
+ select(func.distinct(tbl.sid)).where(and_(*base_clauses))
+ )
+ answered_sids.update(s for (s,) in res.all() if s)
+
+ if q.question_type in CODE_TABLE_TYPES:
+ code_clauses = [
+ Code.acid == q.name,
+ Code.course_id == course.id,
+ ]
+ if instructor_list:
+ code_clauses.append(Code.sid.notin_(instructor_list))
+ res = await session.execute(
+ select(func.distinct(Code.sid)).where(and_(*code_clauses))
+ )
+ answered_sids.update(s for (s,) in res.all() if s)
+
+ answered_count = len(answered_sids)
+
+ if (
+ tbl is not None
+ and hasattr(tbl, "correct")
+ and latest_ids_q is not None
+ ):
+ last_correct_clauses = [tbl.id.in_(latest_ids_q)]
+ if hasattr(tbl, "percent"):
+ last_correct_clauses.append(
+ or_(
+ tbl.correct == True, # noqa: E712
+ tbl.percent >= 1.0,
+ )
+ )
+ else:
+ last_correct_clauses.append(tbl.correct == True) # noqa: E712
+ res = await session.execute(
+ select(func.count(func.distinct(tbl.sid))).where(
+ and_(*last_correct_clauses)
+ )
+ )
+ correct_count = int(res.scalar() or 0)
+ else:
+
+ if max_points > 0:
+ qg_clauses = [
+ QuestionGrade.div_id == q.name,
+ QuestionGrade.course_name == course.course_name,
+ QuestionGrade.score != None, # noqa: E711
+ QuestionGrade.score >= float(max_points),
+ ]
+ if instructor_list:
+ qg_clauses.append(QuestionGrade.sid.notin_(instructor_list))
+ res = await session.execute(
+ select(func.count(func.distinct(QuestionGrade.sid))).where(
+ and_(*qg_clauses)
+ )
+ )
+ correct_count = int(res.scalar() or 0)
+
+ if (
+ tbl is not None
+ and hasattr(tbl, "percent")
+ and latest_ids_q is not None
+ ):
+ res = await session.execute(
+ select(func.avg(tbl.percent)).where(
+ and_(
+ tbl.id.in_(latest_ids_q),
+ tbl.percent != None, # noqa: E711
+ )
+ )
+ )
+ val = res.scalar()
+ if val is not None:
+ avg_percent = round(float(val), 4)
+
+ qg_base = [
+ QuestionGrade.div_id == q.name,
+ QuestionGrade.course_name == course.course_name,
+ QuestionGrade.score != None, # noqa: E711
+ ]
+ if instructor_list:
+ qg_base.append(QuestionGrade.sid.notin_(instructor_list))
+
+ res = await session.execute(
+ select(
+ func.avg(QuestionGrade.score),
+ func.count(func.distinct(QuestionGrade.sid)),
+ ).where(and_(*qg_base))
+ )
+ row_avg = res.first()
+ if row_avg is not None:
+ avg_val, gcount = row_avg
+ average_score = float(avg_val) if avg_val is not None else 0.0
+ graded_count = int(gcount or 0)
+ except Exception as e: # pragma: no cover - defensive
+ rslogger.error(f"Grader stats failed for question {q.name}: {e}")
+
+ questions.append(
+ GraderQuestionStats(
+ id=q.id,
+ name=q.name,
+ question_type=q.question_type,
+ htmlsrc=q.htmlsrc,
+ points=max_points,
+ autograde=aq.autograde,
+ which_to_grade=aq.which_to_grade,
+ answered_count=answered_count,
+ correct_count=correct_count,
+ graded_count=graded_count,
+ average_score=round(average_score, 2),
+ avg_percent=avg_percent,
+ )
+ )
+
+ return make_json_response(
+ status=status.HTTP_200_OK,
+ detail={
+ "assignment": {
+ "id": assignment.id,
+ "name": assignment.name,
+ "description": assignment.description,
+ "duedate": (
+ assignment.duedate.isoformat() if assignment.duedate else None
+ ),
+ "points": assignment.points,
+ },
+ "questions": [q.dict() for q in questions],
+ },
+ )
+
+
+@router.get("/questions/answers")
+@instructor_role_required()
+async def list_question_answers(
+ request: Request,
+ assignment_id: int = Query(...),
+ question_id: int = Query(...),
+ user=Depends(auth_manager),
+):
+ """Return the most-recent answer for every student (excluding instructors)
+ that submitted something for this question.
+ """
+ course = await fetch_course(user.course_name)
+ assignment = await fetch_one_assignment(assignment_id)
+ if not assignment or assignment.course != course.id:
+ return make_json_response(
+ status=status.HTTP_404_NOT_FOUND, detail="Assignment not found"
+ )
+
+ rows = await fetch_assignment_questions(assignment_id)
+ question = None
+ max_points = 0
+ for row in rows:
+ if row.Question.id == question_id:
+ question = row.Question
+ max_points = row.AssignmentQuestion.points or 0
+ break
+ if question is None:
+ return make_json_response(
+ status=status.HTTP_404_NOT_FOUND, detail="Question not in assignment"
+ )
+
+ tbl = _answer_table_for(question.question_type)
+ students = await fetch_users_for_course(course.course_name)
+ instructor_ids = {
+ u.username for u in await fetch_course_instructors(course.course_name)
+ }
+ student_map = {s.username: s for s in students if s.username not in instructor_ids}
+
+ answers: List[GraderStudentAnswer] = []
+ latest_by_sid: dict = {}
+ attempt_counts: dict = {}
+ answer_source: dict = {}
+
+ if tbl is not None:
+ async with async_session() as session:
+ q = (
+ select(tbl)
+ .where(
+ and_(
+ tbl.div_id == question.name,
+ tbl.course_name == course.course_name,
+ )
+ )
+ .order_by(tbl.sid, tbl.timestamp.desc())
+ )
+ res = await session.execute(q)
+ for a in res.scalars():
+ if a.sid not in student_map:
+ continue
+ attempt_counts[a.sid] = attempt_counts.get(a.sid, 0) + 1
+ if a.sid not in latest_by_sid:
+ latest_by_sid[a.sid] = a
+ answer_source[a.sid] = "answer_table"
+
+ if question.question_type in CODE_TABLE_TYPES:
+ async with async_session() as session:
+ q = (
+ select(Code)
+ .where(
+ and_(
+ Code.acid == question.name,
+ Code.course_id == course.id,
+ )
+ )
+ .order_by(Code.sid, Code.timestamp.desc())
+ )
+ res = await session.execute(q)
+ for c in res.scalars():
+ if c.sid not in student_map:
+ continue
+ attempt_counts[c.sid] = attempt_counts.get(c.sid, 0) + 1
+ if c.sid not in latest_by_sid:
+ latest_by_sid[c.sid] = c
+ answer_source[c.sid] = "code_table"
+
+ for sid, row in latest_by_sid.items():
+ stu = student_map.get(sid)
+ grade = await fetch_question_grade(sid, course.course_name, question.name)
+
+ if answer_source.get(sid) == "code_table":
+ answer_text = row.code or ""
+ correct_val = None
+ percent_val = None
+ else:
+ answer_text = str(getattr(row, "answer", "") or "")
+ correct_val = bool(row.correct) if hasattr(row, "correct") else None
+ percent_val = (
+ float(row.percent)
+ if hasattr(row, "percent") and row.percent is not None
+ else None
+ )
+
+ answers.append(
+ GraderStudentAnswer(
+ sid=sid,
+ first_name=getattr(stu, "first_name", None),
+ last_name=getattr(stu, "last_name", None),
+ email=getattr(stu, "email", None),
+ answer=answer_text,
+ correct=correct_val,
+ percent=percent_val,
+ timestamp=row.timestamp.isoformat() if row.timestamp else None,
+ attempts=attempt_counts.get(sid, 1),
+ score=float(grade.score) if grade and grade.score is not None else None,
+ comment=grade.comment if grade else None,
+ max_points=max_points,
+ )
+ )
+
+ answers.sort(key=lambda x: ((x.last_name or ""), (x.first_name or ""), x.sid))
+
+ return make_json_response(
+ status=status.HTTP_200_OK,
+ detail={
+ "question": {
+ "id": question.id,
+ "name": question.name,
+ "question_type": question.question_type,
+ "htmlsrc": question.htmlsrc,
+ "max_points": max_points,
+ },
+ "answers": [a.dict() for a in answers],
+ },
+ )
+
+
+@router.get("/questions/history")
+@instructor_role_required()
+async def get_student_answer_history(
+ request: Request,
+ assignment_id: int = Query(...),
+ question_id: int = Query(...),
+ sid: str = Query(...),
+ user=Depends(auth_manager),
+):
+ """Return every attempt a student made on a particular question so the
+ instructor can detect brute-force behaviour.
+ """
+ course = await fetch_course(user.course_name)
+
+ rows = await fetch_assignment_questions(assignment_id)
+ question = None
+ for row in rows:
+ if row.Question.id == question_id:
+ question = row.Question
+ break
+ if question is None:
+ return make_json_response(
+ status=status.HTTP_404_NOT_FOUND, detail="Question not in assignment"
+ )
+
+ tbl = _answer_table_for(question.question_type)
+ history: List[GraderAnswerHistoryItem] = []
+
+ if tbl is not None:
+ async with async_session() as session:
+ q = (
+ select(tbl)
+ .where(
+ and_(
+ tbl.div_id == question.name,
+ tbl.course_name == course.course_name,
+ tbl.sid == sid,
+ )
+ )
+ .order_by(tbl.timestamp.asc())
+ )
+ res = await session.execute(q)
+ for a in res.scalars():
+ raw_answer = getattr(a, "answer", None)
+
+ if isinstance(raw_answer, (dict, list)):
+ answer_val: Any = raw_answer
+ else:
+ answer_val = "" if raw_answer is None else str(raw_answer)
+ history.append(
+ GraderAnswerHistoryItem(
+ id=a.id,
+ answer=answer_val,
+ correct=bool(a.correct) if hasattr(a, "correct") else None,
+ percent=(
+ float(a.percent)
+ if hasattr(a, "percent") and a.percent is not None
+ else None
+ ),
+ timestamp=a.timestamp.isoformat() if a.timestamp else None,
+ source=(
+ str(getattr(a, "source", "") or "")
+ if hasattr(a, "source")
+ else None
+ ),
+ )
+ )
+
+ if question.question_type in CODE_TABLE_TYPES:
+ async with async_session() as session:
+ q = (
+ select(Code)
+ .where(
+ and_(
+ Code.acid == question.name,
+ Code.course_id == course.id,
+ Code.sid == sid,
+ )
+ )
+ .order_by(Code.timestamp.asc())
+ )
+ res = await session.execute(q)
+ for c in res.scalars():
+ history.append(
+ GraderAnswerHistoryItem(
+ id=c.id,
+ answer=c.code or "",
+ correct=None,
+ percent=None,
+ timestamp=c.timestamp.isoformat() if c.timestamp else None,
+ source="code_table",
+ )
+ )
+
+ history.sort(key=lambda h: h.timestamp or "")
+
+ async with async_session() as session:
+ q = (
+ select(Useinfo)
+ .where(
+ and_(
+ Useinfo.div_id == question.name,
+ Useinfo.course_id == course.course_name,
+ Useinfo.sid == sid,
+ )
+ )
+ .order_by(Useinfo.timestamp.asc())
+ )
+ res = await session.execute(q)
+ useinfo_rows = [
+ {
+ "id": u.id,
+ "timestamp": u.timestamp.isoformat() if u.timestamp else None,
+ "event": u.event,
+ "act": u.act,
+ }
+ for u in res.scalars()
+ ]
+
+ return make_json_response(
+ status=status.HTTP_200_OK,
+ detail={
+ "history": [h.dict() for h in history],
+ "useinfo": useinfo_rows,
+ },
+ )
+
+
+@router.post("/grade")
+@instructor_role_required()
+async def upsert_grade(
+ payload: GradeUpdatePayload, request: Request, user=Depends(auth_manager)
+):
+ """Create or update a QuestionGrade row for a student on a given question.
+ The comment is stored alongside the score.
+ """
+ course = await fetch_course(user.course_name)
+ existing = await fetch_question_grade(
+ payload.sid, course.course_name, payload.div_id
+ )
+
+ if existing is None:
+ await create_question_grade_entry(
+ payload.sid, course.course_name, payload.div_id, int(payload.score)
+ )
+ else:
+ await update_question_grade_entry(
+ payload.sid,
+ course.course_name,
+ payload.div_id,
+ int(payload.score),
+ qge_id=existing.id,
+ )
+
+ async with async_session() as session:
+ q = select(QuestionGrade).where(
+ and_(
+ QuestionGrade.sid == payload.sid,
+ QuestionGrade.course_name == course.course_name,
+ QuestionGrade.div_id == payload.div_id,
+ )
+ )
+ res = await session.execute(q)
+ row = res.scalars().first()
+ if row is not None:
+ row.score = payload.score
+ row.comment = payload.comment or ""
+ await session.commit()
+
+ rslogger.info(
+ f"Grader saved grade sid={payload.sid} div={payload.div_id} score={payload.score}"
+ )
+ return make_json_response(
+ status=status.HTTP_200_OK,
+ detail={
+ "score": payload.score,
+ "comment": payload.comment or "",
+ "sid": payload.sid,
+ "div_id": payload.div_id,
+ },
+ )
diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py
index 82230c292..cf1c4d93f 100644
--- a/bases/rsptx/assignment_server_api/routers/instructor.py
+++ b/bases/rsptx/assignment_server_api/routers/instructor.py
@@ -970,6 +970,16 @@ async def get_grader(
return await get_builder(request, "/grader", user, response_class)
+@router.get("/grader/{subpath:path}")
+async def get_grader_subpath(
+ request: Request,
+ subpath: str,
+ user=Depends(auth_manager),
+ response_class=HTMLResponse,
+):
+ return await get_builder(request, "/grader", user, response_class)
+
+
@router.get("/except")
async def get_except(
request: Request, user=Depends(auth_manager), response_class=HTMLResponse
diff --git a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py
index d84a13ee2..000e47b42 100644
--- a/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py
+++ b/bases/rsptx/book_server_api/routers/personalized_parsons/generate_parsons_blocks.py
@@ -223,7 +223,7 @@ def generate_partial_Parsons(
+ distractor_tuple_dict[fixed_line_key][2].strip()
+ " #paired",
)
- elif type(distractor_tuple_dict[fixed_line_key]) is str:
+ elif isinstance(distractor_tuple_dict[fixed_line_key], str):
distractor_tuple_dict[fixed_line_key] = (
fixed_line_key[0] + 0.5,
fixed_line_key[0],
diff --git a/components/rsptx/configuration/core.py b/components/rsptx/configuration/core.py
index 3d481337b..72eb0bfdb 100644
--- a/components/rsptx/configuration/core.py
+++ b/components/rsptx/configuration/core.py
@@ -176,7 +176,7 @@ def read_key():
rslogger.warning(
"No Key file OR WEB2PY_PRIVATE_KEY - will default to settings.jwt_secret"
)
- if type(self.jwt_secret) is bytes:
+ if isinstance(self.jwt_secret, bytes):
return self.jwt_secret.decode("utf-8")
return self.jwt_secret
diff --git a/components/rsptx/lti1p3/caches.py b/components/rsptx/lti1p3/caches.py
index 3913ffb42..6ce274279 100644
--- a/components/rsptx/lti1p3/caches.py
+++ b/components/rsptx/lti1p3/caches.py
@@ -52,8 +52,8 @@ def get(self, key):
def set(self, key, value, expiration=600):
# pylti1p3 library tries to store dicts and bools
# make sure we don't give redis those
- if type(value) is bool:
+ if isinstance(value, bool):
value = str(value)
- elif type(value) is dict:
+ elif isinstance(value, dict):
value = json.dumps(value)
self.redis.set(key, value, ex=expiration)