///
import {
type Cell,
cell,
Default,
handler,
lift,
recipe,
str,
} from "commontools";
type AttendanceStatus = "present" | "absent";
interface StudentSeed {
id?: string;
name?: string;
}
interface StudentRecord {
id: string;
name: string;
sortKey: string;
}
interface AttendanceMark {
studentId?: string;
status?: string;
present?: boolean;
}
interface RecordAttendanceEvent {
sessionId?: string;
date?: string;
topic?: string;
attendance?: AttendanceMark[];
marks?: AttendanceMark[];
}
interface AttendanceEntryInternal {
sessionId: string;
date: string;
topic: string;
presentIds: string[];
absentIds: string[];
recordedOrder: number;
}
interface AttendanceEntryView {
sessionId: string;
date: string;
topic: string;
presentIds: string[];
absentIds: string[];
}
interface AbsentStudentSummary {
id: string;
name: string;
}
interface SessionSummary {
sessionId: string;
date: string;
topic: string;
presentCount: number;
absentCount: number;
absentStudents: AbsentStudentSummary[];
}
interface StudentAttendanceTrackerArgs {
roster: Default;
}
const isoDatePattern = /^\d{4}-\d{2}-\d{2}$/;
const sanitizeStudentName = (
value: unknown,
fallback: string,
): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
if (trimmed.length === 0) return fallback;
return trimmed.replace(/\s+/g, " ");
};
const slugify = (value: string): string => {
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
const sanitizeStudentId = (
value: unknown,
fallback: string,
name: string,
): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
const slug = slugify(name);
return slug.length > 0 ? `${slug}-${fallback}` : fallback;
};
const sanitizeRoster = (
value: readonly StudentSeed[] | undefined,
): StudentRecord[] => {
if (!Array.isArray(value)) return [];
const seen = new Set();
const sanitized: StudentRecord[] = [];
for (let index = 0; index < value.length; index += 1) {
const seed = value[index];
const fallbackId = `student-${index + 1}`;
const name = sanitizeStudentName(seed?.name, `Student ${index + 1}`);
const id = sanitizeStudentId(seed?.id, fallbackId, name);
if (seen.has(id)) continue;
seen.add(id);
sanitized.push({ id, name, sortKey: name.toLowerCase() });
}
sanitized.sort((left, right) => {
const nameCompare = left.sortKey.localeCompare(right.sortKey);
if (nameCompare !== 0) return nameCompare;
return left.id.localeCompare(right.id);
});
return sanitized;
};
const sanitizeDate = (value: unknown, fallback: string): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (isoDatePattern.test(trimmed)) {
return trimmed;
}
const parsed = new Date(trimmed);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().slice(0, 10);
}
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().slice(0, 10);
}
return fallback;
};
const sanitizeTopic = (value: unknown, fallback: string): string => {
if (typeof value !== "string") return fallback;
const trimmed = value.trim();
if (trimmed.length === 0) return fallback;
return trimmed.replace(/\s+/g, " ");
};
const sanitizeSessionId = (
value: unknown,
fallback: string,
date: string,
): string => {
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed.length > 0) {
return trimmed;
}
}
if (date.length > 0) {
return `${date}-${fallback}`;
}
return fallback;
};
const toAttendanceList = (
event: RecordAttendanceEvent | undefined,
): AttendanceMark[] => {
if (Array.isArray(event?.attendance)) return event.attendance;
if (Array.isArray(event?.marks)) return event.marks;
return [];
};
const normalizeStudentIdRef = (
value: unknown,
roster: readonly StudentRecord[],
): string | null => {
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (trimmed.length === 0) return null;
for (const student of roster) {
if (student.id === trimmed) return student.id;
if (student.id.toLowerCase() === trimmed.toLowerCase()) {
return student.id;
}
}
return null;
};
const interpretStatus = (
mark: AttendanceMark | undefined,
): AttendanceStatus => {
if (!mark) return "absent";
if (typeof mark.present === "boolean") {
return mark.present ? "present" : "absent";
}
const raw = mark.status;
if (typeof raw !== "string") return "absent";
const normalized = raw.trim().toLowerCase();
if (normalized === "present" || normalized === "p" || normalized === "yes") {
return "present";
}
return "absent";
};
const computeAttendanceSets = (
event: RecordAttendanceEvent | undefined,
roster: readonly StudentRecord[],
): { present: Set } => {
const present = new Set();
const marks = toAttendanceList(event);
for (const mark of marks) {
const id = normalizeStudentIdRef(mark?.studentId, roster);
if (!id) continue;
if (interpretStatus(mark) === "present") {
present.add(id);
}
}
return { present };
};
const toOrderedAttendance = (
roster: readonly StudentRecord[],
present: Set,
): { presentIds: string[]; absentIds: string[] } => {
const presentIds: string[] = [];
const absentIds: string[] = [];
for (const student of roster) {
if (present.has(student.id)) {
presentIds.push(student.id);
} else {
absentIds.push(student.id);
}
}
return { presentIds, absentIds };
};
const createAttendanceEntry = (
event: RecordAttendanceEvent | undefined,
roster: readonly StudentRecord[],
order: number,
): AttendanceEntryInternal => {
const date = sanitizeDate(event?.date, "1970-01-01");
const topic = sanitizeTopic(event?.topic, `Session ${order}`);
const sessionId = sanitizeSessionId(
event?.sessionId,
`session-${order}`,
date,
);
const { present } = computeAttendanceSets(event, roster);
const { presentIds, absentIds } = toOrderedAttendance(roster, present);
return {
sessionId,
date,
topic,
presentIds,
absentIds,
recordedOrder: order,
};
};
const compareEntries = (
left: AttendanceEntryInternal,
right: AttendanceEntryInternal,
): number => {
if (left.date !== right.date) {
return left.date.localeCompare(right.date);
}
if (left.sessionId !== right.sessionId) {
return left.sessionId.localeCompare(right.sessionId);
}
return left.recordedOrder - right.recordedOrder;
};
const projectView = (
entries: readonly AttendanceEntryInternal[],
): AttendanceEntryView[] => {
return entries.map((entry) => ({
sessionId: entry.sessionId,
date: entry.date,
topic: entry.topic,
presentIds: [...entry.presentIds],
absentIds: [...entry.absentIds],
}));
};
const computeSummaries = (
entries: readonly AttendanceEntryInternal[],
roster: readonly StudentRecord[],
): SessionSummary[] => {
const rosterById = new Map();
for (const student of roster) {
rosterById.set(student.id, student);
}
const sorted = [...entries].sort(compareEntries);
return sorted.map((entry) => {
const absentStudents: AbsentStudentSummary[] = entry.absentIds.map((id) => {
const student = rosterById.get(id);
return { id, name: student?.name ?? id };
});
return {
sessionId: entry.sessionId,
date: entry.date,
topic: entry.topic,
presentCount: entry.presentIds.length,
absentCount: entry.absentIds.length,
absentStudents,
};
});
};
const formatSessionLabel = (summary: SessionSummary): string => {
if (summary.absentCount === 0) {
return `${summary.date} ${summary.topic}: perfect attendance`;
}
const names = summary.absentStudents.map((student) => student.name).join(
", ",
);
const plural = summary.absentCount === 1 ? "absence" : "absences";
return `${summary.date} ${summary.topic}: ${summary.absentCount} ${plural} (${names})`;
};
const recordAttendance = handler(
(
event: RecordAttendanceEvent | undefined,
context: {
log: Cell;
roster: Cell;
runtimeSeed: Cell;
},
) => {
const roster = context.roster.get() ?? [];
const order = (context.runtimeSeed.get() ?? 0) + 1;
const nextEntry = createAttendanceEntry(event, roster, order);
const existing = context.log.get() ?? [];
const filtered = existing.filter((entry) =>
entry.sessionId !== nextEntry.sessionId
);
const updated = [...filtered, nextEntry].sort(compareEntries);
context.log.set(updated);
context.runtimeSeed.set(order);
},
);
export const studentAttendanceTrackerPattern = recipe<
StudentAttendanceTrackerArgs
>(
"Student Attendance Tracker",
({ roster }) => {
const runtimeSeed = cell(0);
const rosterView = lift((value: readonly StudentSeed[] | undefined) =>
sanitizeRoster(value)
)(roster);
const attendanceLogInternal = cell([]);
const attendanceLog = lift((entries: readonly AttendanceEntryInternal[]) =>
projectView(entries)
)(attendanceLogInternal);
const sessionSummaries = lift((inputs: {
entries: AttendanceEntryInternal[];
roster: StudentRecord[];
}) => computeSummaries(inputs.entries, inputs.roster))({
entries: attendanceLogInternal,
roster: rosterView,
});
const latestSummary = lift((summaries: readonly SessionSummary[]) =>
summaries.length === 0 ? null : summaries[summaries.length - 1]
)(sessionSummaries);
const sessionAbsenceLabels = lift((summaries: readonly SessionSummary[]) =>
summaries.map((summary) => formatSessionLabel(summary))
)(sessionSummaries);
const totalAbsences = lift((summaries: readonly SessionSummary[]) =>
summaries.reduce((sum, summary) => sum + summary.absentCount, 0)
)(sessionSummaries);
const totalSessions = lift((summaries: readonly SessionSummary[]) =>
summaries.length
)(sessionSummaries);
const absenceWord = lift((count: number) =>
count === 1 ? "absence" : "absences"
)(totalAbsences);
const sessionWord = lift((count: number) =>
count === 1 ? "session" : "sessions"
)(totalSessions);
const absenceSummaryLabel =
str`${totalAbsences} ${absenceWord} across ${totalSessions} ${sessionWord}`;
return {
roster: rosterView,
attendanceLog,
sessionSummaries,
latestSummary,
sessionAbsenceLabels,
totalAbsences,
totalSessions,
absenceSummaryLabel,
recordAttendance: recordAttendance({
log: attendanceLogInternal,
roster: rosterView,
runtimeSeed,
}),
};
},
);
export type {
AbsentStudentSummary,
AttendanceEntryView,
SessionSummary,
StudentRecord,
};