205 lines
7.5 KiB
TypeScript
205 lines
7.5 KiB
TypeScript
// Path: Frontend/src/components/assignment/student/assignment-review.data.ts
|
|
|
|
import { apiFetchJson } from "../../../lib/api";
|
|
import type { ApiAssignment, ApiAssignmentStudentQuestionDetail, ApiClassroom, ApiListResponse, ApiUser } from "../../../lib/api-types";
|
|
import { getAssignmentWorkHref } from "../../../lib/routes";
|
|
import type { AssignmentPageData } from "../shared/assignment-types";
|
|
|
|
const formatDateLabel = (value: string | null) => {
|
|
if (!value) return "No due date";
|
|
|
|
return new Intl.DateTimeFormat("en-GB", {
|
|
weekday: "short",
|
|
month: "short",
|
|
day: "numeric",
|
|
}).format(new Date(value));
|
|
};
|
|
|
|
const extractTopic = (assignment: ApiAssignment, questions: ApiAssignmentStudentQuestionDetail[]) => {
|
|
const fromInstructions = assignment.instructions?.match(/^Topic:\s*(.+)$/i)?.[1]?.trim();
|
|
if (fromInstructions) return fromInstructions;
|
|
|
|
return questions[0]?.subject ?? "Assignment";
|
|
};
|
|
|
|
const deriveStudentStatus = (questions: ApiAssignmentStudentQuestionDetail[], assignmentStatus: ApiAssignment["status"]) => {
|
|
const total = questions.length;
|
|
const answered = questions.filter((question) => question.answer_id).length;
|
|
const reviewed = questions.filter((question) => question.answer_status === "reviewed").length;
|
|
|
|
if (answered === 0) return "NOT_STARTED" as const;
|
|
if (reviewed === total || assignmentStatus === "closed") return "SUBMITTED" as const;
|
|
return "IN_PROGRESS" as const;
|
|
};
|
|
|
|
const deriveStatusLabel = (status: AssignmentPageData["status"]) => {
|
|
switch (status) {
|
|
case "SUBMITTED":
|
|
return "Reviewed";
|
|
case "IN_PROGRESS":
|
|
return "In progress";
|
|
default:
|
|
return "Not started";
|
|
}
|
|
};
|
|
|
|
const deriveHeadline = (status: AssignmentPageData["status"], topic: string) => {
|
|
switch (status) {
|
|
case "SUBMITTED":
|
|
return `Review how you did in ${topic}`;
|
|
case "IN_PROGRESS":
|
|
return "Keep going — you already have momentum here";
|
|
default:
|
|
return "Start this assignment with a steady first pass";
|
|
}
|
|
};
|
|
|
|
const deriveDescription = (status: AssignmentPageData["status"], answered: number, reviewed: number, total: number, topic: string) => {
|
|
switch (status) {
|
|
case "SUBMITTED":
|
|
return `${reviewed} of ${total} questions have teacher-reviewed answers. Use this review view to see what landed well and what still needs another pass in ${topic}.`;
|
|
case "IN_PROGRESS":
|
|
return `You have touched ${answered} of ${total} questions so far. Finish the remaining questions while ${topic.toLowerCase()} still feels familiar.`;
|
|
default:
|
|
return `This assignment has ${total} questions. Start with a calm first pass, then come back here for one shared review summary across the assignment.`;
|
|
}
|
|
};
|
|
|
|
const derivePrimaryAction = (status: AssignmentPageData["status"]) => {
|
|
switch (status) {
|
|
case "SUBMITTED":
|
|
return "Open workspace";
|
|
case "IN_PROGRESS":
|
|
return "Continue assignment";
|
|
default:
|
|
return "Start assignment";
|
|
}
|
|
};
|
|
|
|
const mapQuestionStatus = (question: ApiAssignmentStudentQuestionDetail) => {
|
|
if (question.is_correct === true) {
|
|
return {
|
|
statusLabel: "Correct",
|
|
statusTone: "success" as const,
|
|
};
|
|
}
|
|
|
|
if (question.answer_status === "reviewed") {
|
|
return {
|
|
statusLabel: "Reviewed",
|
|
statusTone: "success" as const,
|
|
};
|
|
}
|
|
|
|
if (question.answer_status === "submitted") {
|
|
return {
|
|
statusLabel: "Submitted",
|
|
statusTone: "warning" as const,
|
|
};
|
|
}
|
|
|
|
if (question.answer_status === "in_progress") {
|
|
return {
|
|
statusLabel: "In progress",
|
|
statusTone: "warning" as const,
|
|
};
|
|
}
|
|
|
|
return {
|
|
statusLabel: "Not started",
|
|
statusTone: "muted" as const,
|
|
};
|
|
};
|
|
|
|
export const getAssignmentReviewPageData = async (assignmentId: number, studentId: number): Promise<AssignmentPageData | null> => {
|
|
if (!Number.isFinite(assignmentId) || assignmentId <= 0) return null;
|
|
|
|
try {
|
|
const assignment = await apiFetchJson<ApiAssignment>(`/api/assignments/${assignmentId}`);
|
|
|
|
const [student, teacher, classrooms, questionDetails] = await Promise.all([
|
|
apiFetchJson<ApiUser>(`/api/users/${studentId}`),
|
|
apiFetchJson<ApiUser>(`/api/users/${assignment.teacher_id}`),
|
|
apiFetchJson<ApiListResponse<ApiClassroom>>(`/api/teachers/${assignment.teacher_id}/classrooms`),
|
|
apiFetchJson<ApiListResponse<ApiAssignmentStudentQuestionDetail>>(`/api/assignments/${assignmentId}/students/${studentId}/questions`),
|
|
]);
|
|
|
|
const classroom = classrooms.data.find((entry) => entry.id === assignment.classroom_id);
|
|
const questions = questionDetails.data;
|
|
const topic = extractTopic(assignment, questions);
|
|
const answeredCount = questions.filter((question) => question.answer_id).length;
|
|
const reviewedCount = questions.filter((question) => question.answer_status === "reviewed").length;
|
|
const assignmentAiFeedback = questions[0]?.assignment_ai_feedback?.trim() || "";
|
|
const assignmentTeacherFeedback = questions[0]?.assignment_teacher_feedback?.trim() || "";
|
|
const status = deriveStudentStatus(questions, assignment.status);
|
|
const statusLabel = deriveStatusLabel(status);
|
|
|
|
return {
|
|
id: assignment.id,
|
|
title: assignment.title,
|
|
topic,
|
|
status,
|
|
statusLabel,
|
|
dueLabel: formatDateLabel(assignment.due_at),
|
|
studentName: student.full_name,
|
|
classroomName: classroom?.name ?? "Classroom",
|
|
tutorName: teacher.full_name,
|
|
headline: deriveHeadline(status, topic),
|
|
description: deriveDescription(status, answeredCount, reviewedCount, questions.length, topic),
|
|
primaryAction: derivePrimaryAction(status),
|
|
primaryHref: getAssignmentWorkHref(assignment.id),
|
|
stats: [
|
|
{ label: "Status", value: statusLabel },
|
|
{ label: "Due", value: formatDateLabel(assignment.due_at) },
|
|
{ label: "Questions", value: `${questions.length}` },
|
|
{ label: status === "SUBMITTED" ? "Reviewed" : "Answered", value: `${status === "SUBMITTED" ? reviewedCount : answeredCount}/${questions.length}` },
|
|
],
|
|
coachCard: {
|
|
title: "Review notes",
|
|
description: `Use this review view to compare your latest answers with the shared AI and teacher feedback for ${topic.toLowerCase()}.`,
|
|
items: [`${answeredCount} questions attempted`, `${reviewedCount} questions reviewed`, `${Math.max(questions.length - answeredCount, 0)} questions still untouched`],
|
|
},
|
|
assignmentAiFeedback: assignmentAiFeedback || undefined,
|
|
assignmentTeacherFeedback: assignmentTeacherFeedback || undefined,
|
|
questions: questions.map((question) => {
|
|
const questionStatus = mapQuestionStatus(question);
|
|
|
|
return {
|
|
id: question.question_id,
|
|
order: question.position,
|
|
prompt: question.prompt,
|
|
topic: question.subject,
|
|
subTopic: null,
|
|
difficulty: "Backend",
|
|
marks: null,
|
|
statusLabel: questionStatus.statusLabel,
|
|
statusTone: questionStatus.statusTone,
|
|
responseLabel: question.answer_id ? "Latest answer" : "Status",
|
|
responseValue: question.answer_text?.trim() || "No attempt yet",
|
|
feedback: "",
|
|
workingSteps: question.working_steps?.trim() || "",
|
|
solveModeLabel:
|
|
question.solve_mode === "step_by_step"
|
|
? "Step by step"
|
|
: question.solve_mode === "solve_together"
|
|
? "Solve together"
|
|
: question.solve_mode === "handwritten"
|
|
? "Handwritten"
|
|
: question.solve_mode === "just_answer"
|
|
? "Just answer"
|
|
: undefined,
|
|
showAnswerKey: Boolean(question.correct_answer && question.answer_id),
|
|
correctAnswer: question.correct_answer?.trim() || null,
|
|
isCorrect: typeof question.is_correct === "boolean" ? question.is_correct : null,
|
|
};
|
|
}),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === "not_found") {
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
};
|