diff --git a/src/App.tsx b/src/App.tsx index 832e1837..105263b1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import RootLayout from "./layout/Root"; import ManageUserTypes, { loader as loadUsers } from "./pages/Administrator/ManageUserTypes"; import Assignment from "./pages/Assignments/Assignment"; import AssignmentEditor from "./pages/Assignments/AssignmentEditor"; +import CalibrationReview from "./pages/Assignments/CalibrationReview"; import { loadAssignment } from "./pages/Assignments/AssignmentUtil"; import ResponseMappings from "./pages/ResponseMappings/ResponseMappings"; import CreateTeams from "./pages/Assignments/CreateTeams"; @@ -124,6 +125,10 @@ function App() { element: , loader: loadAssignment, }, + { + path: "assignments/edit/:assignmentId/calibration/:mapId", + element: , + }, { path: "assignments/new", diff --git a/src/pages/Assignments/AssignmentEditor.tsx b/src/pages/Assignments/AssignmentEditor.tsx index 1bd19dff..13059385 100644 --- a/src/pages/Assignments/AssignmentEditor.tsx +++ b/src/pages/Assignments/AssignmentEditor.tsx @@ -24,6 +24,8 @@ import ToolTip from "../../components/ToolTip"; import EtcTab from './tabs/EtcTab'; import TopicsTab from "./tabs/TopicsTab"; import DutyEditor from "pages/Duties/DutyEditor"; +// DEMO_INSTRUCTOR_RESPONSE: remove this import when the real review form ships. +import useCalibrationInstructorDemo from "./hooks/useCalibrationInstructorDemo"; interface TopicSettings { allowTopicSuggestions: boolean; @@ -111,8 +113,13 @@ const AssignmentEditor: React.FC = ({ mode }) => { const { data: assignmentResponse, error: assignmentError, sendRequest } = useAPI(); const { data: coursesResponse, error: coursesError, sendRequest: sendCoursesRequest } = useAPI(); const { data: calibrationSubmissionsResponse, error: calibrationSubmissionsError, sendRequest: sendCalibrationSubmissionsRequest } = useAPI(); + // Separate hook instances keep add/remove errors from overwriting list errors + // and let us trigger reloads only after a mutation succeeds. + const { data: addCalibrationResponse, error: addCalibrationError, sendRequest: sendAddCalibrationRequest } = useAPI(); + const { data: removeCalibrationResponse, error: removeCalibrationError, sendRequest: sendRemoveCalibrationRequest } = useAPI(); const [courses, setCourses] = useState([]); const [calibrationSubmissions, setCalibrationSubmissions] = useState([]); + const [calibrationUsername, setCalibrationUsername] = useState(""); const { data: topicsResponse, error: topicsApiError, sendRequest: fetchTopics } = useAPI(); const { data: updateResponse, error: updateError, sendRequest: updateAssignment } = useAPI(); @@ -536,32 +543,47 @@ const AssignmentEditor: React.FC = ({ mode }) => { coursesError && dispatch(alertActions.showAlert({ variant: "danger", message: coursesError })); }, [coursesError, dispatch]); - // Load calibration submissions on component mount + // Fetch the calibration submitters for this assignment from the backend. + // Wrapped in useCallback so both the initial load and post-mutation + // refreshes call the exact same request. + const refreshCalibrationParticipants = useCallback(() => { + if (!assignmentData?.id) return; + sendCalibrationSubmissionsRequest({ + url: `/assignments/${assignmentData.id}/review_mappings/calibration_participants`, + method: HttpMethod.GET, + }); + }, [assignmentData?.id, sendCalibrationSubmissionsRequest]); + + // Load calibration submitters on mount / when the assignment id resolves. useEffect(() => { - // sendCalibrationSubmissionsRequest({ - // url: `/calibration_submissions/get_instructor_calibration_submissions/${assignmentData.id}`, - // method: HttpMethod.GET, - // }); - setCalibrationSubmissions([ - { - id: 1, - participant_name: "Participant 1", - review_status: "not_started", - submitted_content: { hyperlinks: ["https://www.google.com"], files: ["file1.txt", "file2.pdf"] }, - }, - { - id: 2, - participant_name: "Participant 2", - review_status: "in_progress", - submitted_content: { hyperlinks: ["https://www.google.com"], files: ["file1.txt", "file2.pdf"] }, - }, - ]); - }, []); + refreshCalibrationParticipants(); + }, [refreshCalibrationParticipants]); - // Handle calibration submissions response + // Handle calibration submissions response. Backend returns + // { assignment_id, calibration_participants: [...] }, which we normalize + // into the shape the table expects. useEffect(() => { - if (calibrationSubmissionsResponse && calibrationSubmissionsResponse.status >= 200 && calibrationSubmissionsResponse.status < 300) { - setCalibrationSubmissions(calibrationSubmissionsResponse.data || []); + if ( + calibrationSubmissionsResponse && + calibrationSubmissionsResponse.status >= 200 && + calibrationSubmissionsResponse.status < 300 + ) { + const body: any = calibrationSubmissionsResponse.data || {}; + const rows: any[] = Array.isArray(body) + ? body + : (body.calibration_participants || []); + setCalibrationSubmissions( + rows.map((row: any) => { + const personName = row.full_name || row.username || row.handle || `Participant ${row.participant_id}`; + return { + participant_id: row.participant_id, + participant_name: row.team_name || `Team_${personName}`, + instructor_review_map_id: row.instructor_review_map_id, + instructor_review_status: row.instructor_review_status || 'not_started', + submitted_content: row.submissions || { hyperlinks: [], files: [] }, + }; + }) + ); } }, [calibrationSubmissionsResponse]); @@ -570,6 +592,68 @@ const AssignmentEditor: React.FC = ({ mode }) => { calibrationSubmissionsError && dispatch(alertActions.showAlert({ variant: "danger", message: calibrationSubmissionsError })); }, [calibrationSubmissionsError, dispatch]); + // Success/error handling for adding a calibration participant. Refresh the + // list after a successful add so the new row shows up with its submissions. + useEffect(() => { + if (addCalibrationResponse && addCalibrationResponse.status >= 200 && addCalibrationResponse.status < 300) { + dispatch(alertActions.showAlert({ variant: "success", message: "Calibration participant added" })); + setCalibrationUsername(""); + refreshCalibrationParticipants(); + } + }, [addCalibrationResponse, dispatch, refreshCalibrationParticipants]); + + useEffect(() => { + addCalibrationError && dispatch(alertActions.showAlert({ variant: "danger", message: addCalibrationError })); + }, [addCalibrationError, dispatch]); + + // Success/error handling for removing a calibration participant. + useEffect(() => { + if (removeCalibrationResponse && removeCalibrationResponse.status >= 200 && removeCalibrationResponse.status < 300) { + dispatch(alertActions.showAlert({ variant: "success", message: "Calibration participant removed" })); + refreshCalibrationParticipants(); + } + }, [removeCalibrationResponse, dispatch, refreshCalibrationParticipants]); + + useEffect(() => { + removeCalibrationError && dispatch(alertActions.showAlert({ variant: "danger", message: removeCalibrationError })); + }, [removeCalibrationError, dispatch]); + + const handleAddCalibrationParticipant = useCallback(() => { + const username = calibrationUsername.trim(); + if (!username || !assignmentData?.id) { + dispatch(alertActions.showAlert({ variant: "danger", message: "Enter a username to add as a calibration participant." })); + return; + } + sendAddCalibrationRequest({ + url: `/assignments/${assignmentData.id}/review_mappings/calibration_participants`, + method: HttpMethod.POST, + data: { username }, + }).catch(() => { + // useAPI already surfaces the error into addCalibrationError. + }); + }, [assignmentData?.id, calibrationUsername, dispatch, sendAddCalibrationRequest]); + + const handleRemoveCalibrationParticipant = useCallback( + (participantId: number | string) => { + if (!assignmentData?.id || !participantId) return; + sendRemoveCalibrationRequest({ + url: `/assignments/${assignmentData.id}/review_mappings/calibration_participants/${participantId}`, + method: HttpMethod.DELETE, + }).catch(() => { + // useAPI already surfaces the error into removeCalibrationError. + }); + }, + [assignmentData?.id, sendRemoveCalibrationRequest] + ); + + // DEMO_INSTRUCTOR_RESPONSE: remove this call (and the import above) when the + // real review form ships. See useCalibrationInstructorDemo for the full + // removal checklist. + const { handleBeginCalibrationReview } = useCalibrationInstructorDemo({ + assignmentId: assignmentData?.id, + onSuccess: refreshCalibrationParticipants, + }); + const onSubmit = ( values: IAssignmentFormValues, @@ -1166,62 +1250,169 @@ const AssignmentEditor: React.FC = ({ mode }) => { - {/* Calibration Tab */} + {/* Calibration Tab A team is created for the submitter and an + instructor calibration review map is opened automatically.*/} -

Submit reviews for calibration

+
+ +
+ setCalibrationUsername(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddCalibrationParticipant(); + } + }} + aria-label="Calibration participant username" + style={{ flex: 1, height: 38, lineHeight: '1.5', padding: '0.375rem 0.75rem', boxSizing: 'border-box' }} + /> + +
+
+ +

Select participants for submitting calibration artifacts

+
({ - id: calibrationSubmission.id, - participant_name: calibrationSubmission.participant_name, - review_status: calibrationSubmission.review_status, - submitted_content: calibrationSubmission.submitted_content, - })), - ]} + data={calibrationSubmissions.map((calibrationSubmission: any) => ({ + participant_id: calibrationSubmission.participant_id, + participant_name: calibrationSubmission.participant_name, + instructor_review_map_id: calibrationSubmission.instructor_review_map_id, + instructor_review_status: calibrationSubmission.instructor_review_status, + submitted_content: calibrationSubmission.submitted_content, + }))} columns={[ { accessorKey: "participant_name", header: "Participant name", enableSorting: false, enableColumnFilter: false }, { + // Review column. The real review form is out of scope of this + // project, so: + // - "Begin" posts to the demo endpoint that seeds a mock + // instructor calibration response, then the row refreshes + // and flips to "View | Edit". + // - "View | Edit" link to placeholder routes assumed to be + // provided by the existing review flow. cell: ({ row }) => { - if (row.original.review_status === "not_started") { - return Begin; - } else { - return
- View - | - Edit -
; + const mapId = row.original.instructor_review_map_id; + const status = row.original.instructor_review_status; + const reviewBase = mapId + ? `/assignments/edit/${assignmentData.id}/calibration/${mapId}` + : '#'; + const linkStyle: React.CSSProperties = { color: '#986633', textDecoration: 'none' }; + + if (status === 'not_started') { + // DEMO_INSTRUCTOR_RESPONSE: when the real review + // form ships, replace this button with a plain + // Begin + // and remove handleBeginCalibrationReview. + const beginStyle: React.CSSProperties = { + ...linkStyle, + background: 'none', + border: 'none', + padding: 0, + cursor: mapId ? 'pointer' : 'not-allowed', + font: 'inherit', + }; + return ( + + ); } + + return ( + + View + | + Edit + + ); }, - accessorKey: "action", header: "Action", enableSorting: false, enableColumnFilter: false + accessorKey: "review", header: "Review", enableSorting: false, enableColumnFilter: false }, { - cell: ({ row }) => <> -
Hyperlinks:
-
- { - row.original.submitted_content.hyperlinks.map((item: any, index: number) => { - return {item}; - }) - } -
-
Files:
-
- { - row.original.submitted_content.files.map((item: any, index: number) => { - return {item}; - }) - } -
- , + cell: ({ row }) => { + const mapId = row.original.instructor_review_map_id; + const href = mapId + ? `/assignments/edit/${assignmentData.id}/calibration/${mapId}` + : '#'; + return ( + + View review report + + ); + }, + accessorKey: "report", header: "Report", enableSorting: false, enableColumnFilter: false + }, + { + cell: ({ row }) => ( + <> +
Hyperlinks:
+
+ {(row.original.submitted_content?.hyperlinks || []).map((item: any, index: number) => ( + {item} + ))} +
+
Files:
+
+ {(row.original.submitted_content?.files || []).map((item: any, index: number) => { + // Files may arrive as bare strings (report shape) or as + // { id, name, path, submitted_at, submitted_by } objects + // (participant-list shape). Handle both. + const href = typeof item === 'string' ? item : item.path; + const label = typeof item === 'string' ? item : (item.name || item.path); + return ( + + {label} + + ); + })} +
+ + ), accessorKey: "submitted_content", header: "Submitted items(s)", enableSorting: false, enableColumnFilter: false }, + { + cell: ({ row }) => ( + + ), + accessorKey: "remove", header: "", enableSorting: false, enableColumnFilter: false + }, ]} /> diff --git a/src/pages/Assignments/CalibrationReview.tsx b/src/pages/Assignments/CalibrationReview.tsx new file mode 100644 index 00000000..dd0dc5f8 --- /dev/null +++ b/src/pages/Assignments/CalibrationReview.tsx @@ -0,0 +1,159 @@ +import { useEffect, useState } from "react"; +import { Alert, Card, Container, Spinner, Tab, Tabs } from "react-bootstrap"; +import { Link, useParams } from "react-router-dom"; +import axiosClient from "../../utils/axios_client"; +import { + normalizeCalibrationReport, + type CalibrationReportResponse, +} from "./calibrationReportNormalize"; +import CalibrationStackedChart from "./components/CalibrationStackedChart"; +import CalibrationRubricDetailPanel from "./components/CalibrationRubricDetailPanel"; + +const CalibrationReview = () => { + const { assignmentId, mapId } = useParams(); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedReviewerId, setSelectedReviewerId] = useState(null); + const [activeTab, setActiveTab] = useState("comparison"); + + useEffect(() => { + const loadReport = async () => { + if (!assignmentId || !mapId) { + setError("Missing assignment or calibration map id."); + setLoading(false); + return; + } + + try { + setLoading(true); + setError(null); + const response = await axiosClient.get( + `/assignments/${assignmentId}/reports/calibration/${mapId}` + ); + setReport(response.data); + } catch (err: any) { + setError(err?.response?.data?.error || "Unable to load calibration report"); + } finally { + setLoading(false); + } + }; + + loadReport(); + }, [assignmentId, mapId]); + + const normalizedReport = report ? normalizeCalibrationReport(report) : null; + useEffect(() => { + setSelectedReviewerId(normalizedReport?.defaultReviewerId ?? null); + }, [normalizedReport?.defaultReviewerId]); + + const studentResponseCount = normalizedReport?.latestStudentResponses.length ?? 0; + const hyperlinks = report?.submitted_content?.hyperlinks ?? []; + const files = report?.submitted_content?.files ?? []; + const hasSubmittedContent = hyperlinks.length > 0 || files.length > 0; + const backHref = assignmentId ? `/assignments/edit/${assignmentId}` : "/assignments"; + const rubricDetailRows = + normalizedReport && selectedReviewerId + ? normalizedReport.rubricDetailRowsByReviewer[selectedReviewerId] ?? normalizedReport.rubricDetailRows + : normalizedReport?.rubricDetailRows ?? []; + + return ( + +
+
+

Calibration Report

+

+ Assignment {assignmentId} · Calibration map {mapId} + {report && ( + <> + {" "} + · {studentResponseCount} student response{studentResponseCount === 1 ? "" : "s"} + + )} +

+
+ + ← Back to assignment + +
+ + {loading && ( +
+ + Loading calibration report... +
+ )} + + {!loading && error && ( + + Unable to load calibration report +
{error}
+
+ )} + + {!loading && !error && report && normalizedReport && ( + <> + setActiveTab(key ?? "comparison")} + > + +
+ +
+
+ +
+ + + {hasSubmittedContent && ( + + + Submitted content + {hyperlinks.length > 0 && ( + <> +
Hyperlinks
+
    + {hyperlinks.map((link, idx) => ( +
  • + + {link} + +
  • + ))} +
+ + )} + {files.length > 0 && ( + <> +
Files
+
    + {files.map((file, idx) => ( +
  • {file}
  • + ))} +
+ + )} +
+
+ )} +
+
+
+ + )} +
+ ); +}; + +export default CalibrationReview; diff --git a/src/pages/Assignments/__tests__/CalibrationReview.test.tsx b/src/pages/Assignments/__tests__/CalibrationReview.test.tsx new file mode 100644 index 00000000..4fa64d1a --- /dev/null +++ b/src/pages/Assignments/__tests__/CalibrationReview.test.tsx @@ -0,0 +1,92 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { vi } from "vitest"; +import CalibrationReview from "../CalibrationReview"; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); +vi.mock("../../../utils/axios_client", () => ({ + default: { get: mockGet }, +})); + +const mockReport = { + map_id: 8, + assignment_id: 1, + reviewee_id: 5, + rubric_items: [{ id: 11, txt: "Code quality", seq: 1, min_score: 0, max_score: 5 }], + instructor_response: { + id: 21, + map_id: 8, + reviewer_id: 15, + reviewer_name: "instructor", + is_submitted: true, + updated_at: "2026-04-23T16:00:00Z", + answers: [{ item_id: 11, score: 4, comments: "Good code" }], + }, + student_responses: [ + { + id: 31, + map_id: 9, + reviewer_id: 22, + reviewer_name: "Student A", + is_submitted: true, + updated_at: "2026-04-23T16:00:00Z", + answers: [{ item_id: 11, score: 3, comments: "Mostly good" }], + }, + ], + per_item_summary: [ + { + item_id: 11, + item_label: "Code quality", + item_seq: 1, + instructor_score: 4, + instructor_comment: "Good code", + bucket_counts: { "0": 0, "1": 0, "2": 0, "3": 1, "4": 0, "5": 0 }, + student_response_count: 1, + }, + ], + submitted_content: { hyperlinks: [], files: [] }, +}; + +const renderPage = () => + render( + + + } + /> + + + ); + +describe("CalibrationReview page", () => { + afterEach(() => vi.clearAllMocks()); + + test("shows loading spinner then renders report heading and student count on success", async () => { + mockGet.mockResolvedValueOnce({ data: mockReport } as any); + + renderPage(); + + expect(screen.getByText(/loading calibration report/i)).toBeInTheDocument(); + + await waitFor(() => + expect(screen.getByText(/calibration report/i)).toBeInTheDocument() + ); + expect(screen.getByText(/1 student response/i)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /class comparison/i })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /rubric detail/i })).toBeInTheDocument(); + }); + + test("shows error alert when the API call fails", async () => { + mockGet.mockRejectedValueOnce({ + response: { data: { error: "Not authorized" } }, + }); + + renderPage(); + + await waitFor(() => + expect(screen.getByText(/unable to load calibration report/i)).toBeInTheDocument() + ); + expect(screen.getByText("Not authorized")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Assignments/__tests__/CalibrationRubricDetailPanel.test.tsx b/src/pages/Assignments/__tests__/CalibrationRubricDetailPanel.test.tsx new file mode 100644 index 00000000..c17ba6e0 --- /dev/null +++ b/src/pages/Assignments/__tests__/CalibrationRubricDetailPanel.test.tsx @@ -0,0 +1,87 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { useState } from "react"; +import CalibrationRubricDetailPanel from "../components/CalibrationRubricDetailPanel"; +import type { ReviewerOption, RubricDetailRow } from "../calibrationReportNormalize"; + +describe("CalibrationRubricDetailPanel", () => { + const reviewerOptions: ReviewerOption[] = [ + { value: 21, label: "Reviewer Alpha", mapId: 9, responseId: 101 }, + { value: 22, label: "Reviewer Beta", mapId: 10, responseId: 102 }, + ]; + + const rowsByReviewer: Record = { + 21: [ + { + itemId: 1, + itemLabel: "Code quality", + itemSeq: 1, + instructorScore: 4, + instructorComment: "Clear implementation", + studentScore: 3, + studentComment: "Mostly clear", + agreeCount: 2, + nearCount: 3, + disagreeCount: 1, + noScoreCount: 0, + totalResponses: 6, + averageScore: 3.2, + }, + ], + 22: [ + { + itemId: 1, + itemLabel: "Code quality", + itemSeq: 1, + instructorScore: 4, + instructorComment: "", + studentScore: null, + studentComment: "", + agreeCount: 1, + nearCount: 1, + disagreeCount: 2, + noScoreCount: 2, + totalResponses: 6, + averageScore: 2.5, + }, + ], + }; + + const PanelHarness = () => { + const [selectedReviewerId, setSelectedReviewerId] = useState(21); + + return ( + + ); + }; + + test("renders the default reviewer comparison rows", () => { + render(); + + expect(screen.getByText(/rubric detail/i)).toBeInTheDocument(); + expect(screen.getByTestId("selected-reviewer-summary")).toHaveTextContent("Reviewer Alpha"); + expect(screen.getByText("1. Code quality")).toBeInTheDocument(); + expect(screen.getByText("Clear implementation")).toBeInTheDocument(); + expect(screen.getByText("Mostly clear")).toBeInTheDocument(); + expect(screen.getByText("1 below")).toBeInTheDocument(); + expect(screen.getByText("Green Agree: 2")).toBeInTheDocument(); + expect(screen.getByText("Yellow Near: 3")).toBeInTheDocument(); + expect(screen.getByText("Red Disagree: 1")).toBeInTheDocument(); + }); + + test("switches reviewers and updates the comparison rows", () => { + render(); + + fireEvent.change(screen.getByLabelText(/student reviewer/i), { + target: { value: "22" }, + }); + + expect(screen.getByTestId("selected-reviewer-summary")).toHaveTextContent("Reviewer Beta"); + expect(screen.getAllByText("No comments")).toHaveLength(2); + expect(screen.getAllByText("N/A")).toHaveLength(2); + }); +}); diff --git a/src/pages/Assignments/__tests__/CalibrationStackedChart.test.tsx b/src/pages/Assignments/__tests__/CalibrationStackedChart.test.tsx new file mode 100644 index 00000000..24e4763f --- /dev/null +++ b/src/pages/Assignments/__tests__/CalibrationStackedChart.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from "@testing-library/react"; +import CalibrationStackedChart from "../components/CalibrationStackedChart"; +import type { StackedChartDataRow } from "../calibrationReportNormalize"; + +describe("CalibrationStackedChart", () => { + const chartData: StackedChartDataRow[] = [ + { + itemId: 11, + itemLabel: "Code quality", + itemSeq: 1, + instructorScore: 4, + agreeCount: 0, + nearCount: 1, + disagreeCount: 0, + totalResponses: 1, + }, + { + itemId: 12, + itemLabel: "Documentation", + itemSeq: 2, + instructorScore: 5, + agreeCount: 0, + nearCount: 1, + disagreeCount: 0, + totalResponses: 1, + }, + ]; + + test("renders chart title and instructor reference table", () => { + render( + + ); + + expect(screen.getByText(/class comparison \(stacked\)/i)).toBeInTheDocument(); + expect(screen.getByTestId("calibration-stacked-chart")).toBeInTheDocument(); + expect(screen.getByText(/agree \(matches instructor score\)/i)).toBeInTheDocument(); + expect(screen.getByText(/near \(±1\)/i)).toBeInTheDocument(); + expect(screen.getByText(/disagree/i)).toBeInTheDocument(); + expect(screen.getByText(/instructor reference scores/i)).toBeInTheDocument(); + expect(screen.getByText("Code quality")).toBeInTheDocument(); + expect(screen.getByText("Documentation")).toBeInTheDocument(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("5")).toBeInTheDocument(); + }); +}); diff --git a/src/pages/Assignments/__tests__/calibrationReportNormalize.test.ts b/src/pages/Assignments/__tests__/calibrationReportNormalize.test.ts new file mode 100644 index 00000000..3dc6445a --- /dev/null +++ b/src/pages/Assignments/__tests__/calibrationReportNormalize.test.ts @@ -0,0 +1,156 @@ +import { + normalizeCalibrationReport, + type CalibrationReportResponse, +} from "../calibrationReportNormalize"; + +describe("normalizeCalibrationReport", () => { + const report: CalibrationReportResponse = { + map_id: 8, + assignment_id: 8, + reviewee_id: 8, + rubric_items: [ + { id: 11, txt: "Code quality", seq: 1, min_score: 0, max_score: 5 }, + { id: 12, txt: "Documentation", seq: 2, min_score: 0, max_score: 5 }, + ], + instructor_response: { + id: 21, + map_id: 8, + reviewer_id: 15, + reviewer_name: "admin", + is_submitted: true, + updated_at: "2026-04-23T16:00:00Z", + answers: [ + { item_id: 11, score: 4, comments: "Instructor code" }, + { item_id: 12, score: 5, comments: "Instructor docs" }, + ], + }, + student_responses: [ + { + id: 31, + map_id: 9, + reviewer_id: 22, + reviewer_name: "Student A", + is_submitted: true, + updated_at: "2026-04-22T16:00:00Z", + answers: [ + { item_id: 11, score: 2, comments: "Older code" }, + { item_id: 12, score: 3, comments: "Older docs" }, + ], + }, + { + id: 32, + map_id: 9, + reviewer_id: 22, + reviewer_name: "Student A", + is_submitted: true, + updated_at: "2026-04-23T16:00:00Z", + answers: [ + { item_id: 11, score: 3, comments: "Latest code" }, + { item_id: 12, score: null, comments: "" }, + ], + }, + ], + per_item_summary: [ + { + item_id: 11, + item_label: "Code quality", + item_seq: 1, + instructor_score: 4, + instructor_comment: "Instructor code", + bucket_counts: { "0": 0, "1": 0, "2": 0, "3": 1, "4": 0, "5": 0 }, + student_response_count: 1, + }, + { + item_id: 12, + item_label: "Documentation", + item_seq: 2, + instructor_score: 5, + instructor_comment: "Instructor docs", + bucket_counts: { "0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 }, + student_response_count: 1, + }, + ], + submitted_content: { + hyperlinks: ["https://example.com"], + files: ["submission.pdf"], + }, + }; + + test("builds agreement buckets from per-item summaries relative to instructor score", () => { + const normalized = normalizeCalibrationReport(report); + + expect(normalized.bucketKeys).toEqual(["agreeCount", "nearCount", "disagreeCount"]); + expect(normalized.stackedChartData).toEqual([ + expect.objectContaining({ + itemId: 11, + itemLabel: "Code quality", + instructorScore: 4, + agreeCount: 0, + nearCount: 1, + disagreeCount: 0, + }), + expect.objectContaining({ + itemId: 12, + itemLabel: "Documentation", + instructorScore: 5, + agreeCount: 0, + nearCount: 0, + disagreeCount: 0, + totalResponses: 0, + }), + ]); + }); + + test("returns empty chart data when there are no student responses", () => { + const emptyReport: CalibrationReportResponse = { + ...report, + student_responses: [], + per_item_summary: report.per_item_summary.map((s) => ({ + ...s, + bucket_counts: { "0": 0, "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 }, + student_response_count: 0, + })), + }; + const normalized = normalizeCalibrationReport(emptyReport); + + expect(normalized.reviewerOptions).toHaveLength(0); + expect(normalized.defaultReviewerId).toBeNull(); + normalized.stackedChartData.forEach((row) => { + expect(row.agreeCount).toBe(0); + expect(row.nearCount).toBe(0); + expect(row.disagreeCount).toBe(0); + }); + }); + + test("uses the latest submitted student response for rubric detail data", () => { + const normalized = normalizeCalibrationReport(report); + + expect(normalized.reviewerOptions).toEqual([ + { + value: 22, + label: "Student A", + mapId: 9, + responseId: 32, + }, + ]); + expect(normalized.defaultReviewerId).toBe(22); + expect(normalized.rubricDetailRows).toEqual([ + expect.objectContaining({ + itemId: 11, + instructorScore: 4, + studentScore: 3, + studentComment: "Latest code", + noScoreCount: 0, + averageScore: 3, + }), + expect.objectContaining({ + itemId: 12, + instructorScore: 5, + studentScore: null, + studentComment: "", + noScoreCount: 1, + averageScore: null, + }), + ]); + }); +}); diff --git a/src/pages/Assignments/calibrationReportNormalize.ts b/src/pages/Assignments/calibrationReportNormalize.ts new file mode 100644 index 00000000..bbcf95c6 --- /dev/null +++ b/src/pages/Assignments/calibrationReportNormalize.ts @@ -0,0 +1,249 @@ +export interface CalibrationAnswer { + item_id: number; + score: number | null; + comments?: string | null; +} + +export interface CalibrationRubricItem { + id: number; + txt: string; + seq: number; + question_type?: string; + weight?: number; + min_score?: number; + max_score?: number; +} + +export interface CalibrationResponse { + id: number; + map_id: number; + reviewer_id: number; + reviewer_name?: string | null; + is_submitted: boolean; + updated_at: string; + answers: CalibrationAnswer[]; +} + +export interface CalibrationPerItemSummary { + item_id: number; + item_label: string; + item_seq: number; + instructor_score: number | null; + instructor_comment?: string | null; + bucket_counts: Record; + student_response_count: number; +} + +export interface CalibrationReportResponse { + map_id: number; + assignment_id: number; + reviewee_id: number; + rubric_items: CalibrationRubricItem[]; + instructor_response: CalibrationResponse; + student_responses: CalibrationResponse[]; + per_item_summary: CalibrationPerItemSummary[]; + submitted_content?: { + hyperlinks?: string[]; + files?: string[]; + }; +} + +export interface StackedChartDataRow { + itemId: number; + itemLabel: string; + itemSeq: number; + instructorScore: number | null; + agreeCount: number; + nearCount: number; + disagreeCount: number; + totalResponses: number; +} + +export interface ReviewerOption { + value: number; + label: string; + mapId: number; + responseId: number; +} + +export interface RubricDetailRow { + itemId: number; + itemLabel: string; + itemSeq: number; + instructorScore: number | null; + instructorComment: string; + studentScore: number | null; + studentComment: string; + agreeCount: number; + nearCount: number; + disagreeCount: number; + noScoreCount: number; + totalResponses: number; + averageScore: number | null; +} + +export interface NormalizedCalibrationReport { + bucketKeys: string[]; + stackedChartData: StackedChartDataRow[]; + reviewerOptions: ReviewerOption[]; + rubricDetailRows: RubricDetailRow[]; + rubricDetailRowsByReviewer: Record; + latestStudentResponses: CalibrationResponse[]; + defaultReviewerId: number | null; +} + +const buildAnswersByItem = (answers: CalibrationAnswer[]) => + answers.reduce>((byItem, answer) => { + byItem[answer.item_id] = answer; + return byItem; + }, {}); + +const latestStudentResponsesForMaps = (responses: CalibrationResponse[]) => + Object.values( + responses.reduce>((latestByMap, response) => { + const current = latestByMap[response.map_id]; + + if (!current || new Date(response.updated_at).getTime() > new Date(current.updated_at).getTime()) { + latestByMap[response.map_id] = response; + } + + return latestByMap; + }, {}) + ).sort((left, right) => { + const leftName = left.reviewer_name ?? ""; + const rightName = right.reviewer_name ?? ""; + return leftName.localeCompare(rightName); + }); + +const scoreBucketKey = (score: string) => `score_${score}`; + +const agreementCountFor = ( + summary: CalibrationPerItemSummary, + classification: "agree" | "near" | "disagree" +) => { + if (summary.instructor_score === null) { + return 0; + } + + return Object.entries(summary.bucket_counts).reduce((count, [score, bucketCount]) => { + const distance = Math.abs(Number(score) - summary.instructor_score!); + + if (classification === "agree" && distance === 0) { + return count + bucketCount; + } + + if (classification === "near" && distance === 1) { + return count + bucketCount; + } + + if (classification === "disagree" && distance > 1) { + return count + bucketCount; + } + + return count; + }, 0); +}; + +const buildRubricRows = ( + rubricItems: CalibrationRubricItem[], + instructorResponse: CalibrationResponse, + perItemSummary: CalibrationPerItemSummary[], + studentResponse?: CalibrationResponse +) => { + const instructorAnswers = buildAnswersByItem(instructorResponse.answers); + const studentAnswers = studentResponse ? buildAnswersByItem(studentResponse.answers) : {}; + const summaryByItem = perItemSummary.reduce>((byItem, summary) => { + byItem[summary.item_id] = summary; + return byItem; + }, {}); + + return [...rubricItems] + .sort((left, right) => left.seq - right.seq) + .map((item) => { + const instructorAnswer = instructorAnswers[item.id]; + const studentAnswer = studentAnswers[item.id]; + const summary = summaryByItem[item.id]; + const totalScoredResponses = summary + ? Object.values(summary.bucket_counts).reduce((total, count) => total + count, 0) + : 0; + const totalResponses = summary?.student_response_count ?? 0; + const averageScore = summary && totalScoredResponses > 0 + ? Object.entries(summary.bucket_counts).reduce((total, [score, count]) => total + Number(score) * count, 0) / + totalScoredResponses + : null; + + return { + itemId: item.id, + itemLabel: item.txt, + itemSeq: item.seq, + instructorScore: instructorAnswer?.score ?? null, + instructorComment: instructorAnswer?.comments ?? "", + studentScore: studentAnswer?.score ?? null, + studentComment: studentAnswer?.comments ?? "", + agreeCount: summary ? agreementCountFor(summary, "agree") : 0, + nearCount: summary ? agreementCountFor(summary, "near") : 0, + disagreeCount: summary ? agreementCountFor(summary, "disagree") : 0, + noScoreCount: Math.max(0, totalResponses - totalScoredResponses), + totalResponses, + averageScore, + }; + }); +}; + +export const normalizeCalibrationReport = ( + report: CalibrationReportResponse +): NormalizedCalibrationReport => { + const bucketKeys = ["agreeCount", "nearCount", "disagreeCount"]; + + const stackedChartData = [...report.per_item_summary] + .sort((left, right) => left.item_seq - right.item_seq) + .map((summary): StackedChartDataRow => { + const totalResponses = Object.values(summary.bucket_counts).reduce((total, count) => total + count, 0); + + return { + itemId: summary.item_id, + itemLabel: summary.item_label, + itemSeq: summary.item_seq, + instructorScore: summary.instructor_score, + agreeCount: agreementCountFor(summary, "agree"), + nearCount: agreementCountFor(summary, "near"), + disagreeCount: agreementCountFor(summary, "disagree"), + totalResponses, + }; + }); + + const latestStudentResponses = latestStudentResponsesForMaps(report.student_responses); + const reviewerOptions = latestStudentResponses.map((response) => ({ + value: response.reviewer_id, + label: response.reviewer_name || `Reviewer ${response.reviewer_id}`, + mapId: response.map_id, + responseId: response.id, + })); + + const rubricDetailRowsByReviewer = latestStudentResponses.reduce>( + (rowsByReviewer, response) => { + rowsByReviewer[response.reviewer_id] = buildRubricRows( + report.rubric_items, + report.instructor_response, + report.per_item_summary, + response + ); + return rowsByReviewer; + }, + {} + ); + + const defaultReviewerId = reviewerOptions[0]?.value ?? null; + + return { + bucketKeys, + stackedChartData, + reviewerOptions, + rubricDetailRows: defaultReviewerId + ? rubricDetailRowsByReviewer[defaultReviewerId] + : buildRubricRows(report.rubric_items, report.instructor_response, report.per_item_summary), + rubricDetailRowsByReviewer, + latestStudentResponses, + defaultReviewerId, + }; +}; diff --git a/src/pages/Assignments/components/CalibrationRubricDetailPanel.tsx b/src/pages/Assignments/components/CalibrationRubricDetailPanel.tsx new file mode 100644 index 00000000..b600b928 --- /dev/null +++ b/src/pages/Assignments/components/CalibrationRubricDetailPanel.tsx @@ -0,0 +1,127 @@ +import { Card, Col, Form, Row, Table } from "react-bootstrap"; +import type { ReviewerOption, RubricDetailRow } from "../calibrationReportNormalize"; +import CalibrationRubricDistributionChart from "./CalibrationRubricDistributionChart"; + +interface CalibrationRubricDetailPanelProps { + reviewerOptions: ReviewerOption[]; + selectedReviewerId: number | null; + rows: RubricDetailRow[]; + onReviewerChange: (reviewerId: number) => void; +} + +const scoreLabel = (score: number | null) => (score === null ? "N/A" : score); + +const commentLabel = (comment: string) => comment.trim() || "No comments"; + +const differenceLabel = (instructorScore: number | null, studentScore: number | null) => { + if (instructorScore === null || studentScore === null) { + return "N/A"; + } + + const difference = studentScore - instructorScore; + + if (difference === 0) { + return "Matches instructor"; + } + + return `${Math.abs(difference)} ${difference > 0 ? "above" : "below"}`; +}; + +const CalibrationRubricDetailPanel = ({ + reviewerOptions, + selectedReviewerId, + rows, + onReviewerChange, +}: CalibrationRubricDetailPanelProps) => { + const selectedReviewer = reviewerOptions.find((option) => option.value === selectedReviewerId); + + return ( + + +
+
+ Rubric detail + + Compare one student calibration review directly against the instructor on each rubric item. + +
+ + + Student reviewer + onReviewerChange(Number(event.target.value))} + > + {reviewerOptions.map((option) => ( + + ))} + + +
+ + {selectedReviewer ? ( +

+ Comparing against {selectedReviewer.label}. +

+ ) : ( +

No student reviewer is available for comparison yet.

+ )} + +
+ {rows.map((row) => ( + + +
{row.itemSeq}. {row.itemLabel}
+ + +
+
+ + + + + + + + + + + + + + + + + + +
Instructor scoreStudent scoreDifferenceInstructor commentStudent comment
{scoreLabel(row.instructorScore)}{scoreLabel(row.studentScore)}{differenceLabel(row.instructorScore, row.studentScore)}{commentLabel(row.instructorComment)}{commentLabel(row.studentComment)}
+ + + +
+
+ Class summary for this rubric ({row.totalResponses} response{row.totalResponses === 1 ? "" : "s"}) +
+ +
+
Green Agree: {row.agreeCount}
+
Yellow Near: {row.nearCount}
+
Red Disagree: {row.disagreeCount}
+
+
+ + + + + ))} +
+ + + ); +}; + +export default CalibrationRubricDetailPanel; diff --git a/src/pages/Assignments/components/CalibrationRubricDistributionChart.tsx b/src/pages/Assignments/components/CalibrationRubricDistributionChart.tsx new file mode 100644 index 00000000..62b3547c --- /dev/null +++ b/src/pages/Assignments/components/CalibrationRubricDistributionChart.tsx @@ -0,0 +1,49 @@ +import { ResponsiveContainer, BarChart, Bar, CartesianGrid, Tooltip, XAxis, YAxis, Cell } from "recharts"; +import type { RubricDetailRow } from "../calibrationReportNormalize"; + +interface CalibrationRubricDistributionChartProps { + row: RubricDetailRow; +} + +const DISTRIBUTION_COLORS = { + agree: "#22c55e", + near: "#facc15", + disagree: "#ef4444", +}; + +const CalibrationRubricDistributionChart = ({ + row, +}: CalibrationRubricDistributionChartProps) => { + const data = [ + { category: "Agree", count: row.agreeCount, color: DISTRIBUTION_COLORS.agree }, + { category: "Near", count: row.nearCount, color: DISTRIBUTION_COLORS.near }, + { category: "Disagree", count: row.disagreeCount, color: DISTRIBUTION_COLORS.disagree }, + ]; + + return ( +
+
+ + + + + + [value, "Responses"]} /> + + {data.map((entry) => ( + + ))} + + + +
+
+ ); +}; + +export default CalibrationRubricDistributionChart; diff --git a/src/pages/Assignments/components/CalibrationStackedChart.tsx b/src/pages/Assignments/components/CalibrationStackedChart.tsx new file mode 100644 index 00000000..afb07240 --- /dev/null +++ b/src/pages/Assignments/components/CalibrationStackedChart.tsx @@ -0,0 +1,154 @@ +import { Card, Table } from "react-bootstrap"; +import { + Bar, + BarChart, + CartesianGrid, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { StackedChartDataRow } from "../calibrationReportNormalize"; + +interface CalibrationStackedChartProps { + bucketKeys: string[]; + chartData: StackedChartDataRow[]; +} + +const AGREEMENT_COLORS: Record = { + agreeCount: "#22c55e", + nearCount: "#facc15", + disagreeCount: "#ef4444", +}; + +const bucketLabel = (bucketKey: string) => { + if (bucketKey === "agreeCount") return "Agree (matches instructor score)"; + if (bucketKey === "nearCount") return "Near (±1)"; + return "Disagree"; +}; + +const CalibrationStackedChart = ({ + bucketKeys, + chartData, +}: CalibrationStackedChartProps) => { + const displayData = chartData.map((row) => ({ + ...row, + distributionSummary: `Agree: ${row.agreeCount}, Near: ${row.nearCount}, Disagree: ${row.disagreeCount}`, + })); + const chartHeight = Math.max(320, displayData.length * 80); + + return ( + + + Class comparison (stacked) + + Each rubric item is shown on the x-axis. The y-axis shows the number of student responses. Green means + the student score matches the instructor, yellow means the student is within one point, and red means + the student is farther away. + + +
+ + + + + + [ + value === null ? "N/A" : value, + bucketLabel(name), + ]} + labelFormatter={(_, payload) => { + const row = payload?.[0]?.payload as StackedChartDataRow | undefined; + if (!row) return ""; + + const displayRow = payload?.[0]?.payload as (StackedChartDataRow & { + distributionSummary?: string; + }) | undefined; + + return `${row.itemSeq}. ${row.itemLabel}${ + displayRow?.distributionSummary ? ` | ${displayRow.distributionSummary}` : "" + }`; + }} + /> + {bucketKeys.map((bucketKey) => ( + + ))} + + +
+ +
+ + Agree (matches instructor score) + + Near (±1) + + Disagree +
+ +

Instructor reference scores

+ + + + + + + + + + {chartData.map((row) => ( + + + + + + ))} + +
CriterionInstructor scoreStudent responses
{row.itemLabel}{row.instructorScore ?? "N/A"}{row.totalResponses}
+
+
+ ); +}; + +export default CalibrationStackedChart; diff --git a/src/pages/Assignments/hooks/useCalibrationInstructorDemo.ts b/src/pages/Assignments/hooks/useCalibrationInstructorDemo.ts new file mode 100644 index 00000000..6cad7dfe --- /dev/null +++ b/src/pages/Assignments/hooks/useCalibrationInstructorDemo.ts @@ -0,0 +1,94 @@ +// DEMO_INSTRUCTOR_RESPONSE +// +// Demo-only hook: wires the "Begin" button on the Calibration tab to the +// backend's mock-seeding endpoint. When the instructor clicks "Begin", this +// hook POSTs to `…/:map_id/mock_instructor_response`, which materialises a +// submitted instructor calibration Response (and a small set of peer student +// responses) so the calibration report has data to display. +// +// Lives in its own file (and its own Demo-prefixed hook) so: +// * AssignmentEditor stays free of mock-data plumbing. +// * Deleting the demo is a single file delete + removing the import from +// AssignmentEditor and swapping the Begin +// with a plain anchor pointing at the real review form: +// Begin +// +// Backend (reimplementation-back-end-1): +// 4. Delete app/services/demo/calibration_instructor_seeder.rb. +// 5. Delete app/controllers/demo/calibration_instructor_responses_controller.rb. +// 6. Remove the `mock_instructor_response` route from config/routes.rb. + +import { useCallback, useEffect } from "react"; +import { useDispatch } from "react-redux"; +import { alertActions } from "../../../store/slices/alertSlice"; +import useAPI from "../../../hooks/useAPI"; +import { HttpMethod } from "../../../utils/httpMethods"; + +interface UseCalibrationInstructorDemoOptions { + assignmentId: number | string | null | undefined; + onSuccess: () => void; // called after a successful seed so the table refreshes +} + +export default function useCalibrationInstructorDemo({ + assignmentId, + onSuccess, +}: UseCalibrationInstructorDemoOptions) { + const dispatch = useDispatch(); + const { + data: beginCalibrationResponse, + error: beginCalibrationError, + sendRequest: sendBeginCalibrationRequest, + } = useAPI(); + + const handleBeginCalibrationReview = useCallback( + (mapId: number | string | null | undefined) => { + if (!assignmentId || !mapId) return; + sendBeginCalibrationRequest({ + url: `/assignments/${assignmentId}/review_mappings/${mapId}/mock_instructor_response`, + method: HttpMethod.POST, + }).catch(() => { + // useAPI surfaces the error into beginCalibrationError. + }); + }, + [assignmentId, sendBeginCalibrationRequest] + ); + + useEffect(() => { + if ( + beginCalibrationResponse && + beginCalibrationResponse.status >= 200 && + beginCalibrationResponse.status < 300 + ) { + dispatch( + alertActions.showAlert({ + variant: "success", + message: "Mock instructor and peer calibration responses submitted", + }) + ); + onSuccess(); + } + }, [beginCalibrationResponse, dispatch, onSuccess]); + + useEffect(() => { + beginCalibrationError && + dispatch( + alertActions.showAlert({ variant: "danger", message: beginCalibrationError }) + ); + }, [beginCalibrationError, dispatch]); + + return { handleBeginCalibrationReview }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 5ef84fe6..a9f0a81e 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -25,3 +25,19 @@ Object.defineProperty(window, 'alert', { writable: true, value: vi.fn(), }); + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +Object.defineProperty(window, 'ResizeObserver', { + writable: true, + value: ResizeObserverMock, +}); + +Object.defineProperty(globalThis, 'ResizeObserver', { + writable: true, + value: ResizeObserverMock, +});