///
import {
type Cell,
Default,
derive,
handler,
lift,
recipe,
str,
} from "commontools";
interface StudentInput {
id?: string;
name?: string;
}
interface AssignmentInput {
id?: string;
title?: string;
weight?: number;
maxScore?: number;
}
interface GradeInput {
studentId?: string;
assignmentId?: string;
score?: number;
}
interface StudentRecord {
id: string;
name: string;
}
interface AssignmentRecord {
id: string;
title: string;
weight: number;
maxScore: number;
}
interface GradeRecord {
studentId: string;
assignmentId: string;
score: number;
}
interface AssignmentStatistic {
assignmentId: string;
assignmentTitle: string;
averageScore: number;
maxScore: number;
averagePercent: number;
submissions: number;
completionPercent: number;
weight: number;
}
interface StudentGradeCell {
assignmentId: string;
assignmentTitle: string;
score: number;
maxScore: number;
percent: number;
weight: number;
completed: boolean;
}
interface StudentGradeRow {
studentId: string;
studentName: string;
grades: StudentGradeCell[];
averagePercent: number;
weightedPercent: number;
completionPercent: number;
}
interface GradeMatrixView {
rows: StudentGradeRow[];
assignmentStats: AssignmentStatistic[];
classAveragePercent: number;
studentCount: number;
assignmentCount: number;
}
interface TopPerformer {
studentId: string;
studentName: string;
weightedPercent: number;
completionPercent: number;
}
interface AssignmentHighlight {
assignmentId: string;
assignmentTitle: string;
averagePercent: number;
completionPercent: number;
submissions: number;
}
interface AssignmentGradingMatrixArgs {
students: Default;
assignments: Default;
grades: Default;
}
interface RecordGradeEvent {
studentId?: string;
assignmentId?: string;
score?: number;
delta?: number;
}
const roundToTwo = (value: number): number => {
return Math.round(value * 100) / 100;
};
const formatPercent = (value: number): string => {
const rounded = Math.round(value * 10) / 10;
return `${rounded.toFixed(1)}%`;
};
const sanitizeIdentifier = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
const normalized = trimmed
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
if (normalized.length > 0) return normalized;
}
return fallback;
};
const sanitizeName = (value: unknown, fallback: string): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim().replace(/\s+/g, " ");
if (trimmed.length === 0) return fallback;
const first = trimmed.charAt(0).toUpperCase();
return `${first}${trimmed.slice(1)}`;
};
const sanitizePositive = (value: unknown, fallback: number): number => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
const normalized = roundToTwo(value);
if (normalized <= 0) return fallback;
return normalized;
};
const sanitizeScore = (value: unknown): number | null => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
return roundToTwo(value);
};
const clampScore = (score: number, maxScore: number): number => {
if (maxScore <= 0) return 0;
if (score <= 0) return 0;
if (score >= maxScore) return roundToTwo(maxScore);
return roundToTwo(score);
};
const claimIdentifier = (
used: Set,
candidate: string,
fallback: string,
): string => {
const base = candidate.length > 0 ? candidate : fallback;
let next = base;
let suffix = 2;
while (used.has(next)) {
next = `${base}-${suffix}`;
suffix += 1;
}
used.add(next);
return next;
};
const buildOrderMap = (ids: readonly string[]): Map => {
const order = new Map();
ids.forEach((value, index) => {
order.set(value, index);
});
return order;
};
const sanitizeStudents = (value: unknown): StudentRecord[] => {
if (!Array.isArray(value)) return [];
const used = new Set();
const sanitized: StudentRecord[] = [];
for (let index = 0; index < value.length; index += 1) {
const raw = value[index] as StudentInput | undefined;
const fallbackId = `student-${index + 1}`;
const idCandidate = sanitizeIdentifier(raw?.id, fallbackId);
const id = claimIdentifier(used, idCandidate, fallbackId);
const nameFallback = `Student ${index + 1}`;
const name = sanitizeName(raw?.name, nameFallback);
sanitized.push({ id, name });
}
return sanitized;
};
const sanitizeAssignments = (value: unknown): AssignmentRecord[] => {
if (!Array.isArray(value)) return [];
const used = new Set();
const sanitized: AssignmentRecord[] = [];
for (let index = 0; index < value.length; index += 1) {
const raw = value[index] as AssignmentInput | undefined;
const fallbackId = `assignment-${index + 1}`;
const idCandidate = sanitizeIdentifier(raw?.id, fallbackId);
const id = claimIdentifier(used, idCandidate, fallbackId);
const titleFallback = `Assignment ${index + 1}`;
const title = sanitizeName(raw?.title, titleFallback);
const weight = sanitizePositive(raw?.weight, 1);
const maxScore = sanitizePositive(raw?.maxScore, 100);
sanitized.push({ id, title, weight, maxScore });
}
return sanitized;
};
const sanitizeGrades = (
value: unknown,
students: readonly StudentRecord[],
assignments: readonly AssignmentRecord[],
): GradeRecord[] => {
if (!Array.isArray(value)) return [];
const studentOrder = buildOrderMap(students.map((student) => student.id));
const assignmentOrder = buildOrderMap(
assignments.map((assignment) => assignment.id),
);
const assignmentById = new Map();
for (const assignment of assignments) {
assignmentById.set(assignment.id, assignment);
}
const map = new Map();
for (const entry of value as GradeInput[]) {
if (!entry) continue;
const studentId = sanitizeIdentifier(entry.studentId, "");
const assignmentId = sanitizeIdentifier(entry.assignmentId, "");
if (!studentOrder.has(studentId)) continue;
const assignment = assignmentById.get(assignmentId);
if (!assignment) continue;
const scoreValue = sanitizeScore(entry.score) ?? 0;
const clamped = clampScore(scoreValue, assignment.maxScore);
const key = `${studentId}::${assignmentId}`;
map.set(key, { studentId, assignmentId, score: clamped });
}
const sanitized = Array.from(map.values());
sanitized.sort((left, right) => {
const studentDiff = (studentOrder.get(left.studentId) ?? 0) -
(studentOrder.get(right.studentId) ?? 0);
if (studentDiff !== 0) return studentDiff;
return (assignmentOrder.get(left.assignmentId) ?? 0) -
(assignmentOrder.get(right.assignmentId) ?? 0);
});
return sanitized;
};
const buildGradeMatrix = (
entries: readonly GradeRecord[],
students: readonly StudentRecord[],
assignments: readonly AssignmentRecord[],
): GradeMatrixView => {
const entryMap = new Map();
for (const entry of entries) {
const key = `${entry.studentId}::${entry.assignmentId}`;
entryMap.set(key, entry);
}
const rows: StudentGradeRow[] = [];
let weightedTotal = 0;
let weightedCount = 0;
for (const student of students) {
const cells: StudentGradeCell[] = [];
let percentSum = 0;
let weightedSum = 0;
let weightTotal = 0;
let completed = 0;
for (const assignment of assignments) {
const key = `${student.id}::${assignment.id}`;
const grade = entryMap.get(key);
const score = grade?.score ?? 0;
const percent = assignment.maxScore === 0
? 0
: roundToTwo((score / assignment.maxScore) * 100);
const isCompleted = grade !== undefined;
if (isCompleted) completed += 1;
percentSum += percent;
weightedSum += percent * assignment.weight;
weightTotal += assignment.weight;
cells.push({
assignmentId: assignment.id,
assignmentTitle: assignment.title,
score: roundToTwo(score),
maxScore: assignment.maxScore,
percent,
weight: assignment.weight,
completed: isCompleted,
});
}
const averagePercent = assignments.length === 0
? 0
: roundToTwo(percentSum / assignments.length);
const weightedPercent = weightTotal === 0
? 0
: roundToTwo(weightedSum / weightTotal);
const completionPercent = assignments.length === 0
? 0
: roundToTwo((completed / assignments.length) * 100);
rows.push({
studentId: student.id,
studentName: student.name,
grades: cells,
averagePercent,
weightedPercent,
completionPercent,
});
weightedTotal += weightedPercent;
weightedCount += 1;
}
const classAveragePercent = weightedCount === 0
? 0
: roundToTwo(weightedTotal / weightedCount);
const assignmentStats: AssignmentStatistic[] = assignments.map(
(assignment) => {
let totalScore = 0;
let submissions = 0;
for (const student of students) {
const key = `${student.id}::${assignment.id}`;
const grade = entryMap.get(key);
if (!grade) continue;
totalScore += grade.score;
submissions += 1;
}
const averageScore = submissions === 0
? 0
: roundToTwo(totalScore / submissions);
const averagePercent = assignment.maxScore === 0
? 0
: roundToTwo((averageScore / assignment.maxScore) * 100);
const completionPercent = students.length === 0
? 0
: roundToTwo((submissions / students.length) * 100);
return {
assignmentId: assignment.id,
assignmentTitle: assignment.title,
averageScore,
maxScore: assignment.maxScore,
averagePercent,
submissions,
completionPercent,
weight: assignment.weight,
};
},
);
return {
rows,
assignmentStats,
classAveragePercent,
studentCount: rows.length,
assignmentCount: assignments.length,
};
};
const identifyTopPerformer = (matrix: GradeMatrixView): TopPerformer => {
if (matrix.rows.length === 0) {
return {
studentId: "none",
studentName: "none",
weightedPercent: 0,
completionPercent: 0,
};
}
let best = matrix.rows[0];
for (let index = 1; index < matrix.rows.length; index += 1) {
const current = matrix.rows[index];
if (current.weightedPercent > best.weightedPercent) {
best = current;
continue;
}
if (current.weightedPercent === best.weightedPercent) {
if (current.completionPercent > best.completionPercent) {
best = current;
continue;
}
if (
current.completionPercent === best.completionPercent &&
current.studentName.localeCompare(best.studentName) < 0
) {
best = current;
}
}
}
return {
studentId: best.studentId,
studentName: best.studentName,
weightedPercent: best.weightedPercent,
completionPercent: best.completionPercent,
};
};
const highlightAssignment = (
stats: readonly AssignmentStatistic[],
): AssignmentHighlight => {
if (stats.length === 0) {
return {
assignmentId: "none",
assignmentTitle: "none",
averagePercent: 0,
completionPercent: 0,
submissions: 0,
};
}
let best = stats[0];
for (let index = 1; index < stats.length; index += 1) {
const current = stats[index];
if (current.averagePercent > best.averagePercent) {
best = current;
continue;
}
if (current.averagePercent === best.averagePercent) {
if (current.completionPercent > best.completionPercent) {
best = current;
continue;
}
if (
current.completionPercent === best.completionPercent &&
current.assignmentTitle.localeCompare(best.assignmentTitle) < 0
) {
best = current;
}
}
}
return {
assignmentId: best.assignmentId,
assignmentTitle: best.assignmentTitle,
averagePercent: best.averagePercent,
completionPercent: best.completionPercent,
submissions: best.submissions,
};
};
const updateGradeEntries = (
list: readonly GradeRecord[],
event: RecordGradeEvent | undefined,
students: readonly StudentRecord[],
assignments: readonly AssignmentRecord[],
): GradeRecord[] => {
if (!event) return [...list];
const studentId = sanitizeIdentifier(event.studentId, "");
const assignmentId = sanitizeIdentifier(event.assignmentId, "");
if (studentId.length === 0 || assignmentId.length === 0) {
return [...list];
}
const studentExists = students.some((student) => student.id === studentId);
if (!studentExists) return [...list];
const assignment = assignments.find((item) => item.id === assignmentId);
if (!assignment) return [...list];
const absolute = sanitizeScore(event.score);
const delta = sanitizeScore(event.delta);
let nextScore: number | null = null;
if (absolute !== null) {
nextScore = clampScore(absolute, assignment.maxScore);
} else if (delta !== null && delta !== 0) {
const current = list.find((entry) =>
entry.studentId === studentId && entry.assignmentId === assignmentId
);
const base = current?.score ?? 0;
nextScore = clampScore(base + delta, assignment.maxScore);
}
if (nextScore === null) return [...list];
const updated: GradeRecord[] = [];
let replaced = false;
for (const entry of list) {
if (entry.studentId === studentId && entry.assignmentId === assignmentId) {
if (!replaced) {
updated.push({ studentId, assignmentId, score: nextScore });
replaced = true;
}
continue;
}
updated.push(entry);
}
if (!replaced) {
updated.push({ studentId, assignmentId, score: nextScore });
}
const studentOrder = buildOrderMap(students.map((student) => student.id));
const assignmentOrder = buildOrderMap(
assignments.map((assignment) => assignment.id),
);
updated.sort((left, right) => {
const studentDiff = (studentOrder.get(left.studentId) ?? 0) -
(studentOrder.get(right.studentId) ?? 0);
if (studentDiff !== 0) return studentDiff;
return (assignmentOrder.get(left.assignmentId) ?? 0) -
(assignmentOrder.get(right.assignmentId) ?? 0);
});
return updated;
};
const recordGrade = handler(
(
event: RecordGradeEvent | undefined,
context: {
rawGrades: Cell;
students: Cell;
assignments: Cell;
},
) => {
const students = context.students.get();
const assignments = context.assignments.get();
const studentList = Array.isArray(students) ? students : [];
const assignmentList = Array.isArray(assignments) ? assignments : [];
const existing = sanitizeGrades(
context.rawGrades.get(),
studentList,
assignmentList,
);
const next = updateGradeEntries(
existing,
event,
studentList,
assignmentList,
);
context.rawGrades.set(next.map((entry) => ({ ...entry })));
},
);
export const assignmentGradingMatrix = recipe(
"Assignment Grading Matrix",
({ students, assignments, grades }) => {
const sanitizedStudents = lift(sanitizeStudents)(students);
const sanitizedAssignments = lift(sanitizeAssignments)(assignments);
const gradeEntries = lift((input: {
entries: GradeInput[] | undefined;
students: StudentRecord[];
assignments: AssignmentRecord[];
}) => {
const baseEntries = Array.isArray(input.entries) ? input.entries : [];
return sanitizeGrades(baseEntries, input.students, input.assignments);
})({
entries: grades,
students: sanitizedStudents,
assignments: sanitizedAssignments,
});
const gradeMatrix = lift((input: {
entries: GradeRecord[];
students: StudentRecord[];
assignments: AssignmentRecord[];
}) => {
return buildGradeMatrix(input.entries, input.students, input.assignments);
})({
entries: gradeEntries,
students: sanitizedStudents,
assignments: sanitizedAssignments,
});
const topPerformer = derive(gradeMatrix, identifyTopPerformer);
const standoutAssignment = derive(
gradeMatrix,
(matrix) => highlightAssignment(matrix.assignmentStats),
);
const classAverageText = lift((matrix: GradeMatrixView) =>
formatPercent(matrix.classAveragePercent)
)(gradeMatrix);
const studentCountText = lift((matrix: GradeMatrixView) =>
`${matrix.studentCount}`
)(gradeMatrix);
const assignmentCountText = lift((matrix: GradeMatrixView) =>
`${matrix.assignmentCount}`
)(gradeMatrix);
const summaryLabel =
str`Class average ${classAverageText} across ${studentCountText} students for ${assignmentCountText} assignments`;
return {
students: sanitizedStudents,
assignments: sanitizedAssignments,
gradeEntries,
gradeMatrix,
topPerformer,
standoutAssignment,
summaryLabel,
controls: {
recordGrade: recordGrade({
rawGrades: grades,
students: sanitizedStudents,
assignments: sanitizedAssignments,
}),
},
};
},
);