Refactored grading entirely.
Build and Test App / Build-and-test-App (push) Failing after 22s Details
Build and Test API / Build-and-test-API (push) Successful in 51s Details

This commit is contained in:
Andrew Lalis 2025-09-19 10:58:58 -04:00
parent b1f9cfa710
commit 38b34c0598
21 changed files with 549 additions and 622 deletions

View File

@ -32,7 +32,6 @@ CREATE TABLE classroom_compliance_student_label (
);
CREATE TABLE classroom_compliance_entry (
id BIGSERIAL PRIMARY KEY,
class_id BIGINT NOT NULL
REFERENCES classroom_compliance_class(id)
ON UPDATE CASCADE ON DELETE CASCADE,
@ -44,23 +43,20 @@ CREATE TABLE classroom_compliance_entry (
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
absent BOOLEAN NOT NULL DEFAULT FALSE,
comment VARCHAR(2000) NOT NULL DEFAULT '',
phone_compliant BOOLEAN NULL DEFAULT NULL,
classroom_readiness BOOLEAN NULL DEFAULT NULL,
behavior_rating INT NULL DEFAULT NULL,
CONSTRAINT absence_nulls_check CHECK (
(absent AND classroom_readiness IS NULL AND behavior_rating IS NULL) OR
(NOT absent AND classroom_readiness IS NOT NULL AND behavior_rating IS NOT NULL)
),
CONSTRAINT unique_entry_per_date
UNIQUE(class_id, student_id, date)
PRIMARY KEY (class_id, student_id, date)
);
CREATE TABLE classroom_compliance_entry_comment_checklist (
entry_id BIGINT NOT NULL
REFERENCES classroom_compliance_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE,
CREATE TABLE classroom_compliance_entry_checklist_item (
class_id BIGINT NOT NULL,
student_id BIGINT NOT NULL,
date DATE NOT NULL,
item VARCHAR(2000) NOT NULL,
PRIMARY KEY (entry_id, item)
checked BOOLEAN NOT NULL DEFAULT FALSE,
category VARCHAR(255) NOT NULL,
PRIMARY KEY (class_id, student_id, date, item, category),
FOREIGN KEY (class_id, student_id, date)
REFERENCES classroom_compliance_entry(class_id, student_id, date)
ON UPDATE CASCADE ON DELETE CASCADE
);
CREATE TABLE classroom_compliance_class_note (

View File

@ -50,7 +50,7 @@ void getClasses(ref HttpRequestContext ctx) {
SELECT
c.id, c.number, c.school_year, c.archived,
COUNT(DISTINCT s.id) AS student_count,
COUNT(DISTINCT e.id) AS entry_count,
COUNT(DISTINCT (e.student_id, e.date)) AS entry_count,
MAX(e.date) AS last_entry_date
FROM classroom_compliance_class c
LEFT JOIN classroom_compliance_student s ON c.id = s.class_id

View File

@ -16,39 +16,32 @@ import api_modules.classroom_compliance.score;
import db;
import data_utils;
struct EntriesTableEntryChecklistItem {
string item;
bool checked;
string category;
}
struct EntriesTableEntry {
ulong id;
Date date;
ulong createdAt;
bool absent;
string comment;
string[] checklistItems;
Optional!bool phoneCompliant;
Optional!bool classroomReadiness;
Optional!ubyte behaviorRating;
EntriesTableEntryChecklistItem[] checklistItems;
JSONValue toJsonObj() const {
JSONValue obj = JSONValue.emptyObject;
obj.object["id"] = JSONValue(id);
obj.object["date"] = JSONValue(date.toISOExtString());
obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment);
obj.object["checklistItems"] = JSONValue.emptyArray;
foreach (ck; checklistItems) {
obj.object["checklistItems"].array ~= JSONValue(ck);
}
if (absent) {
obj.object["phoneCompliant"] = JSONValue(null);
obj.object["classroomReadiness"] = JSONValue(null);
obj.object["behaviorRating"] = JSONValue(null);
} else {
JSONValue pcValue = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
JSONValue crValue = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
JSONValue bValue = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
obj.object["phoneCompliant"] = pcValue;
obj.object["classroomReadiness"] = crValue;
obj.object["behaviorRating"] = bValue;
JSONValue ckObj = JSONValue.emptyObject;
ckObj.object["item"] = JSONValue(ck.item);
ckObj.object["checked"] = JSONValue(ck.checked);
ckObj.object["category"] = JSONValue(ck.category);
obj.object["checklistItems"].array ~= ckObj;
}
return obj;
}
@ -129,38 +122,7 @@ void getEntries(ref HttpRequestContext ctx) {
Optional!double.empty
)).array;
const entriesQuery = "
SELECT
entry.id,
entry.date,
entry.created_at,
entry.absent,
entry.comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = entry.id
),
entry.phone_compliant,
entry.classroom_readiness,
entry.behavior_rating,
student.id,
student.name,
student.desk_number,
student.removed,
student.class_id
FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_student student
ON student.id = entry.student_id
WHERE
entry.class_id = ?
AND entry.date >= ?
AND entry.date <= ?
ORDER BY
student.id ASC,
entry.date ASC
";
const entriesQuery = import("source/api_modules/classroom_compliance/queries/find_entries_by_class.sql");
PreparedStatement ps = conn.prepareStatement(entriesQuery);
scope(exit) ps.close();
ps.setUlong(1, cls.id);
@ -168,47 +130,49 @@ void getEntries(ref HttpRequestContext ctx) {
ps.setDate(3, dateRange.to);
ResultSet rs = ps.executeQuery();
scope(exit) rs.close();
foreach (DataSetReader r; rs) {
ClassroomComplianceStudent student;
EntriesTableEntry entry;
bool hasNextRow = rs.next();
while (hasNextRow) {
// Parse the basic data from the query.
const absent = r.getBoolean(4);
Optional!bool phoneCompliant = absent
? Optional!bool.empty
: Optional!bool.of(r.getBoolean(7));
if (r.isNull(7)) phoneCompliant.isNull = true;
Optional!bool classroomReadiness = absent
? Optional!bool.empty
: Optional!bool.of(r.getBoolean(8));
if (r.isNull(8)) classroomReadiness.isNull = true;
Optional!ubyte behaviorRating = absent
? Optional!ubyte.empty
: Optional!ubyte.of(r.getUbyte(9));
if (r.isNull(9)) behaviorRating.isNull = true;
import std.string : split;
EntriesTableEntry entryData = EntriesTableEntry(
r.getUlong(1),
r.getDate(2),
r.getUlong(3),
r.getBoolean(4),
r.getString(5),
r.getString(6).split("|||"),
phoneCompliant,
classroomReadiness,
behaviorRating
student.id = rs.getUlong(1);
student.name = rs.getString(2);
student.classId = cls.id;
student.deskNumber = rs.getUshort(3);
student.removed = rs.getBoolean(4);
entry.date = rs.getDate(5);
entry.createdAt = rs.getUlong(6);
entry.absent = rs.getBoolean(7);
entry.comment = rs.getString(8);
bool hasChecklistItem = !rs.isNull(9);
if (hasChecklistItem) {
entry.checklistItems ~= EntriesTableEntryChecklistItem(
rs.getString(9),
rs.getBoolean(10),
rs.getString(11)
);
ClassroomComplianceStudent student = ClassroomComplianceStudent(
r.getUlong(10),
r.getString(11),
r.getUlong(14),
r.getUshort(12),
r.getBoolean(13)
}
// Load in the next row, and if it's the end of the result set or a new entry, we save this one.
hasNextRow = rs.next();
bool shouldSaveEntry = !hasNextRow || (
rs.getUlong(1) != student.id ||
rs.getDate(5) != entry.date
);
string dateStr = entryData.date.toISOExtString();
if (shouldSaveEntry) {
// Save the data for the current student and entry, including all checklist items.
// Then proceed to read the next item.
string dateStr = entry.date.toISOExtString();
// Find the student object this entry belongs to, then add it to their list.
bool studentFound = false;
foreach (ref studentObj; studentObjects) {
if (studentObj.id == student.id) {
studentObj.entries[dateStr] = entryData;
studentObj.entries[dateStr] = entry;
studentFound = true;
break;
}
@ -223,10 +187,14 @@ void getEntries(ref HttpRequestContext ctx) {
student.name,
student.deskNumber,
student.removed,
[dateStr: entryData],
[dateStr: entry],
Optional!double.empty
);
}
// Finally, reset the entry's list of checklist items.
entry.checklistItems = [];
}
}
// Find scores for each student for this timeframe.
@ -286,45 +254,10 @@ void saveEntries(ref HttpRequestContext ctx) {
ulong studentId = studentObj.object["id"].integer();
JSONValue entries = studentObj.object["entries"];
foreach (string dateStr, JSONValue entry; entries.object) {
if (entry.isNull) {
// Always start by deleting the existing entry to overwrite it with the new one.
deleteEntry(conn, cls.id, studentId, dateStr);
continue;
}
Optional!ClassroomComplianceEntry existingEntry = findOne(
conn,
"SELECT id, class_id, student_id, date, created_at,
absent, comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = id
),
phone_compliant, classroom_readiness, behavior_rating
FROM classroom_compliance_entry
WHERE class_id = ? AND student_id = ? AND date = ?",
&ClassroomComplianceEntry.parse,
cls.id, studentId, dateStr
);
ulong entryId = entry.object["id"].integer();
bool creatingNewEntry = entryId == 0;
if (creatingNewEntry) {
if (!existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Cannot create a new entry when one already exists.");
return;
}
insertNewEntry(conn, cls.id, studentId, dateStr, entry);
} else {
if (existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Cannot update an entry which doesn't exist.");
return;
}
updateEntry(conn, cls.id, studentId, dateStr, entryId, entry);
if (!entry.isNull) {
insertEntry(conn, cls.id, studentId, dateStr, entry);
}
}
}
@ -357,10 +290,9 @@ private void deleteEntry(
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
classId, studentId, dateStr
);
infoF!"Deleted entry for student %s on %s"(studentId, dateStr);
}
private void insertNewEntry(
private void insertEntry(
Connection conn,
ulong classId,
ulong studentId,
@ -370,25 +302,29 @@ private void insertNewEntry(
bool absent = payload.object["absent"].boolean;
string comment = payload.object["comment"].str;
if (comment is null) comment = "";
string[] checklistItems;
EntriesTableEntryChecklistItem[] checklistItems;
if ("checklistItems" in payload.object) {
checklistItems = payload.object["checklistItems"].array
.map!(v => v.str)
.map!((obj) {
EntriesTableEntryChecklistItem ck;
ck.item = obj.object["item"].str;
ck.checked = obj.object["checked"].boolean;
ck.category = obj.object["category"].str;
return ck;
})
.array;
}
Optional!bool classroomReadiness = Optional!bool.empty;
Optional!ubyte behaviorRating = Optional!ubyte.empty;
if (!absent) {
classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean);
behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer);
// If absent, ensure no checklist items may be checked.
if (absent) {
foreach (ref ck; checklistItems) {
ck.checked = false;
}
}
// Do the main insert first.
import std.variant;
Variant newEntryId;
const query = "
INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
(class_id, student_id, date, absent, comment)
VALUES (?, ?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setUlong(1, classId);
@ -396,84 +332,23 @@ private void insertNewEntry(
ps.setString(3, dateStr);
ps.setBoolean(4, absent);
ps.setString(5, comment);
if (absent) {
ps.setNull(6);
ps.setNull(7);
} else {
ps.setBoolean(6, classroomReadiness.value);
ps.setUbyte(7, behaviorRating.value);
}
ps.executeUpdate(newEntryId);
updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems);
infoF!"Created new entry for student %d: %s"(studentId, payload);
}
private void updateEntry(
Connection conn,
ulong classId,
ulong studentId,
string dateStr,
ulong entryId,
JSONValue obj
) {
bool absent = obj.object["absent"].boolean;
string comment = obj.object["comment"].str;
if (comment is null) comment = "";
string[] checklistItems;
if ("checklistItems" in obj.object) {
checklistItems = obj.object["checklistItems"].array
.map!(v => v.str)
.array;
}
Optional!bool classroomReadiness = Optional!bool.empty;
Optional!ubyte behaviorRating = Optional!ubyte.empty;
if (!absent) {
classroomReadiness = Optional!bool.of(obj.object["classroomReadiness"].boolean);
behaviorRating = Optional!ubyte.of(cast(ubyte) obj.object["behaviorRating"].integer);
}
const query = "
UPDATE classroom_compliance_entry
SET absent = ?, comment = ?, classroom_readiness = ?, behavior_rating = ?
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?
";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setBoolean(1, absent);
ps.setString(2, comment);
if (absent) {
ps.setNull(3);
ps.setNull(4);
} else {
ps.setBoolean(3, classroomReadiness.value);
ps.setUbyte(4, behaviorRating.value);
}
ps.setUlong(5, classId);
ps.setUlong(6, studentId);
ps.setString(7, dateStr);
ps.setUlong(8, entryId);
ps.executeUpdate();
updateEntryCommentChecklistItems(conn, entryId, checklistItems);
infoF!"Updated entry %d"(entryId);
}
private void updateEntryCommentChecklistItems(Connection conn, ulong entryId, string[] items) {
PreparedStatement ps1 = conn.prepareStatement(
"DELETE FROM classroom_compliance_entry_comment_checklist WHERE entry_id = ?"
);
scope(exit) ps1.close();
ps1.setUlong(1, entryId);
ps1.executeUpdate();
const checklistItemsQuery = "
INSERT INTO classroom_compliance_entry_comment_checklist (entry_id, item)
VALUES (?, ?)
";
PreparedStatement ps2 = conn.prepareStatement(checklistItemsQuery);
scope(exit) ps2.close();
foreach (item; items) {
ps2.setUlong(1, entryId);
ps2.setString(2, item);
ps2.executeUpdate();
// Now insert checklist items, if any.
if (checklistItems.length > 0) {
const ckQuery = "
INSERT INTO classroom_compliance_entry_checklist_item
(class_id, student_id, date, item, checked, category)
VALUES (?, ?, ?, ?, ?, ?)";
PreparedStatement ckPs = conn.prepareStatement(ckQuery);
scope(exit) ckPs.close();
foreach (ck; checklistItems) {
ckPs.setUlong(1, classId);
ckPs.setUlong(2, studentId);
ckPs.setString(3, dateStr);
ckPs.setString(4, ck.item);
ckPs.setBoolean(5, ck.checked);
ckPs.setString(6, ck.category);
ckPs.executeUpdate();
}
}
}

View File

@ -28,9 +28,6 @@ void getFullExport(ref HttpRequestContext ctx) {
e.date AS entry_date,
e.created_at AS entry_created_at,
e.absent AS absent,
e.phone_compliant AS phone_compliant,
e.classroom_readiness AS classroom_readiness,
e.behavior_rating AS behavior_rating,
e.comment AS comment
FROM classroom_compliance_class c
LEFT JOIN classroom_compliance_student s
@ -62,9 +59,6 @@ void getFullExport(ref HttpRequestContext ctx) {
CSVColumnDef("Entry Date", (r, i) => r.getDate(i).toISOExtString()),
CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
CSVColumnDef("Classroom Readiness", &formatClassroomReadiness),
CSVColumnDef("Behavior Rating", &formatBehaviorRating),
CSVColumnDef("Comment", &formatComment)
];
// Write headers first.

View File

@ -157,26 +157,55 @@ void getStudentEntries(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto student = getStudentOrThrow(ctx, conn, user);
auto entries = findAll(
conn,
"SELECT id, class_id, student_id, date, created_at,
absent, comment,
(
SELECT STRING_AGG(ck.item, '|||')
FROM classroom_compliance_entry_comment_checklist ck
WHERE ck.entry_id = id
),
phone_compliant, classroom_readiness, behavior_rating
FROM classroom_compliance_entry
WHERE student_id = ?
GROUP BY classroom_compliance_entry.id
ORDER BY date DESC",
&ClassroomComplianceEntry.parse,
student.id
const query = import("source/api_modules/classroom_compliance/queries/find_entries_by_student.sql");
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setUlong(1, student.id);
ResultSet rs = ps.executeQuery();
ClassroomComplianceEntry entry;
ClassroomComplianceEntryChecklistItem[] checklistItems;
JSONValue responseArray = JSONValue.emptyArray;
bool hasNextRow = rs.next();
while (hasNextRow) {
entry.date = rs.getDate(1);
entry.classId = rs.getUlong(2);
entry.createdAt = rs.getUlong(3);
entry.absent = rs.getBoolean(4);
entry.comment = rs.getString(5);
entry.studentId = student.id;
bool hasChecklistItem = !rs.isNull(6);
if (hasChecklistItem) {
checklistItems ~= ClassroomComplianceEntryChecklistItem(
rs.getString(6),
rs.getBoolean(7),
rs.getString(8)
);
JSONValue response = JSONValue.emptyArray;
foreach (entry; entries) response.array ~= entry.toJsonObj();
ctx.response.writeBodyString(response.toJSON(), "application/json");
}
hasNextRow = rs.next();
bool shouldSaveEntry = !hasNextRow || rs.getDate(1) != entry.date;
if (shouldSaveEntry) {
JSONValue obj = JSONValue.emptyObject;
obj.object["date"] = JSONValue(entry.date.toISOExtString());
obj.object["createdAt"] = JSONValue(entry.createdAt);
obj.object["absent"] = JSONValue(entry.absent);
obj.object["comment"] = JSONValue(entry.comment);
obj.object["checklistItems"] = JSONValue.emptyArray;
foreach (item; checklistItems) {
JSONValue ckObj = JSONValue.emptyObject;
ckObj.object["item"] = JSONValue(item.item);
ckObj.object["checked"] = JSONValue(item.checked);
ckObj.object["category"] = JSONValue(item.category);
obj.object["checklistItems"].array ~= ckObj;
}
responseArray.array ~= obj;
checklistItems = [];
}
}
ctx.response.writeBodyString(responseArray.toJSON(), "application/json");
}
void getStudentOverview(ref HttpRequestContext ctx) {
@ -187,7 +216,9 @@ void getStudentOverview(ref HttpRequestContext ctx) {
const ulong entryCount = count(
conn,
"SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ?",
"SELECT COUNT(DISTINCT(class_id, date))
FROM classroom_compliance_entry
WHERE student_id = ?",
student.id
);
if (entryCount == 0) {
@ -197,37 +228,20 @@ void getStudentOverview(ref HttpRequestContext ctx) {
}
const ulong absenceCount = count(
conn,
"SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true",
student.id
);
const ulong phoneNoncomplianceCount = count(
conn,
"SELECT COUNT(id) FROM classroom_compliance_entry WHERE phone_compliant = FALSE AND student_id = ?",
student.id
);
const behaviorCountQuery = "
SELECT COUNT(id)
"SELECT COUNT(DISTINCT(class_id, date))
FROM classroom_compliance_entry
WHERE student_id = ? AND behavior_rating = ?
";
const ulong behaviorGoodCount = count(conn, behaviorCountQuery, student.id, 3);
const ulong behaviorMediocreCount = count(conn, behaviorCountQuery, student.id, 2);
const ulong behaviorPoorCount = count(conn, behaviorCountQuery, student.id, 1);
WHERE student_id = ? AND absent = true",
student.id
);
// Calculate derived statistics.
const ulong attendanceCount = entryCount - absenceCount;
double attendanceRate = attendanceCount / cast(double) entryCount;
double phoneComplianceRate = (attendanceCount - phoneNoncomplianceCount) / cast(double) attendanceCount;
double behaviorScore = (
behaviorGoodCount * 1.0 +
behaviorMediocreCount * 0.5
) / attendanceCount;
JSONValue response = JSONValue.emptyObject;
response.object["attendanceRate"] = JSONValue(attendanceRate);
response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate);
response.object["behaviorScore"] = JSONValue(behaviorScore);
// response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate);
// response.object["behaviorScore"] = JSONValue(behaviorScore);
response.object["entryCount"] = JSONValue(entryCount);
ctx.response.writeBodyString(response.toJSON(), "application/json");
}

View File

@ -47,52 +47,19 @@ struct ClassroomComplianceStudent {
}
struct ClassroomComplianceEntry {
ulong id;
ulong classId;
ulong studentId;
Date date;
ulong createdAt;
bool absent;
string comment;
string[] checklistItems;
Optional!bool phoneCompliant;
Optional!bool classroomReadiness;
Optional!ubyte behaviorRating;
static ClassroomComplianceEntry parse(DataSetReader r) {
ClassroomComplianceEntry entry;
entry.id = r.getUlong(1);
entry.classId = r.getUlong(2);
entry.studentId = r.getUlong(3);
entry.date = r.getDate(4);
entry.createdAt = r.getUlong(5);
entry.absent = r.getBoolean(6);
entry.comment = r.getString(7);
entry.checklistItems = r.getString(8).split("|||");
entry.phoneCompliant = r.isNull(9) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(9));
entry.classroomReadiness = r.isNull(10) ? Optional!bool.empty : Optional!bool.of(r.getBoolean(10));
entry.behaviorRating = r.isNull(11) ? Optional!ubyte.empty : Optional!ubyte.of(r.getUbyte(11));
return entry;
}
JSONValue toJsonObj() const {
JSONValue obj = JSONValue.emptyObject;
obj.object["id"] = JSONValue(id);
obj.object["classId"] = JSONValue(classId);
obj.object["studentId"] = JSONValue(studentId);
obj.object["date"] = JSONValue(date.toISOExtString());
obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment);
obj.object["checklistItems"] = JSONValue.emptyArray;
foreach (item; checklistItems) {
obj.object["checklistItems"].array ~= JSONValue(item);
}
obj.object["phoneCompliant"] = phoneCompliant.isNull ? JSONValue(null) : JSONValue(phoneCompliant.value);
obj.object["classroomReadiness"] = classroomReadiness.isNull ? JSONValue(null) : JSONValue(classroomReadiness.value);
obj.object["behaviorRating"] = behaviorRating.isNull ? JSONValue(null) : JSONValue(behaviorRating.value);
return obj;
}
struct ClassroomComplianceEntryChecklistItem {
string item;
bool checked;
string category;
}
struct ClassroomComplianceClassNote {

View File

@ -0,0 +1,30 @@
SELECT
student.id,
student.name,
student.desk_number,
student.removed,
entry.date,
entry.created_at,
entry.absent,
entry.comment,
ck.item,
ck.checked,
ck.category
FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_student student
ON student.id = entry.student_id
LEFT JOIN classroom_compliance_entry_checklist_item ck
ON ck.class_id = entry.class_id AND
ck.student_id = entry.student_id AND
ck.date = entry.date
WHERE
entry.class_id = ?
AND entry.date >= ?
AND entry.date <= ?
ORDER BY
student.id ASC,
entry.date ASC,
ck.category ASC,
ck.item ASC

View File

@ -0,0 +1,21 @@
SELECT
entry.date,
entry.class_id,
entry.created_at,
entry.absent,
entry.comment,
ck.item,
ck.checked,
ck.category
FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_entry_checklist_item ck
ON ck.class_id = entry.class_id AND
ck.student_id = entry.student_id AND
ck.date = entry.date
WHERE
entry.student_id = ?
ORDER BY
entry.date DESC,
ck.category ASC,
ck.item ASC

View File

@ -0,0 +1,31 @@
SELECT
entry.student_id,
COUNT(DISTINCT(entry.student_id, entry.date)) AS entry_count,
SUM(
CASE WHEN ck.category = 'Classroom Readiness'
THEN 1 ELSE 0 END
)
AS classroom_readiness_item_count,
SUM(
CASE WHEN ck.category = 'Classroom Readiness' AND ck.checked
THEN 1 ELSE 0 END
)
AS classroom_readiness_item_count_checked,
SUM(
CASE WHEN ck.category = 'Behavior'
THEN 1 ELSE 0 END
) AS behavior_item_count,
SUM(
CASE WHEN ck.category = 'Behavior' AND ck.checked
THEN 1 ELSE 0 END
) AS behavior_item_count_checked
FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_entry_checklist_item ck
ON ck.class_id = entry.class_id AND
ck.student_id = entry.student_id AND
ck.date = entry.date
WHERE
entry.date >= ?
AND entry.date <= ?
AND entry.class_id = ?
GROUP BY entry.student_id

View File

@ -18,6 +18,16 @@ private struct ScoringParameters {
DateRange dateRange;
}
private struct ChecklistItemCategoryStats {
ulong total;
ulong checked;
double getUncheckedRatio() const {
double unchecked = total - checked;
return unchecked / total;
}
}
/**
* Gets an associative array that maps student ids to their (optional) scores.
* Scores are calculated based on aggregate statistics from their entries. A
@ -48,23 +58,7 @@ Optional!double[ulong] getScores(
params.dateRange.to
);
const query = "
SELECT
student_id,
COUNT(id) AS entry_count,
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
SUM(CASE WHEN classroom_readiness = FALSE THEN 1 ELSE 0 END) AS not_classroom_ready_count,
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
FROM classroom_compliance_entry
WHERE
date >= ?
AND date <= ?
AND class_id = ?
GROUP BY student_id
";
const query = import("source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql");
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setDate(1, params.dateRange.from);
@ -72,22 +66,20 @@ Optional!double[ulong] getScores(
ps.setUlong(3, classId);
foreach (DataSetReader r; ps.executeQuery()) {
ulong studentId = r.getUlong(1);
uint entryCount = r.getUint(2);
uint absenceCount = r.getUint(3);
uint phoneNonComplianceCount = r.getUint(4);
uint notClassroomReadyCount = r.getUint(5);
uint behaviorGoodCount = r.getUint(6);
uint behaviorMediocreCount = r.getUint(7);
uint behaviorPoorCount = r.getUint(8);
ulong entryCount = r.getUlong(2);
ChecklistItemCategoryStats[string] categoryStats;
categoryStats["classroom_readiness"] = ChecklistItemCategoryStats(
r.getUlong(3),
r.getUlong(4)
);
categoryStats["behavior"] = ChecklistItemCategoryStats(
r.getUlong(5),
r.getUlong(6)
);
scores[studentId] = calculateScore(
params.expr,
entryCount,
absenceCount,
phoneNonComplianceCount,
notClassroomReadyCount,
behaviorGoodCount,
behaviorMediocreCount,
behaviorPoorCount
categoryStats
);
}
return scores;
@ -132,49 +124,32 @@ private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period)
*/
private Optional!double calculateScore(
in Expr scoreExpression,
uint entryCount,
uint absenceCount,
uint phoneNonComplianceCount,
uint notClassroomReadyCount,
uint behaviorGoodCount,
uint behaviorMediocreCount,
uint behaviorPoorCount
ulong entryCount,
in ChecklistItemCategoryStats[string] categoryStats
) {
if (
entryCount == 0
|| entryCount <= absenceCount
) return Optional!double.empty;
if (entryCount == 0) return Optional!double.empty;
const uint presentCount = entryCount - absenceCount;
// Phone subscore:
uint phoneCompliantCount;
if (presentCount < phoneNonComplianceCount) {
phoneCompliantCount = 0;
} else {
phoneCompliantCount = presentCount - phoneNonComplianceCount;
double classroomReadinessScore = 0;
if ("classroom_readiness" in categoryStats) {
classroomReadinessScore = categoryStats["classroom_readiness"].getUncheckedRatio();
}
double phoneScore = phoneCompliantCount / cast(double) presentCount;
// Classroom readiness score:
uint classroomReadyCount;
if (presentCount < notClassroomReadyCount) {
classroomReadyCount = 0;
} else {
classroomReadyCount = presentCount - notClassroomReadyCount;
double behaviorScore = 0;
if ("behavior" in categoryStats) {
behaviorScore = categoryStats["behavior"].getUncheckedRatio();
}
double classroomReadinessScore = classroomReadyCount / cast(double) presentCount;
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount;
double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore);
return Optional!double.of(scoreExpression.eval([
"phone": phoneScore,
try {
double score = scoreExpression.eval([
"classroom_readiness": classroomReadinessScore,
"behavior": typicalBehaviorScore,
"behavior_good": behaviorGoodScore,
"behavior_mediocre": behaviorMediocreScore,
"behavior_poor": behaviorPoorScore
]));
"behavior": behaviorScore,
]);
import std.math : isNaN;
if (isNaN(score)) return Optional!double.empty;
return Optional!double.of(score);
} catch (ExpressionEvaluationException e) {
import handy_httpd;
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to compute score: " ~ e.msg);
}
}

View File

@ -80,18 +80,7 @@ void addClassroomComplianceSampleData(ref Random rand, ulong adminUserId, Connec
bool missingEntry = uniform01(rand) < 0.05;
if (missingEntry) continue;
bool absent = uniform01(rand) < 0.05;
bool phoneCompliant = uniform01(rand) < 0.85;
ubyte behaviorRating = 3;
if (uniform01(rand) < 0.25) {
behaviorRating = 2;
if (uniform01(rand) < 0.5) {
behaviorRating = 1;
}
}
bool hasComment = uniform01(rand) < 0.2;
string comment = hasComment ? "Test comment." : "";
addEntry(conn, classId, studentId, entryDate, absent, phoneCompliant, behaviorRating, comment);
addEntry(conn, classId, studentId, entryDate, rand);
}
}
}
@ -130,35 +119,49 @@ void addEntry(
ulong classId,
ulong studentId,
Date date,
bool absent,
bool phoneCompliant,
ubyte behaviorRating,
string comment
ref Random rand
) {
bool absent = uniform01(rand) < 0.05;
bool hasComment = uniform01(rand) < 0.25;
string comment = hasComment ? "This is a sample comment." : "";
const entryQuery = "
INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
(class_id, student_id, date, absent, comment)
VALUES (?, ?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(entryQuery);
scope(exit) ps.close();
ps.setUlong(1, classId);
ps.setUlong(2, studentId);
ps.setDate(3, date);
ps.setBoolean(4, absent);
if (comment is null) {
ps.setString(5, "");
} else {
ps.setString(5, comment);
}
ps.setNull(6); // Always set phone_compliant as null from now on.
if (absent) {
ps.setNull(7);
ps.setNull(8);
} else {
ps.setBoolean(7, true);
ps.setUint(8, behaviorRating);
}
ps.executeUpdate();
bool hasChecklistItems = uniform01(rand) < 0.75;
if (!hasChecklistItems) return;
const checklistItemQuery = "
INSERT INTO classroom_compliance_entry_checklist_item
(class_id, student_id, date, item, checked, category)
VALUES (?, ?, ?, ?, ?, ?)";
PreparedStatement ckPs = conn.prepareStatement(checklistItemQuery);
scope(exit) ckPs.close();
const categories = ["Classroom Readiness", "Behavior"];
foreach (category; categories) {
for (int i = 0; i < 6; i++) {
import std.conv : to;
string item = "Checklist item " ~ (i+1).to!string;
bool checked = uniform01(rand) < 0.25;
ckPs.setUlong(1, classId);
ckPs.setUlong(2, studentId);
ckPs.setDate(3, date);
ckPs.setString(4, item);
ckPs.setBoolean(5, checked);
ckPs.setString(6, category);
ckPs.executeUpdate();
}
}
}
void deleteAllData(Connection conn) {
@ -168,7 +171,7 @@ void deleteAllData(Connection conn) {
const tables = [
"announcement",
"classroom_compliance_class_note",
"classroom_compliance_entry_comment_checklist",
"classroom_compliance_entry_checklist_item",
"classroom_compliance_entry",
"classroom_compliance_student",
"classroom_compliance_class",

View File

@ -2,8 +2,6 @@ import { APIClient, APIResponse, type AuthStoreType } from './base'
export const BASE_URL = import.meta.env.VITE_API_URL + '/classroom-compliance'
export const EMOJI_PHONE_COMPLIANT = '📱'
export const EMOJI_PHONE_NONCOMPLIANT = '📵'
export const EMOJI_CLASSROOM_READY = '🍎'
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
export const EMOJI_PRESENT = '✅'
@ -12,6 +10,30 @@ export const EMOJI_BEHAVIOR_GOOD = '😇'
export const EMOJI_BEHAVIOR_MEDIOCRE = '😐'
export const EMOJI_BEHAVIOR_POOR = '😡'
export enum ComplianceLevel {
Good,
Mediocre,
Poor,
}
export const DEFAULT_CHECKLIST_ITEMS: Record<string, string[]> = {
'Classroom Readiness': [
'Tardy',
'Out of Uniform',
'Use of wireless tech',
'10+ minute bathroom pass',
'Not having laptop',
'Not in assigned seat',
],
Behavior: [
'Talking out of turn',
'Throwing objects in class',
'Rude language / comments to peers',
'Disrespectful towards the teacher',
'Disrupting / distracting peers or teacher',
],
}
export interface Class {
id: number
number: number
@ -40,31 +62,55 @@ export interface Student {
}
export interface Entry {
id: number
date: string
createdAt: number
absent: boolean
phoneCompliant: boolean | null
classroomReadiness: boolean | null
behaviorRating: number | null
comment: string
checklistItems: string[]
checklistItems: EntryChecklistItem[]
}
export interface EntryChecklistItem {
item: string
checked: boolean
category: string
}
export function getDefaultEntry(dateStr: string): Entry {
const defaultChecklistItems: EntryChecklistItem[] = []
for (const category in DEFAULT_CHECKLIST_ITEMS) {
for (const item of DEFAULT_CHECKLIST_ITEMS[category]) {
defaultChecklistItems.push({ item, category, checked: false })
}
}
defaultChecklistItems.sort((a, b) => {
const cmpCat = a.category.localeCompare(b.category)
if (cmpCat !== 0) return cmpCat
return a.item.localeCompare(b.category)
})
return {
id: 0,
date: dateStr,
createdAt: Date.now(),
absent: false,
phoneCompliant: null,
classroomReadiness: true,
behaviorRating: 3,
comment: '',
checklistItems: [],
checklistItems: defaultChecklistItems,
}
}
export function getCategoryScore(entry: Entry, category: string): number | null {
const matchingItems = entry.checklistItems.filter((ck) => ck.category === category)
if (matchingItems.length === 0) return null
const unchecked = matchingItems.filter((ck) => !ck.checked).length
return unchecked / matchingItems.length
}
export function getComplianceLevel(entry: Entry, category: string): ComplianceLevel | null {
const score = getCategoryScore(entry, category)
if (score === null) return null
if (score < 0.5) return ComplianceLevel.Poor
if (score < 1) return ComplianceLevel.Mediocre
return ComplianceLevel.Good
}
export interface EntriesResponseStudent {
id: number
classId: number
@ -106,8 +152,6 @@ export interface ScoresResponse {
export interface StudentStatisticsOverview {
attendanceRate: number
phoneComplianceRate: number
behaviorScore: number
entryCount: number
}

View File

@ -136,7 +136,7 @@ function resetScoreParameters() {
<EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
<div>
<div v-if="entriesTable?.selectedView !== 'Whiteboard'">
<h3 style="margin-bottom: 0.25em;">Notes</h3>
<form @submit.prevent="submitNote">
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
@ -146,7 +146,7 @@ function resetScoreParameters() {
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
</div>
<div>
<div v-if="entriesTable?.selectedView !== 'Whiteboard'">
<h3 style="margin-bottom: 0.25em;">Scoring</h3>
<p style="margin-top: 0.25em; margin-bottom: 0.25em;">
Change how scores are calculated for this class here.
@ -162,24 +162,12 @@ function resetScoreParameters() {
can use the following variables:
</p>
<ul class="form-input-hint">
<li><span class="score-expression-variable" style="text-decoration: line-through;">phone</span> - The
student's phone score, defined as the number of
compliant days divided by total days present. <em>Note: Phone compliance is not available for new
entries.</em>
</li>
<li><span class="score-expression-variable">classroom_readiness</span> - The student's classroom readiness
score, defined as the number of 'ready' days divided by total days present.</li>
<li><span class="score-expression-variable">behavior_good</span> - The proportion of days that the student had
good behavior.</li>
<li><span class="score-expression-variable">behavior_mediocre</span> - The proportion of days that the student
had mediocre behavior.</li>
<li><span class="score-expression-variable">behavior_poor</span> - The proportion of days that the student had
poor behavior.</li>
<li><span class="score-expression-variable">behavior</span> - A general behavior score, where a student gets
full points for good behavior, half-points for mediocre behavior, and no points for poor behavior.</li>
score.</li>
<li><span class="score-expression-variable">behavior</span> - The student's behavior score.</li>
</ul>
<p class="form-input-hint">
As an example, a common scoring expression might be 50% phone-compliance, and 50% behavior. The expression
As an example, a common scoring expression might be 50% classroom readiness, and 50% behavior. The expression
below would achieve that:
</p>
<p style="font-family: 'SourceCodePro', monospace; font-size: smaller;">

View File

@ -254,7 +254,8 @@ function addAllEntriesForDate(dateStr: string) {
}
defineExpose({
loadEntries
loadEntries,
selectedView
})
</script>
<template>
@ -314,7 +315,7 @@ defineExpose({
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
<EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date"
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp"
:disabled="disabled" />
:disabled="disabled" :student="student" />
<StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" />
</tr>
</tbody>

View File

@ -1,19 +1,18 @@
<script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
import { EMOJI_ABSENT, EMOJI_PRESENT, getDefaultEntry, type EntriesResponseStudent, type Entry } from '@/api/classroom_compliance';
import EntryTableCell from './entries_table/EntryTableCell.vue';
import { ref, type Ref } from 'vue';
const sampleEntry: Ref<Entry | null> = ref({
id: 1,
date: '2025-01-01',
createdAt: new Date().getTime(),
absent: false,
phoneCompliant: null,
classroomReadiness: true,
behaviorRating: 3,
comment: '',
checklistItems: []
})
const sampleEntry: Ref<Entry | null> = ref(getDefaultEntry('2025-01-01'))
const sampleStudent: EntriesResponseStudent = {
classId: 123,
deskNumber: 123,
id: 123,
name: "John Smith",
removed: false,
score: 0,
entries: {}
}
</script>
<template>
@ -83,19 +82,15 @@ const sampleEntry: Ref<Entry | null> = ref({
<tbody>
<tr>
<EntryTableCell date-str="2025-01-01" :last-save-state-timestamp="0" v-model="sampleEntry"
style="min-width: 150px" />
:student="sampleStudent" style="min-width: 150px" />
</tr>
</tbody>
</table>
<ul>
<li>Attendance: {{ EMOJI_PRESENT }} for present, and {{ EMOJI_ABSENT }} for absent.</li>
<li>Classroom readiness: {{ EMOJI_CLASSROOM_READY }} for compliant, and {{ EMOJI_NOT_CLASSROOM_READY }} for
non-compliant.</li>
<li>Behavior: {{ EMOJI_BEHAVIOR_GOOD }} for good behavior, {{ EMOJI_BEHAVIOR_MEDIOCRE }} for mediocre, and {{
EMOJI_BEHAVIOR_POOR }} for poor.</li>
<li>If you'd like to add a comment, click 💬 and enter your comment in the popup.</li>
<li>If you'd like to add a comment or mark some issue, click 💬 and enter your comment in the popup.</li>
<li>Click the 🗑 to remove the entry.</li>
<li><em>Note that if a student is absent, you can't add any phone or behavior information.</em></li>
<li><em>Note that if a student is absent, you can only add a comment.</em></li>
</ul>
<p>
When editing, changed entries are highlighted, and you need to click <strong>Save</strong> or <strong>Discard

View File

@ -36,7 +36,7 @@ onMounted(() => {
<p v-if="noEntries" class="align-center">
This student doesn't have any entries yet.
</p>
<StudentEntryItem v-for="entry in entries" :key="entry.id" :entry="entry" />
<StudentEntryItem v-for="entry, idx in entries" :key="idx" :entry="entry" />
</div>
</div>
</template>

View File

@ -1,38 +1,34 @@
<script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
import { ComplianceLevel, EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PRESENT, getComplianceLevel, type Entry } from '@/api/classroom_compliance';
import { getFormattedDate } from '@/util';
import { computed } from 'vue';
const props = defineProps<{
entry: Entry
}>()
function getFormattedDate() {
const d = new Date(props.entry.date + 'T00:00:00')
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return days[d.getDay()] + ', ' + d.toLocaleDateString()
}
const classroomReadinessCompliance = computed(() => getComplianceLevel(props.entry, 'Classroom Readiness'))
const behaviorCompliance = computed(() => getComplianceLevel(props.entry, 'Behavior'))
</script>
<template>
<div class="student-entry-item">
<h6>{{ getFormattedDate() }}</h6>
<h6>{{ getFormattedDate(entry.date) }}</h6>
<div class="icons-container">
<span v-if="entry.absent">{{ EMOJI_ABSENT }}</span>
<span v-if="!entry.absent">{{ EMOJI_PRESENT }}</span>
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
<span v-if="classroomReadinessCompliance === ComplianceLevel.Good">{{ EMOJI_CLASSROOM_READY }}</span>
<span v-if="classroomReadinessCompliance !== ComplianceLevel.Good">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
<span v-if="entry.classroomReadiness === true">{{ EMOJI_CLASSROOM_READY }}</span>
<span v-if="entry.classroomReadiness === false">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
<span v-if="entry.behaviorRating === 3">{{ EMOJI_BEHAVIOR_GOOD }}</span>
<span v-if="entry.behaviorRating === 2">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
<span v-if="entry.behaviorRating === 1">{{ EMOJI_BEHAVIOR_POOR }}</span>
<span v-if="behaviorCompliance === ComplianceLevel.Good">{{ EMOJI_BEHAVIOR_GOOD }}</span>
<span v-if="behaviorCompliance === ComplianceLevel.Mediocre">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
<span v-if="behaviorCompliance === ComplianceLevel.Poor">{{ EMOJI_BEHAVIOR_POOR }}</span>
</div>
<p v-if="entry.comment.trim().length > 0" class="comment">
{{ entry.comment }}
</p>
<ul v-if="entry.checklistItems">
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
<li v-for="item in entry.checklistItems" :key="item.category + item.item" v-text="item.item"></li>
</ul>
</div>
</template>

View File

@ -1,11 +1,12 @@
<script setup lang="ts">
import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
import { ClassroomComplianceAPIClient, ComplianceLevel, getCategoryScore, getComplianceLevel, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import StudentEntriesList from './StudentEntriesList.vue'
import { APIError } from '@/api/base'
import { formatScorePercent, getFormattedDate } from '@/util'
const props = defineProps<{
classId: string
@ -107,12 +108,6 @@ async function deleteThisStudent() {
await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
}
function getFormattedDate(entry: Entry) {
const d = new Date(entry.date + 'T00:00:00')
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
return days[d.getDay()] + ', ' + d.toLocaleDateString()
}
async function addLabel() {
if (!cls.value || !student.value || cls.value.archived) return
const newLabels = [...labels.value, newLabel.value.trim()]
@ -146,7 +141,8 @@ async function deleteLabel(label: string) {
<div class="button-bar align-center">
<button type="button"
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button>
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)"
:disabled="cls?.archived">Edit</button>
<button type="button" @click="deleteThisStudent" :disabled="cls?.archived">Delete</button>
<button type="button" @click="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week
overview</button>
@ -158,8 +154,8 @@ async function deleteLabel(label: string) {
<tr>
<th>Desk Number</th>
<td>
{{ student.deskNumber }}
<span v-if="student.deskNumber === 0" style="font-style: italic; font-size: small;">*No assigned desk</span>
<span v-if="student.deskNumber !== 0">{{ student.deskNumber }}</span>
<span v-if="student.deskNumber === 0" style="font-style: italic; font-size: small;">No assigned desk</span>
</td>
</tr>
<tr>
@ -170,14 +166,6 @@ async function deleteLabel(label: string) {
<th>Attendance Rate</th>
<td>{{ (statistics.attendanceRate * 100).toFixed(1) }}%</td>
</tr>
<tr v-if="statistics">
<th>Phone Compliance Rate</th>
<td>{{ (statistics.phoneComplianceRate * 100).toFixed(1) }}%</td>
</tr>
<tr v-if="statistics">
<th>Behavior Score</th>
<td>{{ (statistics.behaviorScore * 100).toFixed(1) }}%</td>
</tr>
<tr v-if="statistics">
<th>Total Entries</th>
<td>{{ statistics.entryCount }}</td>
@ -199,24 +187,30 @@ async function deleteLabel(label: string) {
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
<div>
<h2>This week's overview for <span v-text="student.name"></span></h2>
<div v-for="entry in lastWeeksEntries" :key="entry.id" class="weekly-overview-dialog-day">
<h4>{{ getFormattedDate(entry) }}</h4>
<div v-for="entry in lastWeeksEntries" :key="entry.date" class="weekly-overview-dialog-day">
<h4>{{ getFormattedDate(entry.date) }}</h4>
<div v-if="entry.classroomReadiness !== null">
<h5>Classroom Readiness</h5>
<p v-if="entry.classroomReadiness"> Ready for class</p>
<p v-if="!entry.classroomReadiness"> Not ready for class</p>
<div v-if="entry.absent">
<p style="color: gray;">Absent</p>
</div>
<div v-if="entry.behaviorRating !== null">
<h5>Behavior</h5>
<p v-if="entry.behaviorRating === 1">
<div v-if="getComplianceLevel(entry, 'Classroom Readiness') !== null">
<h5>Classroom Readiness: {{ formatScorePercent(getCategoryScore(entry, 'Classroom Readiness')) }}</h5>
<p v-if="getComplianceLevel(entry, 'Classroom Readiness') === ComplianceLevel.Good"> Ready for class</p>
<p v-if="getComplianceLevel(entry, 'Classroom Readiness') !== ComplianceLevel.Good"> Not ready for class
</p>
</div>
<div v-if="getComplianceLevel(entry, 'Behavior') !== null">
<h5>Behavior: {{ formatScorePercent(getCategoryScore(entry, 'Behavior')) }}</h5>
<p v-if="getComplianceLevel(entry, 'Behavior') === ComplianceLevel.Poor">
Poor
</p>
<p v-if="entry.behaviorRating === 2">
<p v-if="getComplianceLevel(entry, 'Behavior') === ComplianceLevel.Mediocre">
Mediocre
</p>
<p v-if="entry.behaviorRating === 3">
<p v-if="getComplianceLevel(entry, 'Behavior') === ComplianceLevel.Good">
Good
</p>
</div>
@ -226,10 +220,12 @@ async function deleteLabel(label: string) {
<p>{{ entry.comment }}</p>
</div>
<div v-if="entry.checklistItems.length > 0">
<div v-if="entry.checklistItems.filter(ck => ck.checked).length > 0">
<h5>Classroom Readiness & Behavioral Issues</h5>
<ul>
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
<li v-for="item in entry.checklistItems.filter(ck => ck.checked)" :key="item.category + item.item">
{{ item.item }}
</li>
</ul>
</div>
</div>

View File

@ -1,27 +1,12 @@
<script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_CLASSROOM_READY, EMOJI_NOT_CLASSROOM_READY, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance'
import { EMOJI_ABSENT, EMOJI_PRESENT, getDefaultEntry, type EntriesResponseStudent, type Entry, type EntryChecklistItem } from '@/api/classroom_compliance'
import { getFormattedDate } from '@/util';
import { computed, onMounted, ref, useTemplateRef, watch, type Ref } from 'vue'
const COMMENT_CHECKLIST_ITEMS = {
"Classroom Readiness": [
"Tardy",
"Out of Uniform",
"Use of wireless tech",
"10+ minute bathroom pass",
"Not having laptop",
"Not in assigned seat"
],
"Behavior": [
"Talking out of turn",
"Throwing objects in class",
"Rude language / comments to peers",
"Disrespectful towards the teacher"
]
}
const props = defineProps<{
dateStr: string
lastSaveStateTimestamp: number
student: EntriesResponseStudent
disabled?: boolean
}>()
defineEmits<{
@ -31,15 +16,30 @@ defineEmits<{
const model = defineModel<Entry | null>({
required: false,
})
const checklistItemsByCategory: Ref<Record<string, EntryChecklistItem[]>> = computed(() => {
if (!model.value) return {}
const rec: Record<string, EntryChecklistItem[]> = {}
for (const item of model.value.checklistItems) {
if (!(item.category in rec)) {
rec[item.category] = []
}
rec[item.category].push(item)
}
return rec
})
const initialEntryJson: Ref<string> = ref('')
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value)
const hasComment = computed(() => model.value && (model.value.comment.trim().length > 0 || model.value.checklistItems.length > 0))
const hasComment = computed(() => model.value
&& (
model.value.comment.trim().length > 0 ||
model.value.checklistItems.filter(ck => ck.checked).length > 0
))
const previousCommentValue: Ref<string> = ref('')
const previousCommentChecklistItems: Ref<string[]> = ref([])
const commentEditorDialog = useTemplateRef('commentEditorDialog')
const previousCommentChecklistItems: Ref<EntryChecklistItem[]> = ref([])
const cellEditorDialog = useTemplateRef('cellEditorDialog')
onMounted(() => {
initialEntryJson.value = JSON.stringify(model.value)
@ -57,65 +57,43 @@ function toggleAbsence() {
model.value.absent = !model.value.absent
if (model.value.absent) {
// Remove additional data if student is absent.
model.value.classroomReadiness = null
model.value.behaviorRating = null
model.value.checklistItems = []
} else {
// Populate default additional data if student is no longer absent.
model.value.classroomReadiness = true
model.value.behaviorRating = 3
// If we have an initial entry known, restore data from that.
if (initialEntryJson.value) {
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
if (initialEntry === null) return
if (initialEntry.absent) return
if (initialEntry.classroomReadiness) {
model.value.classroomReadiness = initialEntry.classroomReadiness
}
if (initialEntry.phoneCompliant) {
model.value.phoneCompliant = initialEntry.phoneCompliant
}
if (initialEntry.behaviorRating) {
model.value.behaviorRating = initialEntry.behaviorRating
if (initialEntry.checklistItems.length > 0) {
model.value.checklistItems.push(...initialEntry.checklistItems)
}
}
}
}
}
function togglePhoneCompliance() {
if (model.value && model.value.phoneCompliant !== null && !props.disabled) {
model.value.phoneCompliant = !model.value.phoneCompliant
}
}
function toggleClassroomReadiness() {
if (model.value && model.value.classroomReadiness !== null && !props.disabled) {
model.value.classroomReadiness = !model.value.classroomReadiness
}
}
function toggleBehaviorRating() {
if (model.value && model.value.behaviorRating && !props.disabled) {
model.value.behaviorRating = model.value.behaviorRating - 1
if (model.value.behaviorRating < 1) {
model.value.behaviorRating = 3
}
}
}
function showCommentEditor() {
function showEditor() {
if (!model.value) return
previousCommentValue.value = model.value?.comment
previousCommentChecklistItems.value = [...model.value?.checklistItems]
commentEditorDialog.value?.showModal()
previousCommentChecklistItems.value = JSON.parse(JSON.stringify(model.value.checklistItems))
cellEditorDialog.value?.showModal()
}
function cancelCommentEdit() {
function clearEditor() {
if (!model.value) return
model.value.comment = ''
for (const ck of model.value.checklistItems) {
ck.checked = false
}
}
function cancelEdit() {
if (model.value) {
model.value.comment = previousCommentValue.value
model.value.checklistItems = previousCommentChecklistItems.value
}
commentEditorDialog.value?.close()
cellEditorDialog.value?.close()
}
function removeEntry() {
@ -143,11 +121,7 @@ function addEntry() {
<span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span>
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
</div>
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="togglePhoneCompliance"
v-if="!model.absent && model.phoneCompliant !== null">
<span v-if="model.phoneCompliant" title="Phone Compliant">{{ EMOJI_PHONE_COMPLIANT }}</span>
<span v-if="!model.phoneCompliant" title="Phone Non-Compliant">{{ EMOJI_PHONE_NONCOMPLIANT }}</span>
</div>
<!--
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleClassroomReadiness"
v-if="!model.absent && model.classroomReadiness !== null">
<span v-if="model.classroomReadiness" title="Ready for Class">{{ EMOJI_CLASSROOM_READY }}</span>
@ -159,7 +133,8 @@ function addEntry() {
<span v-if="model.behaviorRating === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
<span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
</div>
<div class="status-item" @click="showCommentEditor">
-->
<div class="status-item" @click="showEditor">
<span v-if="hasComment"
style="position: relative; float: right; top: 0px; right: 5px; font-size: 6px;">🔴</span>
<span title="Comments">💬</span>
@ -179,23 +154,35 @@ function addEntry() {
</div>
<!-- A comment editor dialog that shows up when the user edits their comment. -->
<dialog ref="commentEditorDialog" v-if="model">
<textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;"
@keydown.enter="commentEditorDialog?.close()" :readonly="disabled"></textarea>
<div>
<div v-for="options, category in COMMENT_CHECKLIST_ITEMS" :key="category">
<h3 v-text="category"></h3>
<label v-for="opt in options" :key="opt">
<input type="checkbox" v-model="model.checklistItems" :value="opt" :disabled="disabled" />
<span v-text="opt"></span>
<dialog ref="cellEditorDialog" v-if="model">
<h2 style="margin-top: 0;">
Entry for {{ student.name }} on {{ getFormattedDate(dateStr) }}
<span v-if="model.absent" style="color: gray;">Absent</span>
</h2>
<!-- First part of the editor has each category of checklist items. -->
<div style="display: flex; flex-direction: row;">
<div v-for="options, category in checklistItemsByCategory" :key="category"
style="flex-grow: 1; margin: 0 1rem;">
<h3 style="margin-top: 0;">{{ category }}</h3>
<label v-for="opt in options" :key="opt.item" style="margin: 0.25rem 0;">
<input type="checkbox" v-model="opt.checked" :value="opt" :disabled="disabled || model.absent" />
<span style="margin-left: 0.25rem;" :style="{ 'color': model.absent ? 'gray' : 'inherit' }">
{{ opt.item }}
</span>
</label>
</div>
</div>
<!-- Then below that, comments. -->
<div>
<h3>Comments</h3>
<textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;"
@keydown.enter="cellEditorDialog?.close()" :readonly="disabled"></textarea>
</div>
<div class="button-bar" style="text-align: right;">
<button type="button" @click="commentEditorDialog?.close()" :disabled="disabled">Confirm</button>
<button type="button" @click="model.comment = ''; model.checklistItems = []; commentEditorDialog?.close()"
:disabled="disabled">Clear</button>
<button type="button" @click="cancelCommentEdit">Cancel</button>
<button type="button" @click="cellEditorDialog?.close()" :disabled="disabled">Confirm</button>
<button type="button" @click="clearEditor" :disabled="disabled">Clear</button>
<button type="button" @click="cancelEdit">Cancel</button>
</div>
</dialog>
</td>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { formatScorePercent } from '@/util';
defineProps<{
score: number | null
}>()
@ -6,7 +8,7 @@ defineProps<{
<template>
<td style="text-align: right; padding-right: 0.25em;">
<span v-if="score !== null" class="text-mono">
{{ (score * 100).toFixed(1) }}%
{{ formatScorePercent(score) }}
</span>
<span v-if="score === null" style="font-style: italic; color: gray;">No score</span>
</td>

12
app/src/util.ts Normal file
View File

@ -0,0 +1,12 @@
export function getFormattedDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00')
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
return days[d.getDay()] + ', ' + d.toLocaleDateString()
}
export function formatScorePercent(score: number | null | undefined): string {
if (score === null || score === undefined) {
return '-'
}
return (score * 100).toFixed(1) + '%'
}