import "server-only"; import { cache } from "react" import { and, asc, count, eq, inArray } from "drizzle-orm" import { db } from "@/shared/db" import { classes, classEnrollments, grades, schools, } from "@/shared/db/schema" import { getAssignmentIdsForStudents, getAssignmentMaxScoreById, getAssignmentTargetCounts, getHomeworkAssignmentsByIds, getHomeworkAssignmentsWithSubject, getHomeworkSubmissionsForStudents, } from "@/modules/homework/data-access-classes" import type { ClassHomeworkInsights, ClassHomeworkAssignmentStats, GradeHomeworkClassSummary, GradeHomeworkInsights, ScoreStats, } from "./types" import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes" import { getAccessibleClassIdsForTeacher, getSessionTeacherId, getTeacherSubjectIdsForClass, } from "./data-access" const median = (sorted: number[]): number | null => { if (sorted.length === 0) return null const mid = Math.floor(sorted.length / 2) if (sorted.length % 2 === 1) return sorted[mid] ?? null const a = sorted[mid - 1] const b = sorted[mid] if (typeof a !== "number" || typeof b !== "number") return null return (a + b) / 2 } const toScoreStats = (scores: number[]): ScoreStats => { if (scores.length === 0) return { count: 0, avg: null, median: null, min: null, max: null } const sorted = [...scores].sort((a, b) => a - b) const sum = sorted.reduce((acc, v) => acc + v, 0) return { count: sorted.length, avg: sum / sorted.length, median: median(sorted), min: sorted[0] ?? null, max: sorted[sorted.length - 1] ?? null, } } const buildLatestSubmissionByKey = ( submissions: HomeworkSubmissionRecord[] ): Map => { const map = new Map() for (const s of submissions) { const key = `${s.assignmentId}:${s.studentId}` if (!map.has(key)) map.set(key, s) } return map } const computeAssignmentStats = (params: { assignments: Array<{ id: string title: string status: string | null createdAt: Date dueAt: Date | null subjectName?: string | null }> studentIds: string[] latestByKey: Map maxScoreByAssignmentId: Map targetCountByAssignmentId: Map }): { stats: ClassHomeworkAssignmentStats[]; allScored: number[] } => { const { assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId } = params const allScored: number[] = [] const nowMs = Date.now() const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => { const targetCount = targetCountByAssignmentId.get(a.id) ?? 0 let submittedCount = 0 let gradedCount = 0 const scores: number[] = [] const dueMs = a.dueAt ? a.dueAt.getTime() : null for (const studentId of studentIds) { const s = latestByKey.get(`${a.id}:${studentId}`) if (!s) continue const status = s.status ?? "started" if (status === "submitted" || status === "graded") submittedCount += 1 if (status === "graded" || typeof s.score === "number") gradedCount += 1 if (typeof s.score === "number") scores.push(s.score) } allScored.push(...scores) return { assignmentId: a.id, title: a.title, status: a.status ?? "draft", subject: a.subjectName ?? null, createdAt: a.createdAt.toISOString(), dueAt: a.dueAt ? a.dueAt.toISOString() : null, isActive: dueMs === null || dueMs >= nowMs, isOverdue: typeof dueMs === "number" && dueMs < nowMs, maxScore: maxScoreByAssignmentId.get(a.id) ?? 0, targetCount, submittedCount, gradedCount, scoreStats: toScoreStats(scores), } }) return { stats, allScored } } export const getClassHomeworkInsights = cache( async (params: { classId: string; teacherId?: string; limit?: number }): Promise => { const teacherId = params.teacherId ?? (await getSessionTeacherId()) if (!teacherId) return null const classId = params.classId.trim() if (!classId) return null const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId) if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null const [classRow] = await db .select({ id: classes.id, name: classes.name, grade: classes.grade, homeroom: classes.homeroom, room: classes.room, invitationCode: classes.invitationCode, teacherId: classes.teacherId, }) .from(classes) .where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds))) .limit(1) if (!classRow) return null const isHomeroomTeacher = classRow.teacherId === teacherId const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId) const enrollments = await db .select({ studentId: classEnrollments.studentId, status: classEnrollments.status, }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId))) const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId) const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId) const studentIds = enrollments.map((e) => e.studentId) if (!isHomeroomTeacher && subjectIdFilter.length === 0) { return { class: { id: classRow.id, name: classRow.name, grade: classRow.grade, homeroom: classRow.homeroom, room: classRow.room, invitationCode: classRow.invitationCode ?? null, }, studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } } if (studentIds.length === 0) { return { class: { id: classRow.id, name: classRow.name, grade: classRow.grade, homeroom: classRow.homeroom, room: classRow.room, invitationCode: classRow.invitationCode ?? null, }, studentCounts: { total: 0, active: 0, inactive: 0 }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } } const assignmentIds = await getAssignmentIdsForStudents(studentIds) if (assignmentIds.length === 0) { return { class: { id: classRow.id, name: classRow.name, grade: classRow.grade, homeroom: classRow.homeroom, room: classRow.room, invitationCode: classRow.invitationCode ?? null, }, studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } } const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 const assignments = await getHomeworkAssignmentsWithSubject({ assignmentIds, subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined, limit, }) const usedAssignmentIds = assignments.map((a) => a.id) if (usedAssignmentIds.length === 0) { return { class: { id: classRow.id, name: classRow.name, grade: classRow.grade, homeroom: classRow.homeroom, room: classRow.room, invitationCode: classRow.invitationCode ?? null, }, studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } } const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([ getAssignmentMaxScoreById(usedAssignmentIds), getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }), getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }), ]) const latestByKey = buildLatestSubmissionByKey(submissions) const { stats, allScored } = computeAssignmentStats({ assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId, }) const overallScores = toScoreStats(allScored) const latest = stats[0] ?? null return { class: { id: classRow.id, name: classRow.name, grade: classRow.grade, homeroom: classRow.homeroom, room: classRow.room, invitationCode: classRow.invitationCode ?? null, }, studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length }, assignments: stats, latest, overallScores, } } ) const avg = (values: number[]): number | null => { if (values.length === 0) return null const sum = values.reduce((acc, v) => acc + v, 0) return sum / values.length } export const getGradeHomeworkInsights = cache( async (params: { gradeId: string; limit?: number }): Promise => { const gradeId = params.gradeId.trim() if (!gradeId) return null const [gradeRow] = await db .select({ id: grades.id, name: grades.name, schoolId: schools.id, schoolName: schools.name, }) .from(grades) .innerJoin(schools, eq(schools.id, grades.schoolId)) .where(eq(grades.id, gradeId)) .limit(1) if (!gradeRow) return null const classRows = await db .select({ id: classes.id, name: classes.name, grade: classes.grade, homeroom: classes.homeroom, room: classes.room, }) .from(classes) .where(eq(classes.gradeId, gradeId)) .orderBy(asc(classes.name), asc(classes.homeroom), asc(classes.room)) const classIds = classRows.map((r) => r.id) if (classIds.length === 0) { return { grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, classCount: 0, studentCounts: { total: 0, active: 0, inactive: 0 }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, classes: [], } } const enrollmentRows = await db .select({ classId: classEnrollments.classId, studentId: classEnrollments.studentId, status: classEnrollments.status, }) .from(classEnrollments) .where(inArray(classEnrollments.classId, classIds)) const studentActiveById = new Map() const studentsByClassId = new Map; active: Set }>() for (const e of enrollmentRows) { const prev = studentActiveById.get(e.studentId) ?? false const next = prev || e.status === "active" studentActiveById.set(e.studentId, next) const bucket = studentsByClassId.get(e.classId) ?? { all: new Set(), active: new Set() } bucket.all.add(e.studentId) if (e.status === "active") bucket.active.add(e.studentId) studentsByClassId.set(e.classId, bucket) } const studentIds = Array.from(studentActiveById.keys()) const activeCount = Array.from(studentActiveById.values()).filter(Boolean).length const inactiveCount = studentIds.length - activeCount if (studentIds.length === 0) { const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => ({ class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, studentCounts: { total: 0, active: 0, inactive: 0 }, latestAvg: null, prevAvg: null, deltaAvg: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, })) return { grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, classCount: classRows.length, studentCounts: { total: 0, active: 0, inactive: 0 }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, classes: summaries, } } const assignmentIds = await getAssignmentIdsForStudents(studentIds) if (assignmentIds.length === 0) { const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => { const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } return { class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, latestAvg: null, prevAvg: null, deltaAvg: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } }) return { grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, classCount: classRows.length, studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, classes: summaries, } } const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50 const assignments = await getHomeworkAssignmentsByIds({ assignmentIds, limit }) const usedAssignmentIds = assignments.map((a) => a.id) if (usedAssignmentIds.length === 0) { const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => { const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } return { class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, latestAvg: null, prevAvg: null, deltaAvg: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, } }) return { grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, classCount: classRows.length, studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, assignments: [], latest: null, overallScores: { count: 0, avg: null, median: null, min: null, max: null }, classes: summaries, } } const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([ getAssignmentMaxScoreById(usedAssignmentIds), getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }), getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }), ]) const latestByKey = buildLatestSubmissionByKey(submissions) const { stats, allScored } = computeAssignmentStats({ assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId, }) const overallScores = toScoreStats(allScored) const latest = stats[0] ?? null const latestAssignmentId = stats[0]?.assignmentId ?? null const prevAssignmentId = stats[1]?.assignmentId ?? null const classSummaries: GradeHomeworkClassSummary[] = classRows.map((c) => { const bucket = studentsByClassId.get(c.id) ?? { all: new Set(), active: new Set() } const classStudentIds = Array.from(bucket.all) const latestScores: number[] = [] const prevScores: number[] = [] const overallClassScores: number[] = [] if (latestAssignmentId) { for (const studentId of classStudentIds) { const s = latestByKey.get(`${latestAssignmentId}:${studentId}`) if (typeof s?.score === "number") latestScores.push(s.score) } } if (prevAssignmentId) { for (const studentId of classStudentIds) { const s = latestByKey.get(`${prevAssignmentId}:${studentId}`) if (typeof s?.score === "number") prevScores.push(s.score) } } for (const assignmentId of usedAssignmentIds) { for (const studentId of classStudentIds) { const s = latestByKey.get(`${assignmentId}:${studentId}`) if (typeof s?.score === "number") overallClassScores.push(s.score) } } const latestAvg = avg(latestScores) const prevAvg = avg(prevScores) return { class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room }, studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size }, latestAvg, prevAvg, deltaAvg: typeof latestAvg === "number" && typeof prevAvg === "number" ? latestAvg - prevAvg : null, overallScores: toScoreStats(overallClassScores), } }) classSummaries.sort((a, b) => (b.latestAvg ?? -Infinity) - (a.latestAvg ?? -Infinity)) return { grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } }, classCount: classRows.length, studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount }, assignments: stats, latest, overallScores, classes: classSummaries, } } ) export type ClassesDashboardStats = { classCount: number } export const getClassesDashboardStats = cache(async (): Promise => { const [row] = await db.select({ value: count() }).from(classes) return { classCount: Number(row?.value ?? 0) } })