refactor: P0-1/2/4 解耦修复 - 拆分过耦合文件 + dashboard 解耦
This commit is contained in:
@@ -26,86 +26,20 @@ import type {
|
||||
HomeworkAssignmentStatus,
|
||||
HomeworkSubmissionDetails,
|
||||
HomeworkSubmissionListItem,
|
||||
HomeworkAssignmentAnalytics,
|
||||
HomeworkAssignmentQuestionAnalytics,
|
||||
StudentHomeworkAssignmentListItem,
|
||||
StudentHomeworkProgressStatus,
|
||||
StudentHomeworkTakeData,
|
||||
StudentDashboardGradeProps,
|
||||
StudentHomeworkScoreAnalytics,
|
||||
StudentRanking,
|
||||
TeacherGradeTrendItem,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
|
||||
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||
where: and(
|
||||
eq(homeworkAssignments.creatorId, teacherId),
|
||||
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
|
||||
),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit: limit,
|
||||
})
|
||||
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
if (recentAssignments.length === 0) return []
|
||||
|
||||
const assignmentIds = recentAssignments.map((a) => a.id)
|
||||
|
||||
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
|
||||
getAssignmentMaxScoreById(assignmentIds),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
count: count(homeworkAssignmentTargets.studentId),
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||
count: count(homeworkSubmissions.id),
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
])
|
||||
|
||||
const targetCountMap = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
||||
|
||||
const statsMap = new Map<string, { avg: number; count: number }>()
|
||||
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
|
||||
|
||||
return recentAssignments.map((a) => {
|
||||
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
|
||||
return {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
averageScore: stats.avg,
|
||||
maxScore: maxScoreMap.get(a.id) ?? 0,
|
||||
submissionCount: stats.count,
|
||||
totalStudents: targetCountMap.get(a.id) ?? 0,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
}
|
||||
}).reverse() // Reverse to show trend from left (older) to right (newer)
|
||||
})
|
||||
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
|
||||
|
||||
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||
export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
|
||||
if (!isRecord(v)) return null
|
||||
return v as HomeworkQuestionContent
|
||||
}
|
||||
|
||||
const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
|
||||
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
|
||||
const ids = assignmentIds.filter((v) => v.trim().length > 0)
|
||||
if (ids.length === 0) return new Map()
|
||||
|
||||
@@ -473,152 +407,6 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
|
||||
}
|
||||
})
|
||||
|
||||
export const getHomeworkAssignmentAnalytics = cache(
|
||||
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, assignmentId),
|
||||
with: {
|
||||
sourceExam: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
||||
|
||||
const [submissionsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
||||
|
||||
const [submittedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
|
||||
const [gradedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
|
||||
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
||||
|
||||
for (const aq of assignmentQuestions) {
|
||||
statsByQuestionId.set(aq.questionId, {
|
||||
questionId: aq.questionId,
|
||||
questionType: aq.question.type,
|
||||
questionContent: toQuestionContent(aq.question.content),
|
||||
maxScore: aq.score ?? 0,
|
||||
order: aq.order ?? 0,
|
||||
errorCount: 0,
|
||||
errorRate: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
with: {
|
||||
answers: true,
|
||||
student: true,
|
||||
},
|
||||
})
|
||||
|
||||
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
|
||||
for (const s of gradedSubmissionsAll) {
|
||||
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
|
||||
}
|
||||
const gradedSubmissions = Array.from(latestByStudentId.values())
|
||||
|
||||
const scoreBySubmissionQuestion = new Map<string, number>()
|
||||
const answerBySubmissionQuestion = new Map<string, unknown>()
|
||||
for (const sub of gradedSubmissions) {
|
||||
for (const ans of sub.answers) {
|
||||
const key = `${sub.id}|${ans.questionId}`
|
||||
if (scoreBySubmissionQuestion.has(key)) continue
|
||||
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
|
||||
const raw = ans.answerContent
|
||||
if (isRecord(raw) && "answer" in raw) {
|
||||
answerBySubmissionQuestion.set(key, raw.answer)
|
||||
} else {
|
||||
answerBySubmissionQuestion.set(key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const denom = gradedSubmissions.length
|
||||
if (denom > 0) {
|
||||
for (const q of statsByQuestionId.values()) {
|
||||
if (q.maxScore <= 0) continue
|
||||
let errors = 0
|
||||
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
|
||||
for (const sub of gradedSubmissions) {
|
||||
const key = `${sub.id}|${q.questionId}`
|
||||
const score = scoreBySubmissionQuestion.get(key) ?? 0
|
||||
if (score < q.maxScore) {
|
||||
errors += 1
|
||||
wrongAnswers.push({
|
||||
studentId: sub.studentId,
|
||||
studentName: sub.student.name || "Unknown",
|
||||
answerContent: answerBySubmissionQuestion.get(key),
|
||||
})
|
||||
}
|
||||
}
|
||||
q.errorCount = errors
|
||||
q.errorRate = errors / denom
|
||||
q.wrongAnswers = wrongAnswers.slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
const analytics: HomeworkAssignmentAnalytics = {
|
||||
assignment: {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||
maxAttempts: assignment.maxAttempts,
|
||||
targetCount: targetsRow?.c ?? 0,
|
||||
submissionCount: submissionsRow?.c ?? 0,
|
||||
submittedCount: submittedRow?.c ?? 0,
|
||||
gradedCount: gradedRow?.c ?? 0,
|
||||
createdAt: assignment.createdAt.toISOString(),
|
||||
updatedAt: assignment.updatedAt.toISOString(),
|
||||
},
|
||||
gradedSampleCount: gradedSubmissions.length,
|
||||
questions,
|
||||
}
|
||||
|
||||
return analytics
|
||||
}
|
||||
)
|
||||
|
||||
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
|
||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
||||
where: eq(homeworkSubmissions.id, submissionId),
|
||||
@@ -882,157 +670,12 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
|
||||
}
|
||||
})
|
||||
|
||||
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
||||
const id = studentId.trim()
|
||||
if (!id) return { trend: [], recent: [], ranking: null }
|
||||
|
||||
const targetAssignmentIdsRows = await db
|
||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.studentId, id))
|
||||
|
||||
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
|
||||
|
||||
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.studentId, id),
|
||||
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
|
||||
for (const s of gradedSubmissions) {
|
||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||
}
|
||||
|
||||
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
|
||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||
return aTime - bTime
|
||||
})
|
||||
|
||||
const trendSubmissions = unique.slice(-10)
|
||||
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
|
||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: inArray(homeworkAssignments.id, assignmentIds),
|
||||
})
|
||||
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
|
||||
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
|
||||
|
||||
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
|
||||
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
|
||||
const score = s.score ?? 0
|
||||
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
|
||||
return {
|
||||
assignmentId: s.assignmentId,
|
||||
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
|
||||
score,
|
||||
maxScore,
|
||||
percentage,
|
||||
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const trend = trendSubmissions.map(toAnalytics)
|
||||
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
|
||||
|
||||
const enrollment = await db.query.classEnrollments.findFirst({
|
||||
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
|
||||
orderBy: (e, { asc }) => [asc(e.createdAt)],
|
||||
})
|
||||
|
||||
if (!enrollment) return { trend, recent, ranking: null }
|
||||
|
||||
const classStudents = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
|
||||
|
||||
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
|
||||
const classSize = classStudentIds.length
|
||||
if (classSize === 0) return { trend, recent, ranking: null }
|
||||
|
||||
const classAssignmentIdsRows = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
|
||||
|
||||
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
|
||||
|
||||
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
inArray(homeworkSubmissions.studentId, classStudentIds),
|
||||
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
limit: 5000,
|
||||
})
|
||||
|
||||
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
|
||||
for (const s of classGradedSubmissions) {
|
||||
const key = `${s.studentId}|${s.assignmentId}`
|
||||
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
|
||||
}
|
||||
|
||||
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
|
||||
for (const sub of latestByStudentAssignment.values()) {
|
||||
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
|
||||
const score = sub.score ?? 0
|
||||
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
|
||||
totalsByStudentId.set(sub.studentId, {
|
||||
score: prev.score + score,
|
||||
maxScore: prev.maxScore + maxScore,
|
||||
})
|
||||
}
|
||||
|
||||
const classUsers = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, classStudentIds))
|
||||
|
||||
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
|
||||
const myName = nameByStudentId.get(id) ?? "Student"
|
||||
|
||||
const ranked = classStudentIds
|
||||
.map((studentId) => {
|
||||
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
|
||||
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
|
||||
return { studentId, percentage, totals }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.percentage !== a.percentage) return b.percentage - a.percentage
|
||||
return a.studentId.localeCompare(b.studentId)
|
||||
})
|
||||
|
||||
const myIndex = ranked.findIndex((r) => r.studentId === id)
|
||||
if (myIndex < 0) return { trend, recent, ranking: null }
|
||||
|
||||
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
|
||||
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
|
||||
|
||||
const ranking: StudentRanking = {
|
||||
studentId: id,
|
||||
studentName: myName,
|
||||
rank: myIndex + 1,
|
||||
classSize,
|
||||
totalScore: myTotals.score,
|
||||
totalMaxScore: myTotals.maxScore,
|
||||
percentage: myPercentage,
|
||||
}
|
||||
|
||||
return { trend, recent, ranking }
|
||||
})
|
||||
// Re-export stats functions for backward compatibility
|
||||
// New code should import directly from "./stats-service"
|
||||
export {
|
||||
getTeacherGradeTrends,
|
||||
getHomeworkAssignmentAnalytics,
|
||||
getStudentDashboardGrades,
|
||||
getHomeworkDashboardStats,
|
||||
} from "./stats-service"
|
||||
export type { HomeworkDashboardStats } from "./stats-service"
|
||||
|
||||
483
src/modules/homework/stats-service.ts
Normal file
483
src/modules/homework/stats-service.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
classEnrollments,
|
||||
classes,
|
||||
exams,
|
||||
homeworkAssignmentQuestions,
|
||||
homeworkAssignmentTargets,
|
||||
homeworkAssignments,
|
||||
homeworkSubmissions,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import type {
|
||||
HomeworkAssignmentAnalytics,
|
||||
HomeworkAssignmentQuestionAnalytics,
|
||||
HomeworkAssignmentStatus,
|
||||
StudentDashboardGradeProps,
|
||||
StudentHomeworkScoreAnalytics,
|
||||
StudentRanking,
|
||||
TeacherGradeTrendItem,
|
||||
} from "./types"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
|
||||
|
||||
/**
|
||||
* Get grade trend data for a teacher's recent assignments.
|
||||
* Used by the teacher dashboard to visualize class performance over time.
|
||||
*/
|
||||
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
|
||||
const recentAssignments = await db.query.homeworkAssignments.findMany({
|
||||
where: and(
|
||||
eq(homeworkAssignments.creatorId, teacherId),
|
||||
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
|
||||
),
|
||||
orderBy: [desc(homeworkAssignments.createdAt)],
|
||||
limit: limit,
|
||||
})
|
||||
|
||||
if (recentAssignments.length === 0) return []
|
||||
|
||||
const assignmentIds = recentAssignments.map((a) => a.id)
|
||||
|
||||
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
|
||||
getAssignmentMaxScoreById(assignmentIds),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkAssignmentTargets.assignmentId,
|
||||
count: count(homeworkAssignmentTargets.studentId),
|
||||
})
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
|
||||
.groupBy(homeworkAssignmentTargets.assignmentId),
|
||||
db
|
||||
.select({
|
||||
assignmentId: homeworkSubmissions.assignmentId,
|
||||
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
|
||||
count: count(homeworkSubmissions.id),
|
||||
})
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
inArray(homeworkSubmissions.assignmentId, assignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
)
|
||||
)
|
||||
.groupBy(homeworkSubmissions.assignmentId),
|
||||
])
|
||||
|
||||
const targetCountMap = new Map<string, number>()
|
||||
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
||||
|
||||
const statsMap = new Map<string, { avg: number; count: number }>()
|
||||
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
|
||||
|
||||
return recentAssignments.map((a) => {
|
||||
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
|
||||
return {
|
||||
id: a.id,
|
||||
title: a.title,
|
||||
averageScore: stats.avg,
|
||||
maxScore: maxScoreMap.get(a.id) ?? 0,
|
||||
submissionCount: stats.count,
|
||||
totalStudents: targetCountMap.get(a.id) ?? 0,
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
}
|
||||
}).reverse() // Reverse to show trend from left (older) to right (newer)
|
||||
})
|
||||
|
||||
/**
|
||||
* Get detailed analytics for a specific homework assignment.
|
||||
* Includes per-question error rates and wrong answer samples.
|
||||
*/
|
||||
export const getHomeworkAssignmentAnalytics = cache(
|
||||
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
|
||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
||||
where: eq(homeworkAssignments.id, assignmentId),
|
||||
with: {
|
||||
sourceExam: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!assignment) return null
|
||||
|
||||
const [targetsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
|
||||
|
||||
const [submissionsRow] = await db
|
||||
.select({ c: count() })
|
||||
.from(homeworkSubmissions)
|
||||
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
|
||||
|
||||
const [submittedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(
|
||||
and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
||||
)
|
||||
)
|
||||
|
||||
const [gradedRow] = await db
|
||||
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
||||
.from(homeworkSubmissions)
|
||||
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
|
||||
|
||||
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
|
||||
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
|
||||
with: { question: true },
|
||||
orderBy: (q, { asc }) => [asc(q.order)],
|
||||
})
|
||||
|
||||
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
|
||||
|
||||
for (const aq of assignmentQuestions) {
|
||||
statsByQuestionId.set(aq.questionId, {
|
||||
questionId: aq.questionId,
|
||||
questionType: aq.question.type,
|
||||
questionContent: toQuestionContent(aq.question.content),
|
||||
maxScore: aq.score ?? 0,
|
||||
order: aq.order ?? 0,
|
||||
errorCount: 0,
|
||||
errorRate: 0,
|
||||
})
|
||||
}
|
||||
|
||||
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.assignmentId, assignmentId),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
with: {
|
||||
answers: true,
|
||||
student: true,
|
||||
},
|
||||
})
|
||||
|
||||
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
|
||||
for (const s of gradedSubmissionsAll) {
|
||||
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
|
||||
}
|
||||
const gradedSubmissions = Array.from(latestByStudentId.values())
|
||||
|
||||
const scoreBySubmissionQuestion = new Map<string, number>()
|
||||
const answerBySubmissionQuestion = new Map<string, unknown>()
|
||||
for (const sub of gradedSubmissions) {
|
||||
for (const ans of sub.answers) {
|
||||
const key = `${sub.id}|${ans.questionId}`
|
||||
if (scoreBySubmissionQuestion.has(key)) continue
|
||||
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
|
||||
const raw = ans.answerContent
|
||||
if (isRecord(raw) && "answer" in raw) {
|
||||
answerBySubmissionQuestion.set(key, raw.answer)
|
||||
} else {
|
||||
answerBySubmissionQuestion.set(key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const denom = gradedSubmissions.length
|
||||
if (denom > 0) {
|
||||
for (const q of statsByQuestionId.values()) {
|
||||
if (q.maxScore <= 0) continue
|
||||
let errors = 0
|
||||
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
|
||||
for (const sub of gradedSubmissions) {
|
||||
const key = `${sub.id}|${q.questionId}`
|
||||
const score = scoreBySubmissionQuestion.get(key) ?? 0
|
||||
if (score < q.maxScore) {
|
||||
errors += 1
|
||||
wrongAnswers.push({
|
||||
studentId: sub.studentId,
|
||||
studentName: sub.student.name || "Unknown",
|
||||
answerContent: answerBySubmissionQuestion.get(key),
|
||||
})
|
||||
}
|
||||
}
|
||||
q.errorCount = errors
|
||||
q.errorRate = errors / denom
|
||||
q.wrongAnswers = wrongAnswers.slice(0, 500)
|
||||
}
|
||||
}
|
||||
|
||||
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
|
||||
.sort((a, b) => a.order - b.order)
|
||||
|
||||
const analytics: HomeworkAssignmentAnalytics = {
|
||||
assignment: {
|
||||
id: assignment.id,
|
||||
title: assignment.title,
|
||||
description: assignment.description,
|
||||
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
|
||||
sourceExamId: assignment.sourceExamId,
|
||||
sourceExamTitle: assignment.sourceExam.title,
|
||||
structure: assignment.structure as unknown,
|
||||
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
|
||||
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
|
||||
allowLate: assignment.allowLate,
|
||||
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
|
||||
maxAttempts: assignment.maxAttempts,
|
||||
targetCount: targetsRow?.c ?? 0,
|
||||
submissionCount: submissionsRow?.c ?? 0,
|
||||
submittedCount: submittedRow?.c ?? 0,
|
||||
gradedCount: gradedRow?.c ?? 0,
|
||||
createdAt: assignment.createdAt.toISOString(),
|
||||
updatedAt: assignment.updatedAt.toISOString(),
|
||||
},
|
||||
gradedSampleCount: gradedSubmissions.length,
|
||||
questions,
|
||||
}
|
||||
|
||||
return analytics
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Get student dashboard grade data including trend, recent scores, and class ranking.
|
||||
* The ranking calculation queries all classmates' graded submissions and computes
|
||||
* relative position by total percentage score.
|
||||
*/
|
||||
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
|
||||
const id = studentId.trim()
|
||||
if (!id) return { trend: [], recent: [], ranking: null }
|
||||
|
||||
const targetAssignmentIdsRows = await db
|
||||
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(eq(homeworkAssignmentTargets.studentId, id))
|
||||
|
||||
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
|
||||
|
||||
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
eq(homeworkSubmissions.studentId, id),
|
||||
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
limit: 200,
|
||||
})
|
||||
|
||||
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
|
||||
for (const s of gradedSubmissions) {
|
||||
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
|
||||
}
|
||||
|
||||
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
|
||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||
return aTime - bTime
|
||||
})
|
||||
|
||||
const trendSubmissions = unique.slice(-10)
|
||||
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
|
||||
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
|
||||
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
|
||||
return bTime - aTime
|
||||
})
|
||||
|
||||
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
|
||||
const assignments = await db.query.homeworkAssignments.findMany({
|
||||
where: inArray(homeworkAssignments.id, assignmentIds),
|
||||
})
|
||||
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
|
||||
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
|
||||
|
||||
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
|
||||
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
|
||||
const score = s.score ?? 0
|
||||
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
|
||||
return {
|
||||
assignmentId: s.assignmentId,
|
||||
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
|
||||
score,
|
||||
maxScore,
|
||||
percentage,
|
||||
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const trend = trendSubmissions.map(toAnalytics)
|
||||
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
|
||||
|
||||
const enrollment = await db.query.classEnrollments.findFirst({
|
||||
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
|
||||
orderBy: (e, { asc }) => [asc(e.createdAt)],
|
||||
})
|
||||
|
||||
if (!enrollment) return { trend, recent, ranking: null }
|
||||
|
||||
const classStudents = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
|
||||
|
||||
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
|
||||
const classSize = classStudentIds.length
|
||||
if (classSize === 0) return { trend, recent, ranking: null }
|
||||
|
||||
const classAssignmentIdsRows = await db
|
||||
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
|
||||
.from(homeworkAssignmentTargets)
|
||||
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
|
||||
|
||||
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
|
||||
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
|
||||
|
||||
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
|
||||
|
||||
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
|
||||
where: and(
|
||||
inArray(homeworkSubmissions.studentId, classStudentIds),
|
||||
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
|
||||
eq(homeworkSubmissions.status, "graded")
|
||||
),
|
||||
orderBy: (s, { desc }) => [desc(s.updatedAt)],
|
||||
limit: 5000,
|
||||
})
|
||||
|
||||
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
|
||||
for (const s of classGradedSubmissions) {
|
||||
const key = `${s.studentId}|${s.assignmentId}`
|
||||
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
|
||||
}
|
||||
|
||||
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
|
||||
for (const sub of latestByStudentAssignment.values()) {
|
||||
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
|
||||
const score = sub.score ?? 0
|
||||
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
|
||||
totalsByStudentId.set(sub.studentId, {
|
||||
score: prev.score + score,
|
||||
maxScore: prev.maxScore + maxScore,
|
||||
})
|
||||
}
|
||||
|
||||
const classUsers = await db
|
||||
.select({ id: users.id, name: users.name })
|
||||
.from(users)
|
||||
.where(inArray(users.id, classStudentIds))
|
||||
|
||||
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
|
||||
const myName = nameByStudentId.get(id) ?? "Student"
|
||||
|
||||
const ranked = classStudentIds
|
||||
.map((studentId) => {
|
||||
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
|
||||
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
|
||||
return { studentId, percentage, totals }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (b.percentage !== a.percentage) return b.percentage - a.percentage
|
||||
return a.studentId.localeCompare(b.studentId)
|
||||
})
|
||||
|
||||
const myIndex = ranked.findIndex((r) => r.studentId === id)
|
||||
if (myIndex < 0) return { trend, recent, ranking: null }
|
||||
|
||||
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
|
||||
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
|
||||
|
||||
const ranking: StudentRanking = {
|
||||
studentId: id,
|
||||
studentName: myName,
|
||||
rank: myIndex + 1,
|
||||
classSize,
|
||||
totalScore: myTotals.score,
|
||||
totalMaxScore: myTotals.maxScore,
|
||||
percentage: myPercentage,
|
||||
}
|
||||
|
||||
return { trend, recent, ranking }
|
||||
})
|
||||
|
||||
export type HomeworkDashboardStats = {
|
||||
homeworkAssignmentCount: number
|
||||
homeworkAssignmentPublishedCount: number
|
||||
homeworkSubmissionCount: number
|
||||
homeworkSubmissionToGradeCount: number
|
||||
}
|
||||
|
||||
export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promise<HomeworkDashboardStats> => {
|
||||
const homeworkConditions = []
|
||||
const submissionConditions = []
|
||||
|
||||
if (scope && scope.type !== "all") {
|
||||
if (scope.type === "owned") {
|
||||
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
|
||||
const ownedAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(eq(homeworkAssignments.creatorId, scope.userId))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, scope.gradeIds))
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||
if (gradeIds.length > 0) {
|
||||
const gradeExamIds = db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, gradeIds))
|
||||
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
const gradeAssignmentIds = db
|
||||
.select({ id: homeworkAssignments.id })
|
||||
.from(homeworkAssignments)
|
||||
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
|
||||
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [
|
||||
homeworkAssignmentCountRow,
|
||||
homeworkAssignmentPublishedCountRow,
|
||||
homeworkSubmissionCountRow,
|
||||
homeworkSubmissionToGradeCountRow,
|
||||
] = await Promise.all([
|
||||
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
|
||||
db.select({ value: count() }).from(homeworkAssignments).where(
|
||||
homeworkConditions.length
|
||||
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
|
||||
: eq(homeworkAssignments.status, "published")
|
||||
),
|
||||
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
|
||||
db.select({ value: count() }).from(homeworkSubmissions).where(
|
||||
submissionConditions.length
|
||||
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
|
||||
: eq(homeworkSubmissions.status, "submitted")
|
||||
),
|
||||
])
|
||||
|
||||
return {
|
||||
homeworkAssignmentCount: Number(homeworkAssignmentCountRow[0]?.value ?? 0),
|
||||
homeworkAssignmentPublishedCount: Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0),
|
||||
homeworkSubmissionCount: Number(homeworkSubmissionCountRow[0]?.value ?? 0),
|
||||
homeworkSubmissionToGradeCount: Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0),
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user