* fix(exercise): persist completion policy and pass threshold on save buildExerciseUpdateFields accepted completionPolicy and passThreshold in the validation schema but never mapped them to the database update, so exercise settings appeared to save while reverting on refresh. Co-authored-by: Rotimi Best <[email protected]> * fix(db): build workspace deps before compiling @cio/db imports @cio/utils/notifications, which only exists after @cio/utils is built. Building db in isolation failed when utils dist was missing or stale. - Add a db prebuild step for question-types, utils, and email - Clean dist and tsbuildinfo before utils and email builds so incremental tsc cannot skip emission after dist was removed Co-authored-by: Rotimi Best <[email protected]> * chore(dev): build api workspace deps before api:dev Run turbo build for @cio/api and @cio/jobs-worker dependencies before starting the dev servers, so missing dist/ folders do not break startup. Co-authored-by: Rotimi Best <[email protected]> * feat(course): improve exercise progression grading UX --------- Co-authored-by: Rotimi Best <[email protected]>
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
import { isAutoGradableQuestionTypeId } from '@cio/question-types';
|
|
|
|
import type { Question } from '$features/course/types';
|
|
import type { SubmissionListItem } from '$features/course/utils/types';
|
|
|
|
export type ExerciseCompletionPolicy = 'submitted' | 'passed';
|
|
|
|
export type ExerciseAttempt = {
|
|
attemptNumber: number;
|
|
submission: SubmissionListItem;
|
|
};
|
|
|
|
export type ExerciseSubmissionStudentGroup = {
|
|
studentKey: string;
|
|
studentName: string;
|
|
studentAvatarUrl: string;
|
|
attempts: ExerciseAttempt[];
|
|
};
|
|
|
|
export function getActiveExerciseQuestions(questions: Question[]): Question[] {
|
|
return questions.filter((question) => !question.deletedAt);
|
|
}
|
|
|
|
export function getTotalPossibleExercisePoints(questions: Question[]): number {
|
|
return getActiveExerciseQuestions(questions).reduce((total, question) => total + Number(question.points ?? 0), 0);
|
|
}
|
|
|
|
export function getManualGradedExerciseQuestions(questions: Question[]): Question[] {
|
|
return getActiveExerciseQuestions(questions).filter((question) => {
|
|
const questionTypeId = Number(question.questionTypeId ?? question.questionType?.id);
|
|
if (!Number.isFinite(questionTypeId)) return true;
|
|
|
|
return !isAutoGradableQuestionTypeId(questionTypeId);
|
|
});
|
|
}
|
|
|
|
function getSubmissionScore(submission: SubmissionListItem): number {
|
|
const submissionTotal = Number((submission as SubmissionListItem & { total?: number | string | null }).total ?? NaN);
|
|
if (Number.isFinite(submissionTotal)) return submissionTotal;
|
|
|
|
return (submission.answers ?? []).reduce((total, answer) => total + Number(answer.point ?? 0), 0);
|
|
}
|
|
|
|
export function shouldCompleteExerciseFromSubmission(params: {
|
|
completionPolicy: ExerciseCompletionPolicy | null | undefined;
|
|
passThreshold: number | null | undefined;
|
|
totalPossiblePoints: number;
|
|
submission: SubmissionListItem & { gradingState?: string | null };
|
|
}): boolean {
|
|
if ((params.completionPolicy ?? 'submitted') === 'submitted') return true;
|
|
if (params.submission.gradingState !== 'completed') return false;
|
|
if (params.totalPossiblePoints <= 0) return false;
|
|
|
|
const threshold = Number(params.passThreshold ?? 100);
|
|
const scorePercent = (getSubmissionScore(params.submission) / params.totalPossiblePoints) * 100;
|
|
|
|
return scorePercent >= threshold;
|
|
}
|
|
|
|
function getSubmissionTimestamp(submission: SubmissionListItem): number {
|
|
const createdAt = (submission as SubmissionListItem & { createdAt?: string | Date | null }).createdAt;
|
|
const timestamp = createdAt ? new Date(createdAt).getTime() : NaN;
|
|
|
|
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
}
|
|
|
|
function getStudentKey(submission: SubmissionListItem): string {
|
|
const profile = submission.groupmember?.profile;
|
|
const firstAnswerGroupMemberId = submission.answers?.find((answer) => answer.groupMemberId)?.groupMemberId;
|
|
|
|
return String(profile?.id ?? firstAnswerGroupMemberId ?? submission.id);
|
|
}
|
|
|
|
export function groupSubmissionsByStudentAndAttempt(
|
|
submissionItems: SubmissionListItem[]
|
|
): ExerciseSubmissionStudentGroup[] {
|
|
const groupByStudent = new Map<string, SubmissionListItem[]>();
|
|
|
|
for (const submission of submissionItems) {
|
|
const studentKey = getStudentKey(submission);
|
|
const studentSubmissions = groupByStudent.get(studentKey) ?? [];
|
|
studentSubmissions.push(submission);
|
|
groupByStudent.set(studentKey, studentSubmissions);
|
|
}
|
|
|
|
return [...groupByStudent.entries()]
|
|
.map(([studentKey, studentSubmissions]) => {
|
|
const orderedSubmissions = [...studentSubmissions].sort(
|
|
(leftSubmission, rightSubmission) =>
|
|
getSubmissionTimestamp(leftSubmission) - getSubmissionTimestamp(rightSubmission)
|
|
);
|
|
const latestSubmission = orderedSubmissions[orderedSubmissions.length - 1];
|
|
const profile = latestSubmission?.groupmember?.profile;
|
|
|
|
return {
|
|
studentKey,
|
|
studentName: profile?.fullname ?? '',
|
|
studentAvatarUrl: profile?.avatarUrl ?? '',
|
|
attempts: orderedSubmissions.map((submission, index) => ({
|
|
attemptNumber: index + 1,
|
|
submission
|
|
}))
|
|
};
|
|
})
|
|
.sort((leftGroup, rightGroup) => leftGroup.studentName.localeCompare(rightGroup.studentName));
|
|
}
|