Files
classroomio/apps/dashboard/src/lib/features/course/utils/marks-utils.ts
T

231 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-03-09 11:18:22 +01:00
import type { CourseContentItem, CourseMembers } from '$features/course/utils/types';
import type { Marks } from './types';
import { ContentType } from '@cio/utils/constants/content';
import Papa from 'papaparse';
import autoTable from 'jspdf-autotable';
import jsPDF from 'jspdf';
/** Exercise information for display in marks table (linear, no lesson grouping) */
export interface ExerciseInfo {
id: string;
title: string;
points: number;
}
/** Maps groupMemberId -> exerciseId -> points obtained (as string) */
export type StudentMarksByExercise = {
[groupMemberId: string]: {
[exerciseId: string]: string;
};
};
/** Student's exercise data for calculating totals */
export type StudentExerciseData =
| {
[exerciseId: string]: string;
}
| undefined;
/** Processed marks data structure returned from server */
export interface MarksPageData {
studentMarksByExerciseId: StudentMarksByExercise;
exercises: ExerciseInfo[];
students: CourseMembers;
}
/**
* Calculate total points for a student from their exercise data
* @param studentExerciseData Student's marks by exercise ID
* @returns Total points as number
*/
export function calculateStudentTotal(studentExerciseData: StudentExerciseData): number {
if (!studentExerciseData) return 0;
return Object.values(studentExerciseData).reduce((total: number, point) => {
return (total += parseInt(point as string, 10));
}, 0);
}
/**
* Calculate average grade as percentage (points earned / total points possible) for a student
* @param studentExerciseData Student's marks by exercise ID
* @param exercises Ordered list of exercises with points
* @returns Rounded percentage 0-100, or null if no exercises with points
*/
export function calculateStudentAverage(
studentExerciseData: StudentExerciseData,
exercises: ExerciseInfo[]
): number | null {
if (!studentExerciseData || exercises.length === 0) return null;
const totalPossible = exercises.reduce((sum, ex) => sum + ex.points, 0);
if (totalPossible === 0) return null;
const totalEarned = exercises.reduce((sum, ex) => {
const pts = studentExerciseData[ex.id];
return sum + (pts != null ? parseInt(pts, 10) : 0);
}, 0);
return Math.round((totalEarned / totalPossible) * 100);
}
/**
* Transform marks array into nested object structure mapping groupMemberId -> exerciseId -> points
* @param marks Array of mark records from API
* @returns Nested object structure for student marks
*/
export function processMarksIntoStudentMarksByExerciseId(marks: Marks): StudentMarksByExercise {
const result: StudentMarksByExercise = {};
if (!Array.isArray(marks)) return result;
marks.forEach((mark) => {
const { groupmemberId, exerciseId, totalPointsGotten } = mark;
if (!groupmemberId || !exerciseId) return;
if (result[groupmemberId]) {
result[groupmemberId] = {
...result[groupmemberId],
[exerciseId]: totalPointsGotten?.toString() || '0'
};
} else {
result[groupmemberId] = {
[exerciseId]: totalPointsGotten?.toString() || '0'
};
}
});
return result;
}
/**
* Build ordered list of exercises for gradebook from course content and marks.
* When contentItems has no exercises (e.g. course fetch failed), derives order from marks.
* @param marks Array of mark records from API
* @param contentItems Flat or section-flattened content items (lesson + exercise) in display order; may be empty
* @returns Ordered ExerciseInfo[] (exercise id, title, points)
*/
export function processMarksIntoExercises(marks: Marks, contentItems: CourseContentItem[]): ExerciseInfo[] {
const marksByExerciseId = new Map<string, { title: string; points: number }>();
const exerciseOrder: string[] = [];
if (Array.isArray(marks)) {
marks.forEach((mark) => {
const { exerciseId, exerciseTitle, exercisePoints } = mark;
if (!exerciseId) return;
if (!marksByExerciseId.has(exerciseId)) {
exerciseOrder.push(exerciseId);
marksByExerciseId.set(exerciseId, {
title: exerciseTitle || '',
points: exercisePoints ?? 0
});
}
});
}
const exerciseItems = contentItems.filter((item) => item.type === ContentType.Exercise);
if (exerciseItems.length > 0) {
return exerciseItems.map((item) => {
const fromMarks = marksByExerciseId.get(item.id);
return {
id: item.id,
title: item.title ?? fromMarks?.title ?? '',
points: fromMarks?.points ?? 0
};
});
}
// No course content (e.g. course fetch failed): use exercises from marks, order by first appearance
return exerciseOrder.map((id) => {
const fromMarks = marksByExerciseId.get(id)!;
return {
id,
title: fromMarks?.title ?? '',
points: fromMarks?.points ?? 0
};
});
}
/**
* Generate and download CSV export of marks (student by exercise + avg grade)
* @param students Array of students
* @param exercises Ordered exercises
* @param studentMarksByExerciseId Student marks by exercise
* @param courseTitle Course title for filename
*/
export function generateMarksCSV(
students: CourseMembers,
exercises: ExerciseInfo[],
studentMarksByExerciseId: StudentMarksByExercise,
courseTitle: string
): void {
const exportData = students.map((student) => {
const marks = studentMarksByExerciseId[student.id] || {};
const rowData: Record<string, string | number> = {
name: student.profile?.fullname || '',
email: student.profile?.email || ''
};
exercises.forEach((ex) => {
rowData[ex.title] = marks[ex.id] ?? '-';
});
const avg = calculateStudentAverage(marks, exercises);
rowData['Avg grade'] = avg != null ? avg : '-';
return rowData;
});
const csv = Papa.unparse(exportData);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `${courseTitle}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Generate and download PDF export of marks (student by exercise + avg grade)
* @param students Array of students
* @param exercises Ordered exercises
* @param studentMarksByExerciseId Student marks by exercise
* @param courseTitle Course title for filename and header
*/
export function generateMarksPDF(
students: CourseMembers,
exercises: ExerciseInfo[],
studentMarksByExerciseId: StudentMarksByExercise,
courseTitle: string
): void {
try {
const doc = new jsPDF({ orientation: 'landscape' });
const head = [['Student', ...exercises.map((ex) => ex.title), 'Avg grade']];
const body = students.map((student) => {
const marks = studentMarksByExerciseId[student.id] || {};
const row: string[] = [student?.profile?.fullname ?? ''];
exercises.forEach((ex) => {
row.push(marks[ex.id] ?? '-');
});
const avg = calculateStudentAverage(marks, exercises);
row.push(avg != null ? String(avg) : '-');
return row;
});
autoTable(doc, {
head,
body,
startY: 20,
styles: { fontSize: 8 },
headStyles: { fillColor: [22, 160, 133] },
footStyles: { fillColor: [22, 160, 133] },
didDrawPage: function (data) {
doc.setFontSize(14);
doc.setTextColor(40);
doc.text(`${courseTitle} - Marks`, data.settings.margin.left, 10);
}
});
doc.save(`${courseTitle}-marks.pdf`);
} catch (error) {
console.error('Error generating PDF:', error);
throw error;
}
}