Files
BoostAI/Frontend/src/components/assignment/student/assignment-review.data.ts
2026-05-25 17:05:06 +01:00

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;
}
};