@studious-lms/server 1.23.0 → 1.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +0 -1
- package/data/curriculum/nepal-cdc-g4-g5/README.md +90 -0
- package/data/curriculum/nepal-cdc-g4-g5/manifest.json +97 -0
- package/data/curriculum/nepal-cdc-g4-g5/proficiency-scale.json +76 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/english.json +6936 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/health-physical-creative-arts.json +3401 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/mathematics.json +1511 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/nepali.json +7928 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/science-technology.json +2420 -0
- package/data/curriculum/nepal-cdc-g4-g5/subjects/social-studies.json +2177 -0
- package/dist/lib/assignmentTypeDefaults.d.ts +41 -0
- package/dist/lib/assignmentTypeDefaults.d.ts.map +1 -0
- package/dist/lib/assignmentTypeDefaults.js +167 -0
- package/dist/lib/assignmentTypeDefaults.js.map +1 -0
- package/dist/lib/config/env.d.ts +0 -8
- package/dist/lib/config/env.d.ts.map +1 -1
- package/dist/lib/config/env.js +2 -5
- package/dist/lib/config/env.js.map +1 -1
- package/dist/lib/feePdf.d.ts +19 -0
- package/dist/lib/feePdf.d.ts.map +1 -0
- package/dist/lib/feePdf.js +167 -0
- package/dist/lib/feePdf.js.map +1 -0
- package/dist/lib/feePdfFormat.d.ts +4 -0
- package/dist/lib/feePdfFormat.d.ts.map +1 -0
- package/dist/lib/feePdfFormat.js +16 -0
- package/dist/lib/feePdfFormat.js.map +1 -0
- package/dist/lib/idCardPdf.d.ts +26 -0
- package/dist/lib/idCardPdf.d.ts.map +1 -0
- package/dist/lib/idCardPdf.js +272 -0
- package/dist/lib/idCardPdf.js.map +1 -0
- package/dist/lib/pdfLetterhead.d.ts +41 -0
- package/dist/lib/pdfLetterhead.d.ts.map +1 -0
- package/dist/lib/pdfLetterhead.js +86 -0
- package/dist/lib/pdfLetterhead.js.map +1 -0
- package/dist/lib/transcriptPdf.d.ts +55 -0
- package/dist/lib/transcriptPdf.d.ts.map +1 -0
- package/dist/lib/transcriptPdf.js +303 -0
- package/dist/lib/transcriptPdf.js.map +1 -0
- package/dist/models/academicCalendar.d.ts +198 -0
- package/dist/models/academicCalendar.d.ts.map +1 -0
- package/dist/models/academicCalendar.js +106 -0
- package/dist/models/academicCalendar.js.map +1 -0
- package/dist/models/agenda.d.ts +8 -0
- package/dist/models/agenda.d.ts.map +1 -1
- package/dist/models/announcement.d.ts +2 -0
- package/dist/models/announcement.d.ts.map +1 -1
- package/dist/models/assignment.d.ts +66 -0
- package/dist/models/assignment.d.ts.map +1 -1
- package/dist/models/attendance.d.ts +659 -21
- package/dist/models/attendance.d.ts.map +1 -1
- package/dist/models/attendance.js +544 -75
- package/dist/models/attendance.js.map +1 -1
- package/dist/models/auth.d.ts +2 -0
- package/dist/models/auth.d.ts.map +1 -1
- package/dist/models/class.d.ts +12 -0
- package/dist/models/class.d.ts.map +1 -1
- package/dist/models/class.js +3 -2
- package/dist/models/class.js.map +1 -1
- package/dist/models/event.d.ts +16 -0
- package/dist/models/event.d.ts.map +1 -1
- package/dist/models/fees.d.ts +554 -0
- package/dist/models/fees.d.ts.map +1 -0
- package/dist/models/fees.js +278 -0
- package/dist/models/fees.js.map +1 -0
- package/dist/models/file.d.ts +29 -0
- package/dist/models/file.d.ts.map +1 -1
- package/dist/models/folder.d.ts +2 -0
- package/dist/models/folder.d.ts.map +1 -1
- package/dist/models/labChat.d.ts +2 -0
- package/dist/models/labChat.d.ts.map +1 -1
- package/dist/models/marketplace.d.ts +178 -0
- package/dist/models/marketplace.d.ts.map +1 -0
- package/dist/models/marketplace.js +115 -0
- package/dist/models/marketplace.js.map +1 -0
- package/dist/models/moduleItem.d.ts +304 -0
- package/dist/models/moduleItem.d.ts.map +1 -0
- package/dist/models/moduleItem.js +78 -0
- package/dist/models/moduleItem.js.map +1 -0
- package/dist/models/newtonChat.d.ts +1 -0
- package/dist/models/newtonChat.d.ts.map +1 -1
- package/dist/models/school.d.ts +18 -0
- package/dist/models/school.d.ts.map +1 -1
- package/dist/models/school.js +7 -2
- package/dist/models/school.js.map +1 -1
- package/dist/models/section.d.ts +4 -0
- package/dist/models/section.d.ts.map +1 -1
- package/dist/models/worksheet.d.ts +19 -0
- package/dist/models/worksheet.d.ts.map +1 -1
- package/dist/models/worksheet.js +2 -2
- package/dist/models/worksheet.js.map +1 -1
- package/dist/routers/_app.d.ts +11341 -7097
- package/dist/routers/_app.d.ts.map +1 -1
- package/dist/routers/_app.js +12 -2
- package/dist/routers/_app.js.map +1 -1
- package/dist/routers/academicCalendar.d.ts +229 -0
- package/dist/routers/academicCalendar.d.ts.map +1 -0
- package/dist/routers/academicCalendar.js +75 -0
- package/dist/routers/academicCalendar.js.map +1 -0
- package/dist/routers/admin.d.ts +2 -0
- package/dist/routers/admin.d.ts.map +1 -1
- package/dist/routers/agenda.d.ts +8 -0
- package/dist/routers/agenda.d.ts.map +1 -1
- package/dist/routers/assignment.d.ts +75 -0
- package/dist/routers/assignment.d.ts.map +1 -1
- package/dist/routers/assignment.js +10 -2
- package/dist/routers/assignment.js.map +1 -1
- package/dist/routers/attendance.d.ts +103 -0
- package/dist/routers/attendance.d.ts.map +1 -1
- package/dist/routers/attendance.js +8 -5
- package/dist/routers/attendance.js.map +1 -1
- package/dist/routers/class.d.ts +27 -0
- package/dist/routers/class.d.ts.map +1 -1
- package/dist/routers/class.js +6 -2
- package/dist/routers/class.js.map +1 -1
- package/dist/routers/event.d.ts +8 -0
- package/dist/routers/event.d.ts.map +1 -1
- package/dist/routers/fees.d.ts +513 -0
- package/dist/routers/fees.d.ts.map +1 -0
- package/dist/routers/fees.js +88 -0
- package/dist/routers/fees.js.map +1 -0
- package/dist/routers/file.d.ts +2 -0
- package/dist/routers/file.d.ts.map +1 -1
- package/dist/routers/gradebook.d.ts +4 -1
- package/dist/routers/gradebook.d.ts.map +1 -1
- package/dist/routers/marketplace.d.ts +371 -0
- package/dist/routers/marketplace.d.ts.map +1 -0
- package/dist/routers/marketplace.js +87 -0
- package/dist/routers/marketplace.js.map +1 -0
- package/dist/routers/moduleItem.d.ts +263 -0
- package/dist/routers/moduleItem.d.ts.map +1 -0
- package/dist/routers/moduleItem.js +60 -0
- package/dist/routers/moduleItem.js.map +1 -0
- package/dist/routers/parent.d.ts +169 -0
- package/dist/routers/parent.d.ts.map +1 -0
- package/dist/routers/parent.js +49 -0
- package/dist/routers/parent.js.map +1 -0
- package/dist/routers/school.d.ts +363 -7
- package/dist/routers/school.d.ts.map +1 -1
- package/dist/routers/school.js +100 -3
- package/dist/routers/school.js.map +1 -1
- package/dist/routers/worksheet.d.ts +27 -0
- package/dist/routers/worksheet.d.ts.map +1 -1
- package/dist/routers/worksheet.js +10 -3
- package/dist/routers/worksheet.js.map +1 -1
- package/dist/services/academicCalendar.d.ts +173 -0
- package/dist/services/academicCalendar.d.ts.map +1 -0
- package/dist/services/academicCalendar.js +214 -0
- package/dist/services/academicCalendar.js.map +1 -0
- package/dist/services/adminInstitution.d.ts +2 -0
- package/dist/services/adminInstitution.d.ts.map +1 -1
- package/dist/services/agenda.d.ts +8 -0
- package/dist/services/agenda.d.ts.map +1 -1
- package/dist/services/assignment.d.ts +75 -0
- package/dist/services/assignment.d.ts.map +1 -1
- package/dist/services/assignment.js +91 -12
- package/dist/services/assignment.js.map +1 -1
- package/dist/services/attendance.d.ts +323 -10
- package/dist/services/attendance.d.ts.map +1 -1
- package/dist/services/attendance.js +385 -25
- package/dist/services/attendance.js.map +1 -1
- package/dist/services/attendanceMerge.d.ts +53 -0
- package/dist/services/attendanceMerge.d.ts.map +1 -0
- package/dist/services/attendanceMerge.js +87 -0
- package/dist/services/attendanceMerge.js.map +1 -0
- package/dist/services/attendanceRate.d.ts +24 -0
- package/dist/services/attendanceRate.d.ts.map +1 -0
- package/dist/services/attendanceRate.js +31 -0
- package/dist/services/attendanceRate.js.map +1 -0
- package/dist/services/attendanceStats.d.ts +22 -0
- package/dist/services/attendanceStats.d.ts.map +1 -0
- package/dist/services/attendanceStats.js +45 -0
- package/dist/services/attendanceStats.js.map +1 -0
- package/dist/services/class.d.ts +27 -0
- package/dist/services/class.d.ts.map +1 -1
- package/dist/services/class.js +26 -3
- package/dist/services/class.js.map +1 -1
- package/dist/services/event.d.ts +8 -0
- package/dist/services/event.d.ts.map +1 -1
- package/dist/services/fees.d.ts +422 -0
- package/dist/services/fees.d.ts.map +1 -0
- package/dist/services/fees.js +310 -0
- package/dist/services/fees.js.map +1 -0
- package/dist/services/feesAuthz.d.ts +49 -0
- package/dist/services/feesAuthz.d.ts.map +1 -0
- package/dist/services/feesAuthz.js +52 -0
- package/dist/services/feesAuthz.js.map +1 -0
- package/dist/services/file.d.ts +4 -0
- package/dist/services/file.d.ts.map +1 -1
- package/dist/services/gradebook.d.ts +18 -1
- package/dist/services/gradebook.d.ts.map +1 -1
- package/dist/services/gradebook.js +57 -2
- package/dist/services/gradebook.js.map +1 -1
- package/dist/services/idCard.d.ts +11 -0
- package/dist/services/idCard.d.ts.map +1 -0
- package/dist/services/idCard.js +122 -0
- package/dist/services/idCard.js.map +1 -0
- package/dist/services/marketplace.d.ts +301 -0
- package/dist/services/marketplace.d.ts.map +1 -0
- package/dist/services/marketplace.js +628 -0
- package/dist/services/marketplace.js.map +1 -0
- package/dist/services/moduleItem.d.ts +196 -0
- package/dist/services/moduleItem.d.ts.map +1 -0
- package/dist/services/moduleItem.js +188 -0
- package/dist/services/moduleItem.js.map +1 -0
- package/dist/services/notification.d.ts +58 -0
- package/dist/services/notification.d.ts.map +1 -1
- package/dist/services/notification.js +30 -2
- package/dist/services/notification.js.map +1 -1
- package/dist/services/parentPortal.d.ts +133 -0
- package/dist/services/parentPortal.d.ts.map +1 -0
- package/dist/services/parentPortal.js +328 -0
- package/dist/services/parentPortal.js.map +1 -0
- package/dist/services/school.d.ts +28 -3
- package/dist/services/school.d.ts.map +1 -1
- package/dist/services/school.js +65 -45
- package/dist/services/school.js.map +1 -1
- package/dist/services/studentImport.d.ts +34 -0
- package/dist/services/studentImport.d.ts.map +1 -0
- package/dist/services/studentImport.js +236 -0
- package/dist/services/studentImport.js.map +1 -0
- package/dist/services/transcript.d.ts +61 -0
- package/dist/services/transcript.d.ts.map +1 -0
- package/dist/services/transcript.js +243 -0
- package/dist/services/transcript.js.map +1 -0
- package/dist/services/worksheet.d.ts +22 -0
- package/dist/services/worksheet.d.ts.map +1 -1
- package/dist/services/worksheet.js +94 -2
- package/dist/services/worksheet.js.map +1 -1
- package/dist/trpc.d.ts +1 -1
- package/dist/trpc.d.ts.map +1 -1
- package/dist/trpc.js +5 -5
- package/dist/trpc.js.map +1 -1
- package/dist/utils/adminPanelAccess.d.ts +4 -5
- package/dist/utils/adminPanelAccess.d.ts.map +1 -1
- package/dist/utils/adminPanelAccess.js +12 -34
- package/dist/utils/adminPanelAccess.js.map +1 -1
- package/package.json +2 -1
- package/prisma/migrations/20260804120000_add_attendance_records_and_excusals/migration.sql +114 -0
- package/prisma/migrations/20260804120100_add_fee_plans_invoices_payments/migration.sql +101 -0
- package/prisma/migrations/20260804120200_add_marketplace_listings/migration.sql +54 -0
- package/prisma/migrations/20260804120300_content_platform_b3_b9/migration.sql +63 -0
- package/prisma/migrations/20260804120400_c_academic_ops/migration.sql +77 -0
- package/prisma/schema.prisma +371 -11
- package/scripts/import-nepal-cdc-curriculum.ts +379 -0
- package/src/lib/assignmentTypeDefaults.ts +222 -0
- package/src/lib/config/env.ts +0 -3
- package/src/lib/feePdf.ts +199 -0
- package/src/lib/feePdfFormat.ts +14 -0
- package/src/lib/idCardPdf.ts +336 -0
- package/src/lib/pdfLetterhead.ts +124 -0
- package/src/lib/transcriptPdf.ts +502 -0
- package/src/models/academicCalendar.ts +139 -0
- package/src/models/attendance.ts +704 -73
- package/src/models/class.ts +1 -0
- package/src/models/fees.ts +349 -0
- package/src/models/marketplace.ts +158 -0
- package/src/models/moduleItem.ts +107 -0
- package/src/models/school.ts +6 -0
- package/src/models/worksheet.ts +2 -0
- package/src/routers/_app.ts +10 -0
- package/src/routers/academicCalendar.ts +103 -0
- package/src/routers/assignment.ts +8 -0
- package/src/routers/attendance.ts +7 -3
- package/src/routers/class.ts +4 -0
- package/src/routers/fees.ts +126 -0
- package/src/routers/marketplace.ts +134 -0
- package/src/routers/moduleItem.ts +89 -0
- package/src/routers/parent.ts +68 -0
- package/src/routers/school.ts +139 -1
- package/src/routers/worksheet.ts +11 -0
- package/src/services/academicCalendar.ts +299 -0
- package/src/services/assignment.ts +122 -12
- package/src/services/attendance.ts +548 -26
- package/src/services/attendanceMerge.ts +139 -0
- package/src/services/attendanceRate.ts +41 -0
- package/src/services/attendanceStats.ts +63 -0
- package/src/services/class.ts +25 -1
- package/src/services/fees.ts +424 -0
- package/src/services/feesAuthz.ts +77 -0
- package/src/services/gradebook.ts +70 -0
- package/src/services/idCard.ts +147 -0
- package/src/services/marketplace.ts +785 -0
- package/src/services/moduleItem.ts +274 -0
- package/src/services/notification.ts +43 -0
- package/src/services/parentPortal.ts +380 -0
- package/src/services/school.ts +65 -44
- package/src/services/studentImport.ts +273 -0
- package/src/services/transcript.ts +313 -0
- package/src/services/worksheet.ts +111 -0
- package/src/trpc.ts +3 -3
- package/src/utils/adminPanelAccess.ts +12 -35
- package/tests/unit/assignmentTypeDefaults.test.ts +52 -0
- package/tests/unit/lib/feePdf.test.ts +15 -0
- package/tests/unit/lib/idCardPdf.test.ts +40 -0
- package/tests/unit/lib/transcriptPdf.test.ts +111 -0
- package/tests/unit/services/attendanceMerge.test.ts +90 -0
- package/tests/unit/services/attendanceRate.test.ts +38 -0
- package/tests/unit/services/attendanceStats.test.ts +54 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Attendance service –
|
|
3
|
-
*
|
|
2
|
+
* Attendance service – class attendance + school-admin excusals / stats.
|
|
3
|
+
* Dual-writes legacy present/late/absent lists and per-student AttendanceRecord.
|
|
4
|
+
* Active StudentExcusal rows force EXCUSED (locked for teachers).
|
|
4
5
|
*/
|
|
5
6
|
import { TRPCError } from "@trpc/server";
|
|
6
7
|
import {
|
|
@@ -13,9 +14,52 @@ import {
|
|
|
13
14
|
createAttendance,
|
|
14
15
|
createAttendanceForEvent,
|
|
15
16
|
updateAttendance,
|
|
17
|
+
findActiveExcusalsForStudents,
|
|
18
|
+
listExcusalsForSchool,
|
|
19
|
+
findExcusalById,
|
|
20
|
+
createStudentExcusal,
|
|
21
|
+
updateStudentExcusal,
|
|
22
|
+
revokeStudentExcusal,
|
|
23
|
+
applyExcusalToAttendance,
|
|
24
|
+
clearExcusalLocks,
|
|
25
|
+
listSchoolStudentMemberships,
|
|
26
|
+
listAttendanceRecordsForSchoolStudents,
|
|
27
|
+
listTodaySchoolAttendanceSessions,
|
|
28
|
+
toDateOnly,
|
|
29
|
+
startOfUtcDay,
|
|
30
|
+
endOfUtcDay,
|
|
16
31
|
} from "../models/attendance.js";
|
|
32
|
+
import { findActiveSchoolAdminMembership } from "../models/school.js";
|
|
17
33
|
import { sendToMultiple } from "./notification.js";
|
|
18
34
|
import { logger } from "../utils/logger.js";
|
|
35
|
+
import {
|
|
36
|
+
buildExcusalLocks,
|
|
37
|
+
mergeAttendanceUpdateWithLocks,
|
|
38
|
+
type StudentRef,
|
|
39
|
+
} from "./attendanceMerge.js";
|
|
40
|
+
import {
|
|
41
|
+
computeAttendancePercentage,
|
|
42
|
+
emptyAttendanceCounts,
|
|
43
|
+
tallyAttendanceStatuses,
|
|
44
|
+
} from "./attendanceStats.js";
|
|
45
|
+
|
|
46
|
+
async function requireSchoolAdmin(schoolId: string, userId: string) {
|
|
47
|
+
const membership = await findActiveSchoolAdminMembership(schoolId, userId);
|
|
48
|
+
if (!membership) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "FORBIDDEN",
|
|
51
|
+
message: "Only school admins can perform this action",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return membership;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionDate(attendance: {
|
|
58
|
+
date: Date;
|
|
59
|
+
event?: { startTime: Date } | null;
|
|
60
|
+
}): Date {
|
|
61
|
+
return attendance.event?.startTime ?? attendance.date;
|
|
62
|
+
}
|
|
19
63
|
|
|
20
64
|
/** Get attendance for a class (optionally filtered by event). Creates missing records. */
|
|
21
65
|
export async function getAttendance(classId: string, eventId?: string) {
|
|
@@ -33,26 +77,127 @@ export async function getAttendance(classId: string, eventId?: string) {
|
|
|
33
77
|
for (const event of events) {
|
|
34
78
|
const existing = await findAttendanceByEventId(event.id);
|
|
35
79
|
if (!existing) {
|
|
80
|
+
const locks =
|
|
81
|
+
classData.schoolId && studentIds.length > 0
|
|
82
|
+
? buildExcusalLocks(
|
|
83
|
+
await findActiveExcusalsForStudents({
|
|
84
|
+
schoolId: classData.schoolId,
|
|
85
|
+
studentIds,
|
|
86
|
+
date: event.startTime,
|
|
87
|
+
}),
|
|
88
|
+
studentIds
|
|
89
|
+
)
|
|
90
|
+
: [];
|
|
91
|
+
|
|
92
|
+
const merged = mergeAttendanceUpdateWithLocks({
|
|
93
|
+
present: classData.students.map((s) => ({
|
|
94
|
+
id: s.id,
|
|
95
|
+
username: s.username,
|
|
96
|
+
})),
|
|
97
|
+
late: [],
|
|
98
|
+
absent: [],
|
|
99
|
+
locks,
|
|
100
|
+
});
|
|
101
|
+
|
|
36
102
|
await createAttendanceForEvent({
|
|
37
103
|
classId,
|
|
38
104
|
eventId: event.id,
|
|
39
|
-
|
|
105
|
+
date: event.startTime,
|
|
106
|
+
presentIds: merged.presentIds,
|
|
107
|
+
lateIds: merged.lateIds,
|
|
108
|
+
absentIds: merged.absentIds,
|
|
109
|
+
excusedIds: merged.excusedIds,
|
|
110
|
+
records: merged.records,
|
|
111
|
+
});
|
|
112
|
+
} else if (classData.schoolId && studentIds.length > 0) {
|
|
113
|
+
// Ensure active excusals are reflected on existing sessions.
|
|
114
|
+
const locks = buildExcusalLocks(
|
|
115
|
+
await findActiveExcusalsForStudents({
|
|
116
|
+
schoolId: classData.schoolId,
|
|
117
|
+
studentIds,
|
|
118
|
+
date: event.startTime,
|
|
119
|
+
}),
|
|
120
|
+
studentIds
|
|
121
|
+
);
|
|
122
|
+
const needsLock = locks.filter((lock) => {
|
|
123
|
+
const current = existing.records.find((r) => r.studentId === lock.studentId);
|
|
124
|
+
return !(
|
|
125
|
+
current?.status === "EXCUSED" &&
|
|
126
|
+
current.excusedByExcusalId === lock.excusalId
|
|
127
|
+
);
|
|
40
128
|
});
|
|
129
|
+
const byExcusal = new Map<string, string[]>();
|
|
130
|
+
for (const lock of needsLock) {
|
|
131
|
+
const list = byExcusal.get(lock.excusalId) ?? [];
|
|
132
|
+
list.push(lock.studentId);
|
|
133
|
+
byExcusal.set(lock.excusalId, list);
|
|
134
|
+
}
|
|
135
|
+
for (const [excusalId, lockedStudentIds] of byExcusal) {
|
|
136
|
+
await applyExcusalToAttendance({
|
|
137
|
+
schoolId: classData.schoolId,
|
|
138
|
+
excusalId,
|
|
139
|
+
studentIds: lockedStudentIds,
|
|
140
|
+
startDate: event.startTime,
|
|
141
|
+
endDate: event.startTime,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
41
144
|
}
|
|
42
145
|
}
|
|
43
146
|
|
|
44
|
-
|
|
147
|
+
const sessions = await findManyAttendance(classId, eventId);
|
|
148
|
+
return sessions.map(shapeAttendanceSession);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Additive shape: keep present/late/absent; expose excused + lock metadata for UI. */
|
|
152
|
+
function shapeAttendanceSession<
|
|
153
|
+
T extends {
|
|
154
|
+
records: Array<{
|
|
155
|
+
id: string;
|
|
156
|
+
studentId: string;
|
|
157
|
+
status: string;
|
|
158
|
+
note: string | null;
|
|
159
|
+
excusedByExcusalId: string | null;
|
|
160
|
+
updatedById: string | null;
|
|
161
|
+
updatedAt: Date;
|
|
162
|
+
student: { id: string; username: string };
|
|
163
|
+
excusedByExcusal: {
|
|
164
|
+
id: string;
|
|
165
|
+
reason: string;
|
|
166
|
+
note: string | null;
|
|
167
|
+
} | null;
|
|
168
|
+
}>;
|
|
169
|
+
present: unknown[];
|
|
170
|
+
late: unknown[];
|
|
171
|
+
absent: unknown[];
|
|
172
|
+
},
|
|
173
|
+
>(session: T) {
|
|
174
|
+
const records = session.records.map((r) => ({
|
|
175
|
+
...r,
|
|
176
|
+
lockedFromExcusal: Boolean(r.excusedByExcusalId),
|
|
177
|
+
reason: r.excusedByExcusal?.reason ?? null,
|
|
178
|
+
excusalNote: r.excusedByExcusal?.note ?? null,
|
|
179
|
+
}));
|
|
180
|
+
const excused = records
|
|
181
|
+
.filter((r) => r.status === "EXCUSED")
|
|
182
|
+
.map((r) => r.student);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
...session,
|
|
186
|
+
excused,
|
|
187
|
+
records,
|
|
188
|
+
};
|
|
45
189
|
}
|
|
46
190
|
|
|
47
|
-
/** Update attendance record (present/late/absent). Teacher-only. */
|
|
191
|
+
/** Update attendance record (present/late/absent/excused). Teacher-only. */
|
|
48
192
|
export async function updateAttendanceRecord(
|
|
49
193
|
userId: string,
|
|
50
194
|
classId: string,
|
|
51
195
|
eventId: string | null | undefined,
|
|
52
196
|
attendance: {
|
|
53
|
-
present:
|
|
54
|
-
late:
|
|
55
|
-
absent:
|
|
197
|
+
present: StudentRef[];
|
|
198
|
+
late: StudentRef[];
|
|
199
|
+
absent: StudentRef[];
|
|
200
|
+
excused?: StudentRef[];
|
|
56
201
|
}
|
|
57
202
|
) {
|
|
58
203
|
const classData = await findClassWithTeacher(classId, userId);
|
|
@@ -64,55 +209,429 @@ export async function updateAttendanceRecord(
|
|
|
64
209
|
}
|
|
65
210
|
|
|
66
211
|
const existing = await findAttendanceByClassAndEvent(classId, eventId ?? null);
|
|
212
|
+
const dateForExcusal = existing
|
|
213
|
+
? sessionDate(existing)
|
|
214
|
+
: new Date();
|
|
215
|
+
|
|
216
|
+
const classStudentIds = classData.students.map((s) => s.id);
|
|
217
|
+
const locks =
|
|
218
|
+
classData.schoolId && classStudentIds.length > 0
|
|
219
|
+
? buildExcusalLocks(
|
|
220
|
+
await findActiveExcusalsForStudents({
|
|
221
|
+
schoolId: classData.schoolId,
|
|
222
|
+
studentIds: classStudentIds,
|
|
223
|
+
date: dateForExcusal,
|
|
224
|
+
}),
|
|
225
|
+
classStudentIds
|
|
226
|
+
)
|
|
227
|
+
: [];
|
|
228
|
+
|
|
229
|
+
const existingNotes = new Map(
|
|
230
|
+
(existing?.records ?? []).map((r) => [r.studentId, r.note])
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
const merged = mergeAttendanceUpdateWithLocks({
|
|
234
|
+
present: attendance.present,
|
|
235
|
+
late: attendance.late,
|
|
236
|
+
absent: attendance.absent,
|
|
237
|
+
excused: attendance.excused,
|
|
238
|
+
locks,
|
|
239
|
+
updatedById: userId,
|
|
240
|
+
existingNotesByStudentId: existingNotes,
|
|
241
|
+
});
|
|
67
242
|
|
|
68
|
-
const presentIds = attendance.present.map((s) => s.id);
|
|
69
|
-
const lateIds = attendance.late.map((s) => s.id);
|
|
70
|
-
const absentIds = attendance.absent.map((s) => s.id);
|
|
71
243
|
const previousLateIds = new Set(existing?.late.map((student) => student.id) ?? []);
|
|
72
|
-
const previousAbsentIds = new Set(
|
|
73
|
-
|
|
244
|
+
const previousAbsentIds = new Set(
|
|
245
|
+
existing?.absent.map((student) => student.id) ?? []
|
|
246
|
+
);
|
|
247
|
+
const classStudentIdSet = new Set(classStudentIds);
|
|
74
248
|
|
|
75
249
|
if (!existing) {
|
|
76
250
|
const created = await createAttendance({
|
|
77
251
|
classId,
|
|
78
252
|
eventId: eventId ?? null,
|
|
79
|
-
presentIds,
|
|
80
|
-
lateIds,
|
|
81
|
-
absentIds,
|
|
253
|
+
presentIds: merged.presentIds,
|
|
254
|
+
lateIds: merged.lateIds,
|
|
255
|
+
absentIds: merged.absentIds,
|
|
256
|
+
excusedIds: merged.excusedIds,
|
|
257
|
+
records: merged.records,
|
|
258
|
+
updatedById: userId,
|
|
82
259
|
});
|
|
83
260
|
await notifyMarkedLateOrAbsent({
|
|
84
261
|
classId,
|
|
85
262
|
className: classData.name,
|
|
86
263
|
eventId: eventId ?? null,
|
|
87
264
|
eventName: created.event?.name ?? null,
|
|
88
|
-
classStudentIds,
|
|
89
|
-
lateIds,
|
|
90
|
-
absentIds,
|
|
265
|
+
classStudentIds: classStudentIdSet,
|
|
266
|
+
lateIds: merged.lateIds,
|
|
267
|
+
absentIds: merged.absentIds,
|
|
91
268
|
previousLateIds,
|
|
92
269
|
previousAbsentIds,
|
|
93
270
|
});
|
|
94
|
-
return created;
|
|
271
|
+
return shapeAttendanceSession(created);
|
|
95
272
|
}
|
|
96
273
|
|
|
97
274
|
const updated = await updateAttendance(existing.id, {
|
|
98
|
-
presentIds,
|
|
99
|
-
lateIds,
|
|
100
|
-
absentIds,
|
|
275
|
+
presentIds: merged.presentIds,
|
|
276
|
+
lateIds: merged.lateIds,
|
|
277
|
+
absentIds: merged.absentIds,
|
|
278
|
+
records: merged.records,
|
|
101
279
|
});
|
|
102
280
|
await notifyMarkedLateOrAbsent({
|
|
103
281
|
classId,
|
|
104
282
|
className: classData.name,
|
|
105
283
|
eventId: eventId ?? null,
|
|
106
284
|
eventName: existing.event?.name ?? null,
|
|
107
|
-
classStudentIds,
|
|
108
|
-
lateIds,
|
|
109
|
-
absentIds,
|
|
285
|
+
classStudentIds: classStudentIdSet,
|
|
286
|
+
lateIds: merged.lateIds,
|
|
287
|
+
absentIds: merged.absentIds,
|
|
110
288
|
previousLateIds,
|
|
111
289
|
previousAbsentIds,
|
|
112
290
|
});
|
|
291
|
+
return shapeAttendanceSession(updated);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** School-admin: today's attendance rollup across classes. */
|
|
295
|
+
export async function listTodayAttendanceSummary(
|
|
296
|
+
userId: string,
|
|
297
|
+
schoolId: string
|
|
298
|
+
) {
|
|
299
|
+
await requireSchoolAdmin(schoolId, userId);
|
|
300
|
+
|
|
301
|
+
const today = new Date();
|
|
302
|
+
const sessions = await listTodaySchoolAttendanceSessions(schoolId, today);
|
|
303
|
+
const memberships = await listSchoolStudentMemberships(schoolId);
|
|
304
|
+
const totalStudents = memberships.length;
|
|
305
|
+
|
|
306
|
+
const counts = emptyAttendanceCounts();
|
|
307
|
+
const rows: Array<{
|
|
308
|
+
attendanceId: string;
|
|
309
|
+
classId: string;
|
|
310
|
+
className: string;
|
|
311
|
+
section: string;
|
|
312
|
+
subject: string;
|
|
313
|
+
eventId: string | null;
|
|
314
|
+
eventName: string | null;
|
|
315
|
+
date: Date;
|
|
316
|
+
present: number;
|
|
317
|
+
late: number;
|
|
318
|
+
absent: number;
|
|
319
|
+
excused: number;
|
|
320
|
+
unmarked: number;
|
|
321
|
+
classStudentCount: number;
|
|
322
|
+
}> = [];
|
|
323
|
+
|
|
324
|
+
for (const session of sessions) {
|
|
325
|
+
const tallied = tallyAttendanceStatuses(session.records.map((r) => r.status));
|
|
326
|
+
counts.present += tallied.present;
|
|
327
|
+
counts.late += tallied.late;
|
|
328
|
+
counts.absent += tallied.absent;
|
|
329
|
+
counts.excused += tallied.excused;
|
|
330
|
+
const classStudentCount = session.class._count.students;
|
|
331
|
+
const marked =
|
|
332
|
+
tallied.present + tallied.late + tallied.absent + tallied.excused;
|
|
333
|
+
rows.push({
|
|
334
|
+
attendanceId: session.id,
|
|
335
|
+
classId: session.classId,
|
|
336
|
+
className: session.class.name,
|
|
337
|
+
section: session.class.section,
|
|
338
|
+
subject: session.class.subject,
|
|
339
|
+
eventId: session.eventId,
|
|
340
|
+
eventName: session.event?.name ?? null,
|
|
341
|
+
date: session.event?.startTime ?? session.date,
|
|
342
|
+
present: tallied.present,
|
|
343
|
+
late: tallied.late,
|
|
344
|
+
absent: tallied.absent,
|
|
345
|
+
excused: tallied.excused,
|
|
346
|
+
unmarked: Math.max(0, classStudentCount - marked),
|
|
347
|
+
classStudentCount,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
date: toDateOnly(today),
|
|
353
|
+
totalStudents,
|
|
354
|
+
totals: counts,
|
|
355
|
+
percentage: computeAttendancePercentage(counts),
|
|
356
|
+
sessions: rows,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** School-admin: per-student attendance counts + % (excused excluded from %). */
|
|
361
|
+
export async function listStudentAttendanceStats(
|
|
362
|
+
userId: string,
|
|
363
|
+
input: {
|
|
364
|
+
schoolId: string;
|
|
365
|
+
from?: Date;
|
|
366
|
+
to?: Date;
|
|
367
|
+
studentId?: string;
|
|
368
|
+
}
|
|
369
|
+
) {
|
|
370
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
371
|
+
|
|
372
|
+
const memberships = await listSchoolStudentMemberships(input.schoolId);
|
|
373
|
+
const filtered = input.studentId
|
|
374
|
+
? memberships.filter((m) => m.userId === input.studentId)
|
|
375
|
+
: memberships;
|
|
376
|
+
const studentIds = filtered.map((m) => m.userId);
|
|
377
|
+
|
|
378
|
+
const records = await listAttendanceRecordsForSchoolStudents({
|
|
379
|
+
schoolId: input.schoolId,
|
|
380
|
+
studentIds,
|
|
381
|
+
from: input.from,
|
|
382
|
+
to: input.to,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
const byStudent = new Map<string, ReturnType<typeof emptyAttendanceCounts>>();
|
|
386
|
+
for (const id of studentIds) {
|
|
387
|
+
byStudent.set(id, emptyAttendanceCounts());
|
|
388
|
+
}
|
|
389
|
+
for (const record of records) {
|
|
390
|
+
const counts = byStudent.get(record.studentId);
|
|
391
|
+
if (!counts) continue;
|
|
392
|
+
switch (record.status) {
|
|
393
|
+
case "PRESENT":
|
|
394
|
+
counts.present += 1;
|
|
395
|
+
break;
|
|
396
|
+
case "LATE":
|
|
397
|
+
counts.late += 1;
|
|
398
|
+
break;
|
|
399
|
+
case "ABSENT":
|
|
400
|
+
counts.absent += 1;
|
|
401
|
+
break;
|
|
402
|
+
case "EXCUSED":
|
|
403
|
+
counts.excused += 1;
|
|
404
|
+
break;
|
|
405
|
+
default:
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return filtered.map((membership) => {
|
|
411
|
+
const counts = byStudent.get(membership.userId) ?? emptyAttendanceCounts();
|
|
412
|
+
return {
|
|
413
|
+
studentId: membership.userId,
|
|
414
|
+
username: membership.user.username,
|
|
415
|
+
displayName: membership.user.profile?.displayName ?? null,
|
|
416
|
+
profilePicture: membership.user.profile?.profilePicture ?? null,
|
|
417
|
+
profilePictureThumbnail:
|
|
418
|
+
membership.user.profile?.profilePictureThumbnail ?? null,
|
|
419
|
+
classes: membership.user.studentIn,
|
|
420
|
+
present: counts.present,
|
|
421
|
+
late: counts.late,
|
|
422
|
+
absent: counts.absent,
|
|
423
|
+
excused: counts.excused,
|
|
424
|
+
attendancePercentage: computeAttendancePercentage(counts),
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export async function listExcusals(
|
|
430
|
+
userId: string,
|
|
431
|
+
input: {
|
|
432
|
+
schoolId: string;
|
|
433
|
+
includeRevoked?: boolean;
|
|
434
|
+
studentId?: string;
|
|
435
|
+
}
|
|
436
|
+
) {
|
|
437
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
438
|
+
|
|
439
|
+
const excusals = await listExcusalsForSchool(input);
|
|
440
|
+
const now = toDateOnly(new Date());
|
|
441
|
+
|
|
442
|
+
return excusals.map((excusal) => {
|
|
443
|
+
let status: "ACTIVE" | "UPCOMING" | "REVOKED" = "ACTIVE";
|
|
444
|
+
if (excusal.revokedAt) {
|
|
445
|
+
status = "REVOKED";
|
|
446
|
+
} else if (toDateOnly(excusal.startDate) > now) {
|
|
447
|
+
status = "UPCOMING";
|
|
448
|
+
} else if (toDateOnly(excusal.endDate) < now) {
|
|
449
|
+
status = "ACTIVE"; // past but not revoked — still listed as ACTIVE historically
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
id: excusal.id,
|
|
454
|
+
schoolId: excusal.schoolId,
|
|
455
|
+
startDate: excusal.startDate,
|
|
456
|
+
endDate: excusal.endDate,
|
|
457
|
+
reason: excusal.reason,
|
|
458
|
+
note: excusal.note,
|
|
459
|
+
revokedAt: excusal.revokedAt,
|
|
460
|
+
createdAt: excusal.createdAt,
|
|
461
|
+
updatedAt: excusal.updatedAt,
|
|
462
|
+
status,
|
|
463
|
+
studentIds: excusal.students.map((s) => s.studentId),
|
|
464
|
+
students: excusal.students.map((s) => ({
|
|
465
|
+
id: s.student.id,
|
|
466
|
+
username: s.student.username,
|
|
467
|
+
displayName: s.student.profile?.displayName ?? null,
|
|
468
|
+
profilePicture: s.student.profile?.profilePicture ?? null,
|
|
469
|
+
profilePictureThumbnail:
|
|
470
|
+
s.student.profile?.profilePictureThumbnail ?? null,
|
|
471
|
+
})),
|
|
472
|
+
createdBy: {
|
|
473
|
+
id: excusal.createdBy.id,
|
|
474
|
+
username: excusal.createdBy.username,
|
|
475
|
+
displayName: excusal.createdBy.profile?.displayName ?? null,
|
|
476
|
+
},
|
|
477
|
+
revokedBy: excusal.revokedBy
|
|
478
|
+
? {
|
|
479
|
+
id: excusal.revokedBy.id,
|
|
480
|
+
username: excusal.revokedBy.username,
|
|
481
|
+
displayName: excusal.revokedBy.profile?.displayName ?? null,
|
|
482
|
+
}
|
|
483
|
+
: null,
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export async function createExcusal(
|
|
489
|
+
userId: string,
|
|
490
|
+
input: {
|
|
491
|
+
schoolId: string;
|
|
492
|
+
studentIds: string[];
|
|
493
|
+
startDate: Date;
|
|
494
|
+
endDate: Date;
|
|
495
|
+
reason: string;
|
|
496
|
+
note?: string | null;
|
|
497
|
+
}
|
|
498
|
+
) {
|
|
499
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
500
|
+
|
|
501
|
+
if (toDateOnly(input.endDate) < toDateOnly(input.startDate)) {
|
|
502
|
+
throw new TRPCError({
|
|
503
|
+
code: "BAD_REQUEST",
|
|
504
|
+
message: "endDate must be on or after startDate",
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const uniqueStudentIds = [...new Set(input.studentIds)];
|
|
509
|
+
const memberships = await listSchoolStudentMemberships(input.schoolId);
|
|
510
|
+
const schoolStudentIds = new Set(memberships.map((m) => m.userId));
|
|
511
|
+
const invalid = uniqueStudentIds.filter((id) => !schoolStudentIds.has(id));
|
|
512
|
+
if (invalid.length > 0) {
|
|
513
|
+
throw new TRPCError({
|
|
514
|
+
code: "BAD_REQUEST",
|
|
515
|
+
message: "One or more students are not active members of this school",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const excusal = await createStudentExcusal({
|
|
520
|
+
schoolId: input.schoolId,
|
|
521
|
+
studentIds: uniqueStudentIds,
|
|
522
|
+
startDate: input.startDate,
|
|
523
|
+
endDate: input.endDate,
|
|
524
|
+
reason: input.reason,
|
|
525
|
+
note: input.note,
|
|
526
|
+
createdById: userId,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
await applyExcusalToAttendance({
|
|
530
|
+
schoolId: input.schoolId,
|
|
531
|
+
excusalId: excusal.id,
|
|
532
|
+
studentIds: uniqueStudentIds,
|
|
533
|
+
startDate: input.startDate,
|
|
534
|
+
endDate: input.endDate,
|
|
535
|
+
updatedById: userId,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
return excusal;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export async function updateExcusal(
|
|
542
|
+
userId: string,
|
|
543
|
+
input: {
|
|
544
|
+
schoolId: string;
|
|
545
|
+
excusalId: string;
|
|
546
|
+
studentIds?: string[];
|
|
547
|
+
startDate?: Date;
|
|
548
|
+
endDate?: Date;
|
|
549
|
+
reason?: string;
|
|
550
|
+
note?: string | null;
|
|
551
|
+
}
|
|
552
|
+
) {
|
|
553
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
554
|
+
|
|
555
|
+
const existing = await findExcusalById(input.excusalId);
|
|
556
|
+
if (!existing || existing.schoolId !== input.schoolId) {
|
|
557
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
|
|
558
|
+
}
|
|
559
|
+
if (existing.revokedAt) {
|
|
560
|
+
throw new TRPCError({
|
|
561
|
+
code: "BAD_REQUEST",
|
|
562
|
+
message: "Cannot update a revoked excusal",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const startDate = input.startDate ?? existing.startDate;
|
|
567
|
+
const endDate = input.endDate ?? existing.endDate;
|
|
568
|
+
if (toDateOnly(endDate) < toDateOnly(startDate)) {
|
|
569
|
+
throw new TRPCError({
|
|
570
|
+
code: "BAD_REQUEST",
|
|
571
|
+
message: "endDate must be on or after startDate",
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
let studentIds = input.studentIds
|
|
576
|
+
? [...new Set(input.studentIds)]
|
|
577
|
+
: existing.students.map((s) => s.studentId);
|
|
578
|
+
|
|
579
|
+
if (input.studentIds) {
|
|
580
|
+
const memberships = await listSchoolStudentMemberships(input.schoolId);
|
|
581
|
+
const schoolStudentIds = new Set(memberships.map((m) => m.userId));
|
|
582
|
+
const invalid = studentIds.filter((id) => !schoolStudentIds.has(id));
|
|
583
|
+
if (invalid.length > 0) {
|
|
584
|
+
throw new TRPCError({
|
|
585
|
+
code: "BAD_REQUEST",
|
|
586
|
+
message: "One or more students are not active members of this school",
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Clear previous locks, then re-apply for the new window/students.
|
|
592
|
+
await clearExcusalLocks(input.excusalId);
|
|
593
|
+
|
|
594
|
+
const updated = await updateStudentExcusal(input.excusalId, {
|
|
595
|
+
studentIds: input.studentIds ? studentIds : undefined,
|
|
596
|
+
startDate: input.startDate,
|
|
597
|
+
endDate: input.endDate,
|
|
598
|
+
reason: input.reason,
|
|
599
|
+
note: input.note,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
await applyExcusalToAttendance({
|
|
603
|
+
schoolId: input.schoolId,
|
|
604
|
+
excusalId: updated.id,
|
|
605
|
+
studentIds,
|
|
606
|
+
startDate,
|
|
607
|
+
endDate,
|
|
608
|
+
updatedById: userId,
|
|
609
|
+
});
|
|
610
|
+
|
|
113
611
|
return updated;
|
|
114
612
|
}
|
|
115
613
|
|
|
614
|
+
export async function revokeExcusal(
|
|
615
|
+
userId: string,
|
|
616
|
+
input: { schoolId: string; excusalId: string }
|
|
617
|
+
) {
|
|
618
|
+
await requireSchoolAdmin(input.schoolId, userId);
|
|
619
|
+
|
|
620
|
+
const existing = await findExcusalById(input.excusalId);
|
|
621
|
+
if (!existing || existing.schoolId !== input.schoolId) {
|
|
622
|
+
throw new TRPCError({ code: "NOT_FOUND", message: "Excusal not found" });
|
|
623
|
+
}
|
|
624
|
+
if (existing.revokedAt) {
|
|
625
|
+
throw new TRPCError({
|
|
626
|
+
code: "BAD_REQUEST",
|
|
627
|
+
message: "Excusal is already revoked",
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
await clearExcusalLocks(input.excusalId);
|
|
632
|
+
return revokeStudentExcusal(input.excusalId, userId);
|
|
633
|
+
}
|
|
634
|
+
|
|
116
635
|
/** Notify only students whose late or absent status is newly added. */
|
|
117
636
|
async function notifyMarkedLateOrAbsent(input: {
|
|
118
637
|
classId: string;
|
|
@@ -180,3 +699,6 @@ async function sendAttendanceNotification(input: {
|
|
|
180
699
|
logger.error("Failed to send attendance notifications:", { error });
|
|
181
700
|
}
|
|
182
701
|
}
|
|
702
|
+
|
|
703
|
+
// Re-export date helpers used by callers/tests.
|
|
704
|
+
export { toDateOnly, startOfUtcDay, endOfUtcDay };
|