- Report Card Generator: Professional report cards with PDF export - Subject grades table with competency levels - Competency radar chart, teacher comments - Template management, batch generation - Status workflow: draft → review → published → archived - Seating Chart View: Visual classroom layout - Grid-based classroom with desk positions - Multiple layout templates (rows, groups, U-shape, circle) - Click-to-assign student placement - Randomize and smart arrange features - Student info popover - School Library View: Book lending system - Book catalog with search and filter - Checkout system with due dates and renewals - Overdue tracking with notifications - Reservation queue system - Statistics dashboard with charts - Enhanced Parent Portal: Comprehensive dashboard - Child overview with multiple children selector - Academic progress with competency radar chart - Attendance & illness with approval - Communication, schedule, and reports tabs - Bug fixes: i18n breadcrumb keys for new views - Styling: Gradient headers, animations, hover effects across all new views - 200+ i18n keys added, 15+ API routes, 3+ Prisma models
701 lines
26 KiB
TypeScript
Executable File
701 lines
26 KiB
TypeScript
Executable File
import { NextResponse } from 'next/server';
|
|
import { db } from '@/lib/db';
|
|
import { getSession } from '@/lib/auth';
|
|
|
|
// Helper: get ISO week key (YYYY-Www) for a date (Monday-based, ISO-8601)
|
|
function isoWeekKey(date: Date): string {
|
|
const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
|
const day = d.getUTCDay() || 7; // make Sunday = 7
|
|
d.setUTCDate(d.getUTCDate() + 4 - day); // Thursday of this week
|
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
|
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
|
}
|
|
|
|
function startOfDate(d: Date): Date {
|
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
}
|
|
|
|
export async function GET(request: Request) {
|
|
try {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
|
}
|
|
|
|
const { searchParams } = new URL(request.url);
|
|
const classGroupId = searchParams.get('classGroupId');
|
|
const subjectId = searchParams.get('subjectId');
|
|
const schoolYearId = searchParams.get('schoolYearId');
|
|
const schoolId = searchParams.get('schoolId') ?? session.user?.schoolId ?? undefined;
|
|
const startDate = searchParams.get('startDate');
|
|
const endDate = searchParams.get('endDate');
|
|
|
|
// Build base where clause for LearningProgressEntry
|
|
const where: Record<string, unknown> = {};
|
|
|
|
if (classGroupId) {
|
|
where.classGroupId = classGroupId;
|
|
} else {
|
|
// Scope by school via classGroup relation
|
|
const classWhere: Record<string, unknown> = {};
|
|
if (schoolId) classWhere.schoolId = schoolId;
|
|
if (schoolYearId) classWhere.schoolYearId = schoolYearId;
|
|
where.classGroup = classWhere;
|
|
}
|
|
|
|
// Subject filter — via competency.category.competencyTemplate.subjectId
|
|
if (subjectId) {
|
|
where.competency = {
|
|
category: {
|
|
competencyTemplate: { subjectId },
|
|
},
|
|
};
|
|
}
|
|
|
|
// Date range filter
|
|
if (startDate || endDate) {
|
|
const dateFilter: Record<string, unknown> = {};
|
|
if (startDate) dateFilter.gte = new Date(startDate);
|
|
if (endDate) dateFilter.lte = new Date(endDate);
|
|
where.date = dateFilter;
|
|
}
|
|
|
|
// ── Fetch all matching entries with light relations ──
|
|
const entries = await db.learningProgressEntry.findMany({
|
|
where,
|
|
orderBy: { date: 'asc' },
|
|
select: {
|
|
id: true,
|
|
date: true,
|
|
masteryLevelValue: true,
|
|
studentId: true,
|
|
competencyId: true,
|
|
classGroupId: true,
|
|
competency: {
|
|
select: {
|
|
id: true,
|
|
code: true,
|
|
title: true,
|
|
category: { select: { id: true, name: true, color: true } },
|
|
},
|
|
},
|
|
classGroup: { select: { id: true, name: true, schoolId: true, schoolYearId: true } },
|
|
},
|
|
});
|
|
|
|
// ── Mastery Trend (weekly aggregation) ──
|
|
const weekMap = new Map<string, { total: number; count: number; dateMs: number }>();
|
|
for (const e of entries) {
|
|
const wk = isoWeekKey(new Date(e.date));
|
|
const cur = weekMap.get(wk) ?? { total: 0, count: 0, dateMs: 0 };
|
|
cur.total += e.masteryLevelValue;
|
|
cur.count += 1;
|
|
const d = new Date(e.date);
|
|
const ms = startOfDate(d).getTime();
|
|
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
|
|
weekMap.set(wk, cur);
|
|
}
|
|
const masteryTrend = Array.from(weekMap.entries())
|
|
.map(([week, v]) => ({
|
|
week,
|
|
date: new Date(v.dateMs).toISOString(),
|
|
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
|
count: v.count,
|
|
}))
|
|
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
|
|
|
// ── Class Comparison (avg per class for the subject) ──
|
|
const classAgg = new Map<string, { className: string; total: number; count: number; students: Set<string> }>();
|
|
for (const e of entries) {
|
|
const cid = e.classGroupId;
|
|
const className = e.classGroup?.name ?? cid;
|
|
const cur = classAgg.get(cid) ?? { className, total: 0, count: 0, students: new Set<string>() };
|
|
cur.total += e.masteryLevelValue;
|
|
cur.count += 1;
|
|
cur.students.add(e.studentId);
|
|
classAgg.set(cid, cur);
|
|
}
|
|
const classComparison = Array.from(classAgg.entries())
|
|
.map(([classId, v]) => ({
|
|
classId,
|
|
className: v.className,
|
|
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
|
studentCount: v.students.size,
|
|
}))
|
|
.sort((a, b) => b.avgMastery - a.avgMastery);
|
|
|
|
// ── Mastery Distribution (levels 1-4) ──
|
|
const distMap = new Map<number, number>([[1, 0], [2, 0], [3, 0], [4, 0]]);
|
|
for (const e of entries) {
|
|
const lvl = Math.min(4, Math.max(1, Math.floor(e.masteryLevelValue)));
|
|
distMap.set(lvl, (distMap.get(lvl) ?? 0) + 1);
|
|
}
|
|
const totalEntries = entries.length;
|
|
const masteryDistribution = Array.from(distMap.entries())
|
|
.map(([level, count]) => ({
|
|
level,
|
|
count,
|
|
percentage: totalEntries > 0 ? Math.round((count / totalEntries) * 1000) / 10 : 0,
|
|
}))
|
|
.sort((a, b) => a.level - b.level);
|
|
|
|
// ── Top/Bottom Competencies ──
|
|
const compAgg = new Map<string, { code: string; title: string; total: number; count: number }>();
|
|
for (const e of entries) {
|
|
const cid = e.competencyId;
|
|
const cur = compAgg.get(cid) ?? {
|
|
code: e.competency?.code ?? cid,
|
|
title: e.competency?.title ?? '',
|
|
total: 0,
|
|
count: 0,
|
|
};
|
|
cur.total += e.masteryLevelValue;
|
|
cur.count += 1;
|
|
compAgg.set(cid, cur);
|
|
}
|
|
const competencyArr = Array.from(compAgg.entries())
|
|
.map(([competencyId, v]) => ({
|
|
competencyId,
|
|
code: v.code,
|
|
title: v.title,
|
|
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
|
entryCount: v.count,
|
|
}))
|
|
.sort((a, b) => b.avgMastery - a.avgMastery);
|
|
|
|
const topCompetencies = competencyArr.slice(0, 5);
|
|
const bottomCompetencies = competencyArr
|
|
.slice()
|
|
.sort((a, b) => a.avgMastery - b.avgMastery)
|
|
.slice(0, 5);
|
|
|
|
// ── Activity Heatmap (last 90 days) ──
|
|
const now = new Date();
|
|
const ninetyAgo = new Date(now);
|
|
ninetyAgo.setDate(ninetyAgo.getDate() - 89);
|
|
const heatStart = startOfDate(ninetyAgo);
|
|
const heatEnd = startOfDate(now);
|
|
|
|
const heatMap = new Map<string, number>();
|
|
for (let d = new Date(heatStart); d.getTime() <= heatEnd.getTime(); d.setDate(d.getDate() + 1)) {
|
|
heatMap.set(d.toISOString().slice(0, 10), 0);
|
|
}
|
|
for (const e of entries) {
|
|
const ed = startOfDate(new Date(e.date));
|
|
if (ed.getTime() < heatStart.getTime() || ed.getTime() > heatEnd.getTime()) continue;
|
|
const key = ed.toISOString().slice(0, 10);
|
|
heatMap.set(key, (heatMap.get(key) ?? 0) + 1);
|
|
}
|
|
const activityHeatmap = Array.from(heatMap.entries())
|
|
.map(([date, count]) => ({ date, count }))
|
|
.sort((a, b) => a.date.localeCompare(b.date));
|
|
|
|
// ── Grade Trend by Week (with class-average line) ──
|
|
// For each ISO week: school avg mastery (entry-weighted), entry count,
|
|
// unique students, class-average (simple mean of per-class averages).
|
|
type TrendWeekAgg = {
|
|
total: number;
|
|
count: number;
|
|
students: Set<string>;
|
|
dateMs: number;
|
|
classAgg: Map<string, { total: number; count: number }>;
|
|
};
|
|
const trendWeekMap = new Map<string, TrendWeekAgg>();
|
|
for (const e of entries) {
|
|
const wk = isoWeekKey(new Date(e.date));
|
|
const cur = trendWeekMap.get(wk) ?? {
|
|
total: 0,
|
|
count: 0,
|
|
students: new Set<string>(),
|
|
dateMs: 0,
|
|
classAgg: new Map<string, { total: number; count: number }>(),
|
|
};
|
|
cur.total += e.masteryLevelValue;
|
|
cur.count += 1;
|
|
cur.students.add(e.studentId);
|
|
const ms = startOfDate(new Date(e.date)).getTime();
|
|
if (cur.dateMs === 0 || ms < cur.dateMs) cur.dateMs = ms;
|
|
const cid = e.classGroupId;
|
|
const ca = cur.classAgg.get(cid) ?? { total: 0, count: 0 };
|
|
ca.total += e.masteryLevelValue;
|
|
ca.count += 1;
|
|
cur.classAgg.set(cid, ca);
|
|
trendWeekMap.set(wk, cur);
|
|
}
|
|
const gradeTrend = Array.from(trendWeekMap.entries())
|
|
.map(([week, v]) => {
|
|
const classMeans: number[] = [];
|
|
for (const ca of v.classAgg.values()) {
|
|
if (ca.count > 0) classMeans.push(ca.total / ca.count);
|
|
}
|
|
const classAvg =
|
|
classMeans.length > 0
|
|
? Math.round((classMeans.reduce((s, m) => s + m, 0) / classMeans.length) * 100) / 100
|
|
: 0;
|
|
const weekNo = parseInt(week.split('-W')[1] ?? '0', 10);
|
|
return {
|
|
week,
|
|
weekLabel: `KW${weekNo}`,
|
|
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
|
entryCount: v.count,
|
|
uniqueStudents: v.students.size,
|
|
classAvg,
|
|
};
|
|
})
|
|
.sort((a, b) => a.week.localeCompare(b.week));
|
|
|
|
// ── At-Risk Students (Risk Analysis) ──
|
|
// Step 1: identify students in scope (via Enrollment → ClassGroup filter)
|
|
const enrollmentWhere: Record<string, unknown> = { endDate: null };
|
|
const enrClassWhere: Record<string, unknown> = {};
|
|
if (schoolId) enrClassWhere.schoolId = schoolId;
|
|
if (schoolYearId) enrClassWhere.schoolYearId = schoolYearId;
|
|
if (classGroupId) enrClassWhere.id = classGroupId;
|
|
if (Object.keys(enrClassWhere).length > 0) {
|
|
enrollmentWhere.classGroup = enrClassWhere;
|
|
}
|
|
|
|
let enrollments: Array<{
|
|
studentId: string;
|
|
classGroupId: string;
|
|
student: { id: string; firstName: string; lastName: string; deletedAt: Date | null } | null;
|
|
classGroup: { id: string; name: string } | null;
|
|
}> = [];
|
|
try {
|
|
enrollments = await db.enrollment.findMany({
|
|
where: enrollmentWhere,
|
|
select: {
|
|
studentId: true,
|
|
classGroupId: true,
|
|
student: { select: { id: true, firstName: true, lastName: true, deletedAt: true } },
|
|
classGroup: { select: { id: true, name: true } },
|
|
},
|
|
});
|
|
} catch {
|
|
enrollments = [];
|
|
}
|
|
|
|
// Build studentId -> { name, className } (latest enrollment wins)
|
|
const studentMeta = new Map<string, { studentName: string; className: string }>();
|
|
for (const enr of enrollments) {
|
|
if (!enr.student || enr.student.deletedAt) continue;
|
|
const name = `${enr.student.firstName ?? ''} ${enr.student.lastName ?? ''}`.trim();
|
|
studentMeta.set(enr.studentId, {
|
|
studentName: name || enr.studentId,
|
|
className: enr.classGroup?.name ?? '',
|
|
});
|
|
}
|
|
|
|
// Step 2: group entries by studentId (only entries in scope are already filtered)
|
|
const studentEntries = new Map<string, Array<{ date: Date; masteryLevelValue: number }>>();
|
|
for (const e of entries) {
|
|
const arr = studentEntries.get(e.studentId) ?? [];
|
|
arr.push({ date: new Date(e.date), masteryLevelValue: e.masteryLevelValue });
|
|
studentEntries.set(e.studentId, arr);
|
|
}
|
|
|
|
// Step 3: batch-fetch attendance records (wrapped in try/catch)
|
|
const attendanceMap = new Map<string, { present: number; total: number }>();
|
|
const studentIds = Array.from(studentMeta.keys());
|
|
if (studentIds.length > 0) {
|
|
try {
|
|
const attendanceRecords = await db.attendanceRecord.findMany({
|
|
where: { studentId: { in: studentIds } },
|
|
select: { studentId: true, status: true },
|
|
});
|
|
for (const r of attendanceRecords) {
|
|
const cur = attendanceMap.get(r.studentId) ?? { present: 0, total: 0 };
|
|
cur.total += 1;
|
|
if (r.status === 'PRESENT' || r.status === 'LATE') cur.present += 1;
|
|
attendanceMap.set(r.studentId, cur);
|
|
}
|
|
} catch {
|
|
// Attendance data may not exist — ignore
|
|
}
|
|
}
|
|
|
|
// Step 4: compute risk score & signals per student
|
|
const nowMs = Date.now();
|
|
const fourteenDaysMs = 14 * 86400000;
|
|
type RiskLevel = 'low' | 'medium' | 'high' | 'critical';
|
|
type RiskSignal = 'low_mastery' | 'no_recent_progress' | 'low_attendance' | 'declining';
|
|
type AtRiskEntry = {
|
|
studentId: string;
|
|
studentName: string;
|
|
className: string;
|
|
riskScore: number;
|
|
riskLevel: RiskLevel;
|
|
signals: RiskSignal[];
|
|
latestMastery: number;
|
|
latestEntryDate: string;
|
|
};
|
|
const atRiskList: AtRiskEntry[] = [];
|
|
|
|
for (const [studentId, meta] of studentMeta) {
|
|
const eArr = studentEntries.get(studentId) ?? [];
|
|
const signals: RiskSignal[] = [];
|
|
let riskScore = 0;
|
|
let avgMastery = 0;
|
|
let latestMastery = 0;
|
|
let latestEntryDate: string | null = null;
|
|
|
|
if (eArr.length > 0) {
|
|
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
avgMastery = sorted.reduce((s, e) => s + e.masteryLevelValue, 0) / sorted.length;
|
|
latestMastery = sorted[sorted.length - 1].masteryLevelValue;
|
|
latestEntryDate = sorted[sorted.length - 1].date.toISOString().slice(0, 10);
|
|
|
|
if (avgMastery < 2.0) {
|
|
signals.push('low_mastery');
|
|
riskScore += 30;
|
|
if (avgMastery < 1.5) {
|
|
riskScore += 20;
|
|
}
|
|
}
|
|
|
|
const latestMs = sorted[sorted.length - 1].date.getTime();
|
|
if (nowMs - latestMs > fourteenDaysMs) {
|
|
signals.push('no_recent_progress');
|
|
riskScore += 20;
|
|
}
|
|
|
|
// Declining trend: compare last 4 entries to previous 4
|
|
if (sorted.length >= 8) {
|
|
const prev4 = sorted.slice(-8, -4);
|
|
const last4 = sorted.slice(-4);
|
|
const prevAvg = prev4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
|
|
const lastAvg = last4.reduce((s, e) => s + e.masteryLevelValue, 0) / 4;
|
|
if (lastAvg < prevAvg) {
|
|
signals.push('declining');
|
|
riskScore += 15;
|
|
}
|
|
}
|
|
} else {
|
|
// No progress entries at all → counts as no recent progress
|
|
signals.push('no_recent_progress');
|
|
riskScore += 20;
|
|
}
|
|
|
|
// Attendance (data may be missing for some students)
|
|
const att = attendanceMap.get(studentId);
|
|
if (att && att.total > 0) {
|
|
const rate = (att.present / att.total) * 100;
|
|
if (rate < 80) {
|
|
signals.push('low_attendance');
|
|
riskScore += 15;
|
|
}
|
|
}
|
|
|
|
if (riskScore === 0) continue;
|
|
|
|
let riskLevel: RiskLevel;
|
|
if (riskScore >= 65) riskLevel = 'critical';
|
|
else if (riskScore >= 45) riskLevel = 'high';
|
|
else if (riskScore >= 25) riskLevel = 'medium';
|
|
else riskLevel = 'low';
|
|
|
|
atRiskList.push({
|
|
studentId,
|
|
studentName: meta.studentName,
|
|
className: meta.className,
|
|
riskScore: Math.min(100, riskScore),
|
|
riskLevel,
|
|
signals,
|
|
latestMastery: Math.round(latestMastery * 100) / 100,
|
|
latestEntryDate: latestEntryDate ?? new Date().toISOString().slice(0, 10),
|
|
});
|
|
}
|
|
|
|
atRiskList.sort((a, b) => b.riskScore - a.riskScore);
|
|
const atRiskStudents = atRiskList.slice(0, 10);
|
|
|
|
// ── Summary stats ──
|
|
const totalStudents = new Set(entries.map((e) => e.studentId)).size;
|
|
const overallAvgMastery =
|
|
totalEntries > 0
|
|
? Math.round(
|
|
(entries.reduce((sum, e) => sum + e.masteryLevelValue, 0) / totalEntries) * 100
|
|
) / 100
|
|
: 0;
|
|
|
|
// ── Advanced: School Overview counts ──
|
|
const classGroupWhere: Record<string, unknown> = {};
|
|
if (schoolId) classGroupWhere.schoolId = schoolId;
|
|
if (schoolYearId) classGroupWhere.schoolYearId = schoolYearId;
|
|
if (classGroupId) classGroupWhere.id = classGroupId;
|
|
|
|
const [totalClasses, totalTeachers, totalCompetencies] = await Promise.all([
|
|
db.classGroup.count({ where: classGroupWhere }).catch(() => 0),
|
|
db.classGroupTeacher.findMany({
|
|
where: Object.keys(classGroupWhere).length > 0
|
|
? { classGroup: classGroupWhere }
|
|
: {},
|
|
select: { userId: true },
|
|
}).then((rows) => new Set(rows.map((r) => r.userId)).size).catch(() => 0),
|
|
db.competency.count().catch(() => 0),
|
|
]);
|
|
|
|
// ── Advanced: Subject Mastery Avg ──
|
|
const subjectAgg = new Map<string, { subjectName: string; total: number; count: number }>();
|
|
for (const e of entries) {
|
|
const cat = e.competency?.category;
|
|
if (!cat) continue;
|
|
const sid = cat.id;
|
|
const cur = subjectAgg.get(sid) ?? { subjectName: cat.name, total: 0, count: 0 };
|
|
cur.total += e.masteryLevelValue;
|
|
cur.count += 1;
|
|
subjectAgg.set(sid, cur);
|
|
}
|
|
const subjectMasteryAvg = Array.from(subjectAgg.entries())
|
|
.map(([categoryId, v]) => ({
|
|
categoryId,
|
|
categoryName: v.subjectName,
|
|
avgMastery: v.count > 0 ? Math.round((v.total / v.count) * 100) / 100 : 0,
|
|
entryCount: v.count,
|
|
}))
|
|
.sort((a, b) => b.avgMastery - a.avgMastery);
|
|
|
|
// ── Advanced: Teacher Performance ──
|
|
const teacherAssessments = await db.assessment.findMany({
|
|
where: {
|
|
...(classGroupId ? { classGroupId } : {}),
|
|
...(schoolYearId ? { classGroup: { schoolYearId } } : {}),
|
|
...(schoolId ? { classGroup: { schoolId } } : {}),
|
|
},
|
|
select: {
|
|
teacherId: true,
|
|
teacher: { select: { firstName: true, lastName: true } },
|
|
},
|
|
}).catch(() => []);
|
|
|
|
const teacherProgressEntries = await db.learningProgressEntry.findMany({
|
|
where,
|
|
select: {
|
|
classGroupId: true,
|
|
classGroup: {
|
|
select: {
|
|
teachers: { select: { userId: true, user: { select: { firstName: true, lastName: true } } } },
|
|
},
|
|
},
|
|
},
|
|
}).catch(() => []);
|
|
|
|
// Build teacher stats
|
|
const teacherStatsMap = new Map<string, { name: string; assessmentCount: number; progressCount: number }>();
|
|
for (const a of teacherAssessments) {
|
|
const name = `${a.teacher?.firstName ?? ''} ${a.teacher?.lastName ?? ''}`.trim();
|
|
const cur = teacherStatsMap.get(a.teacherId) ?? { name: name || a.teacherId, assessmentCount: 0, progressCount: 0 };
|
|
cur.assessmentCount += 1;
|
|
teacherStatsMap.set(a.teacherId, cur);
|
|
}
|
|
for (const p of teacherProgressEntries) {
|
|
for (const t of p.classGroup?.teachers ?? []) {
|
|
const name = `${t.user?.firstName ?? ''} ${t.user?.lastName ?? ''}`.trim();
|
|
const cur = teacherStatsMap.get(t.userId) ?? { name: name || t.userId, assessmentCount: 0, progressCount: 0 };
|
|
cur.progressCount += 1;
|
|
teacherStatsMap.set(t.userId, cur);
|
|
}
|
|
}
|
|
|
|
// Compute student improvement rate per teacher
|
|
// Batch-fetch all class teachers for students in scope
|
|
const classGroupIds = new Set(enrollments.map((enr) => enr.classGroup?.id).filter(Boolean) as string[]);
|
|
const allClassTeachers = classGroupIds.size > 0
|
|
? await db.classGroupTeacher.findMany({
|
|
where: { classGroupId: { in: Array.from(classGroupIds) } },
|
|
select: { classGroupId: true, userId: true },
|
|
}).catch(() => [])
|
|
: [];
|
|
const classTeacherMap = new Map<string, string[]>();
|
|
for (const ct of allClassTeachers) {
|
|
const arr = classTeacherMap.get(ct.classGroupId) ?? [];
|
|
arr.push(ct.userId);
|
|
classTeacherMap.set(ct.classGroupId, arr);
|
|
}
|
|
|
|
const teacherImprovementMap = new Map<string, { improved: number; total: number }>();
|
|
for (const [studentId, eArr] of studentEntries) {
|
|
if (eArr.length < 2) continue;
|
|
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
const firstHalf = sorted.slice(0, Math.floor(sorted.length / 2));
|
|
const secondHalf = sorted.slice(Math.floor(sorted.length / 2));
|
|
const firstAvg = firstHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / firstHalf.length;
|
|
const secondAvg = secondHalf.reduce((s, e) => s + e.masteryLevelValue, 0) / secondHalf.length;
|
|
const improved = secondAvg > firstAvg;
|
|
|
|
// Find the student's class teacher(s) for attribution
|
|
const studentEnr = enrollments.find((enr) => enr.studentId === studentId);
|
|
const teacherIds = studentEnr?.classGroup?.id ? (classTeacherMap.get(studentEnr.classGroup.id) ?? []) : [];
|
|
for (const tid of teacherIds) {
|
|
const cur = teacherImprovementMap.get(tid) ?? { improved: 0, total: 0 };
|
|
cur.total += 1;
|
|
if (improved) cur.improved += 1;
|
|
teacherImprovementMap.set(tid, cur);
|
|
}
|
|
}
|
|
|
|
// Notebook usage per teacher
|
|
const teacherNotebookCounts = await db.notebook.groupBy({
|
|
by: ['ownerId'],
|
|
where: {
|
|
ownerType: 'TEACHER',
|
|
...(schoolId ? { schoolId } : {}),
|
|
deletedAt: null,
|
|
},
|
|
_count: { id: true },
|
|
}).catch(() => []);
|
|
|
|
const teacherPerformance = Array.from(teacherStatsMap.entries())
|
|
.map(([teacherId, v]) => {
|
|
const imp = teacherImprovementMap.get(teacherId);
|
|
const notebook = teacherNotebookCounts.find((n) => n.ownerId === teacherId);
|
|
return {
|
|
teacherId,
|
|
teacherName: v.name,
|
|
assessmentCount: v.assessmentCount,
|
|
progressCount: v.progressCount,
|
|
improvementRate: imp && imp.total > 0 ? Math.round((imp.improved / imp.total) * 1000) / 10 : 0,
|
|
notebookCount: notebook?._count.id ?? 0,
|
|
};
|
|
})
|
|
.sort((a, b) => b.assessmentCount - a.assessmentCount);
|
|
|
|
// ── Advanced: Self-Assessment vs Teacher Gap Analysis ──
|
|
const selfAssessmentData = await db.selfAssessment.findMany({
|
|
where: {
|
|
...(schoolId ? { schoolId } : {}),
|
|
...(classGroupId ? { classGroupId } : {}),
|
|
deletedAt: null,
|
|
},
|
|
select: {
|
|
studentId: true,
|
|
competencyId: true,
|
|
selfLevel: true,
|
|
createdAt: true,
|
|
student: { select: { firstName: true, lastName: true } },
|
|
},
|
|
}).catch(() => []);
|
|
|
|
// For each self-assessment, find the latest teacher progress entry for the same student+competency
|
|
const gapAnalysis: Array<{
|
|
studentId: string;
|
|
studentName: string;
|
|
competencyId: string;
|
|
selfLevel: number;
|
|
teacherLevel: number;
|
|
gap: number;
|
|
}> = [];
|
|
for (const sa of selfAssessmentData) {
|
|
const teacherEntries = studentEntries.get(sa.studentId)
|
|
?.filter((e) => e.masteryLevelValue > 0)
|
|
?? [];
|
|
if (teacherEntries.length === 0) continue;
|
|
const latestTeacher = teacherEntries.sort((a, b) => b.date.getTime() - a.date.getTime())[0];
|
|
const gap = sa.selfLevel - latestTeacher.masteryLevelValue;
|
|
gapAnalysis.push({
|
|
studentId: sa.studentId,
|
|
studentName: `${sa.student?.firstName ?? ''} ${sa.student?.lastName ?? ''}`.trim() || sa.studentId,
|
|
competencyId: sa.competencyId,
|
|
selfLevel: sa.selfLevel,
|
|
teacherLevel: Math.round(latestTeacher.masteryLevelValue * 100) / 100,
|
|
gap: Math.round(gap * 100) / 100,
|
|
});
|
|
}
|
|
|
|
// Aggregate gap analysis for chart
|
|
const selfVsTeacherAgg = gapAnalysis.length > 0 ? {
|
|
avgSelfLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.selfLevel, 0) / gapAnalysis.length) * 100) / 100,
|
|
avgTeacherLevel: Math.round((gapAnalysis.reduce((s, g) => s + g.teacherLevel, 0) / gapAnalysis.length) * 100) / 100,
|
|
avgGap: Math.round((gapAnalysis.reduce((s, g) => s + g.gap, 0) / gapAnalysis.length) * 100) / 100,
|
|
totalComparisons: gapAnalysis.length,
|
|
} : null;
|
|
|
|
// ── Advanced: Competency Coverage ──
|
|
const assessedCompetencyIds = new Set(entries.map((e) => e.competencyId));
|
|
const competencyCoveragePct = totalCompetencies > 0
|
|
? Math.round((assessedCompetencyIds.size / totalCompetencies) * 1000) / 10
|
|
: 0;
|
|
|
|
// ── Advanced: Students Excelling (improvement trajectory) ──
|
|
const excellingStudents: Array<{
|
|
studentId: string;
|
|
studentName: string;
|
|
className: string;
|
|
latestMastery: number;
|
|
improvement: number;
|
|
}> = [];
|
|
for (const [studentId, eArr] of studentEntries) {
|
|
if (eArr.length < 4) continue;
|
|
const sorted = eArr.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
const first2 = sorted.slice(0, 2);
|
|
const last2 = sorted.slice(-2);
|
|
const firstAvg = first2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2;
|
|
const lastAvg = last2.reduce((s, e) => s + e.masteryLevelValue, 0) / 2;
|
|
const improvement = Math.round((lastAvg - firstAvg) * 100) / 100;
|
|
if (improvement > 0.5) {
|
|
const meta = studentMeta.get(studentId);
|
|
if (meta) {
|
|
excellingStudents.push({
|
|
studentId,
|
|
studentName: meta.studentName,
|
|
className: meta.className,
|
|
latestMastery: Math.round(lastAvg * 100) / 100,
|
|
improvement,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
excellingStudents.sort((a, b) => b.improvement - a.improvement);
|
|
|
|
// ── Advanced: Class Radar Data ──
|
|
// For each class, compute multi-dimensional scores
|
|
const classRadarData = Array.from(classAgg.entries()).map(([classId, v]) => {
|
|
const classStudents = Array.from(v.students);
|
|
const classAttendance = classStudents.reduce((sum, sid) => {
|
|
const att = attendanceMap.get(sid);
|
|
return sum + (att && att.total > 0 ? (att.present / att.total) * 100 : 0);
|
|
}, 0) / (classStudents.length || 1);
|
|
const engagement = Math.min(100, (v.count / Math.max(1, v.students.size)) * 20);
|
|
const progress = v.count > 0 ? (v.total / v.count / 4) * 100 : 0;
|
|
return {
|
|
classId,
|
|
className: v.className,
|
|
mastery: Math.round((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 10) / 10,
|
|
attendance: Math.round(classAttendance * 10) / 10,
|
|
engagement: Math.round(engagement * 10) / 10,
|
|
progress: Math.round(progress * 10) / 10,
|
|
behavior: Math.round(((v.count > 0 ? (v.total / v.count / 4) * 100 : 0) * 0.6 + classAttendance * 0.4) * 10) / 10,
|
|
};
|
|
});
|
|
|
|
return NextResponse.json({
|
|
masteryTrend,
|
|
classComparison,
|
|
masteryDistribution,
|
|
topCompetencies,
|
|
bottomCompetencies,
|
|
activityHeatmap,
|
|
gradeTrend,
|
|
atRiskStudents,
|
|
totalEntries,
|
|
totalStudents,
|
|
overallAvgMastery,
|
|
// New fields
|
|
totalClasses,
|
|
totalTeachers,
|
|
totalCompetencies,
|
|
competencyCoveragePct,
|
|
subjectMasteryAvg,
|
|
teacherPerformance,
|
|
selfVsTeacherAgg,
|
|
gapAnalysis: gapAnalysis.slice(0, 50),
|
|
excellingStudents: excellingStudents.slice(0, 10),
|
|
classRadarData,
|
|
});
|
|
} catch (error) {
|
|
console.error('Analytics GET error:', error);
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|