Refactored grading entirely.
This commit is contained in:
parent
b1f9cfa710
commit
38b34c0598
|
@ -32,7 +32,6 @@ CREATE TABLE classroom_compliance_student_label (
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE classroom_compliance_entry (
|
CREATE TABLE classroom_compliance_entry (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
class_id BIGINT NOT NULL
|
class_id BIGINT NOT NULL
|
||||||
REFERENCES classroom_compliance_class(id)
|
REFERENCES classroom_compliance_class(id)
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
@ -44,23 +43,20 @@ CREATE TABLE classroom_compliance_entry (
|
||||||
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
|
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
|
||||||
absent BOOLEAN NOT NULL DEFAULT FALSE,
|
absent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
comment VARCHAR(2000) NOT NULL DEFAULT '',
|
comment VARCHAR(2000) NOT NULL DEFAULT '',
|
||||||
phone_compliant BOOLEAN NULL DEFAULT NULL,
|
PRIMARY KEY (class_id, student_id, date)
|
||||||
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)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE classroom_compliance_entry_comment_checklist (
|
CREATE TABLE classroom_compliance_entry_checklist_item (
|
||||||
entry_id BIGINT NOT NULL
|
class_id BIGINT NOT NULL,
|
||||||
REFERENCES classroom_compliance_entry(id)
|
student_id BIGINT NOT NULL,
|
||||||
ON UPDATE CASCADE ON DELETE CASCADE,
|
date DATE NOT NULL,
|
||||||
item VARCHAR(2000) 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 (
|
CREATE TABLE classroom_compliance_class_note (
|
||||||
|
|
|
@ -50,7 +50,7 @@ void getClasses(ref HttpRequestContext ctx) {
|
||||||
SELECT
|
SELECT
|
||||||
c.id, c.number, c.school_year, c.archived,
|
c.id, c.number, c.school_year, c.archived,
|
||||||
COUNT(DISTINCT s.id) AS student_count,
|
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
|
MAX(e.date) AS last_entry_date
|
||||||
FROM classroom_compliance_class c
|
FROM classroom_compliance_class c
|
||||||
LEFT JOIN classroom_compliance_student s ON c.id = s.class_id
|
LEFT JOIN classroom_compliance_student s ON c.id = s.class_id
|
||||||
|
|
|
@ -16,39 +16,32 @@ import api_modules.classroom_compliance.score;
|
||||||
import db;
|
import db;
|
||||||
import data_utils;
|
import data_utils;
|
||||||
|
|
||||||
|
struct EntriesTableEntryChecklistItem {
|
||||||
|
string item;
|
||||||
|
bool checked;
|
||||||
|
string category;
|
||||||
|
}
|
||||||
|
|
||||||
struct EntriesTableEntry {
|
struct EntriesTableEntry {
|
||||||
ulong id;
|
|
||||||
Date date;
|
Date date;
|
||||||
ulong createdAt;
|
ulong createdAt;
|
||||||
bool absent;
|
bool absent;
|
||||||
string comment;
|
string comment;
|
||||||
string[] checklistItems;
|
EntriesTableEntryChecklistItem[] checklistItems;
|
||||||
Optional!bool phoneCompliant;
|
|
||||||
Optional!bool classroomReadiness;
|
|
||||||
Optional!ubyte behaviorRating;
|
|
||||||
|
|
||||||
JSONValue toJsonObj() const {
|
JSONValue toJsonObj() const {
|
||||||
JSONValue obj = JSONValue.emptyObject;
|
JSONValue obj = JSONValue.emptyObject;
|
||||||
obj.object["id"] = JSONValue(id);
|
|
||||||
obj.object["date"] = JSONValue(date.toISOExtString());
|
obj.object["date"] = JSONValue(date.toISOExtString());
|
||||||
obj.object["createdAt"] = JSONValue(createdAt);
|
obj.object["createdAt"] = JSONValue(createdAt);
|
||||||
obj.object["absent"] = JSONValue(absent);
|
obj.object["absent"] = JSONValue(absent);
|
||||||
obj.object["comment"] = JSONValue(comment);
|
obj.object["comment"] = JSONValue(comment);
|
||||||
obj.object["checklistItems"] = JSONValue.emptyArray;
|
obj.object["checklistItems"] = JSONValue.emptyArray;
|
||||||
foreach (ck; checklistItems) {
|
foreach (ck; checklistItems) {
|
||||||
obj.object["checklistItems"].array ~= JSONValue(ck);
|
JSONValue ckObj = JSONValue.emptyObject;
|
||||||
}
|
ckObj.object["item"] = JSONValue(ck.item);
|
||||||
if (absent) {
|
ckObj.object["checked"] = JSONValue(ck.checked);
|
||||||
obj.object["phoneCompliant"] = JSONValue(null);
|
ckObj.object["category"] = JSONValue(ck.category);
|
||||||
obj.object["classroomReadiness"] = JSONValue(null);
|
obj.object["checklistItems"].array ~= ckObj;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
@ -129,38 +122,7 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
Optional!double.empty
|
Optional!double.empty
|
||||||
)).array;
|
)).array;
|
||||||
|
|
||||||
const entriesQuery = "
|
const entriesQuery = import("source/api_modules/classroom_compliance/queries/find_entries_by_class.sql");
|
||||||
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
|
|
||||||
";
|
|
||||||
PreparedStatement ps = conn.prepareStatement(entriesQuery);
|
PreparedStatement ps = conn.prepareStatement(entriesQuery);
|
||||||
scope(exit) ps.close();
|
scope(exit) ps.close();
|
||||||
ps.setUlong(1, cls.id);
|
ps.setUlong(1, cls.id);
|
||||||
|
@ -168,65 +130,71 @@ void getEntries(ref HttpRequestContext ctx) {
|
||||||
ps.setDate(3, dateRange.to);
|
ps.setDate(3, dateRange.to);
|
||||||
ResultSet rs = ps.executeQuery();
|
ResultSet rs = ps.executeQuery();
|
||||||
scope(exit) rs.close();
|
scope(exit) rs.close();
|
||||||
foreach (DataSetReader r; rs) {
|
|
||||||
// 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
|
|
||||||
);
|
|
||||||
ClassroomComplianceStudent student = ClassroomComplianceStudent(
|
|
||||||
r.getUlong(10),
|
|
||||||
r.getString(11),
|
|
||||||
r.getUlong(14),
|
|
||||||
r.getUshort(12),
|
|
||||||
r.getBoolean(13)
|
|
||||||
);
|
|
||||||
string dateStr = entryData.date.toISOExtString();
|
|
||||||
|
|
||||||
// Find the student object this entry belongs to, then add it to their list.
|
ClassroomComplianceStudent student;
|
||||||
bool studentFound = false;
|
EntriesTableEntry entry;
|
||||||
foreach (ref studentObj; studentObjects) {
|
bool hasNextRow = rs.next();
|
||||||
if (studentObj.id == student.id) {
|
|
||||||
studentObj.entries[dateStr] = entryData;
|
while (hasNextRow) {
|
||||||
studentFound = true;
|
// Parse the basic data from the query.
|
||||||
break;
|
student.id = rs.getUlong(1);
|
||||||
}
|
student.name = rs.getString(2);
|
||||||
}
|
student.classId = cls.id;
|
||||||
if (!studentFound) {
|
student.deskNumber = rs.getUshort(3);
|
||||||
// The student isn't in our list of original students from the
|
student.removed = rs.getBoolean(4);
|
||||||
// class, so it's a student who has since moved to another class.
|
|
||||||
// Their data should still be shown, so add the student here.
|
entry.date = rs.getDate(5);
|
||||||
studentObjects ~= EntriesTableStudentResponse(
|
entry.createdAt = rs.getUlong(6);
|
||||||
student.id,
|
entry.absent = rs.getBoolean(7);
|
||||||
student.classId,
|
entry.comment = rs.getString(8);
|
||||||
student.name,
|
|
||||||
student.deskNumber,
|
bool hasChecklistItem = !rs.isNull(9);
|
||||||
student.removed,
|
if (hasChecklistItem) {
|
||||||
[dateStr: entryData],
|
entry.checklistItems ~= EntriesTableEntryChecklistItem(
|
||||||
Optional!double.empty
|
rs.getString(9),
|
||||||
|
rs.getBoolean(10),
|
||||||
|
rs.getString(11)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
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] = entry;
|
||||||
|
studentFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!studentFound) {
|
||||||
|
// The student isn't in our list of original students from the
|
||||||
|
// class, so it's a student who has since moved to another class.
|
||||||
|
// Their data should still be shown, so add the student here.
|
||||||
|
studentObjects ~= EntriesTableStudentResponse(
|
||||||
|
student.id,
|
||||||
|
student.classId,
|
||||||
|
student.name,
|
||||||
|
student.deskNumber,
|
||||||
|
student.removed,
|
||||||
|
[dateStr: entry],
|
||||||
|
Optional!double.empty
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, reset the entry's list of checklist items.
|
||||||
|
entry.checklistItems = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find scores for each student for this timeframe.
|
// Find scores for each student for this timeframe.
|
||||||
|
@ -286,45 +254,10 @@ void saveEntries(ref HttpRequestContext ctx) {
|
||||||
ulong studentId = studentObj.object["id"].integer();
|
ulong studentId = studentObj.object["id"].integer();
|
||||||
JSONValue entries = studentObj.object["entries"];
|
JSONValue entries = studentObj.object["entries"];
|
||||||
foreach (string dateStr, JSONValue entry; entries.object) {
|
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);
|
deleteEntry(conn, cls.id, studentId, dateStr);
|
||||||
continue;
|
if (!entry.isNull) {
|
||||||
}
|
insertEntry(conn, cls.id, studentId, dateStr, entry);
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -357,10 +290,9 @@ private void deleteEntry(
|
||||||
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
|
||||||
classId, studentId, dateStr
|
classId, studentId, dateStr
|
||||||
);
|
);
|
||||||
infoF!"Deleted entry for student %s on %s"(studentId, dateStr);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void insertNewEntry(
|
private void insertEntry(
|
||||||
Connection conn,
|
Connection conn,
|
||||||
ulong classId,
|
ulong classId,
|
||||||
ulong studentId,
|
ulong studentId,
|
||||||
|
@ -370,25 +302,29 @@ private void insertNewEntry(
|
||||||
bool absent = payload.object["absent"].boolean;
|
bool absent = payload.object["absent"].boolean;
|
||||||
string comment = payload.object["comment"].str;
|
string comment = payload.object["comment"].str;
|
||||||
if (comment is null) comment = "";
|
if (comment is null) comment = "";
|
||||||
string[] checklistItems;
|
EntriesTableEntryChecklistItem[] checklistItems;
|
||||||
if ("checklistItems" in payload.object) {
|
if ("checklistItems" in payload.object) {
|
||||||
checklistItems = payload.object["checklistItems"].array
|
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;
|
.array;
|
||||||
}
|
}
|
||||||
Optional!bool classroomReadiness = Optional!bool.empty;
|
// If absent, ensure no checklist items may be checked.
|
||||||
Optional!ubyte behaviorRating = Optional!ubyte.empty;
|
if (absent) {
|
||||||
if (!absent) {
|
foreach (ref ck; checklistItems) {
|
||||||
classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean);
|
ck.checked = false;
|
||||||
behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer);
|
}
|
||||||
}
|
}
|
||||||
// Do the main insert first.
|
// Do the main insert first.
|
||||||
import std.variant;
|
|
||||||
Variant newEntryId;
|
|
||||||
const query = "
|
const query = "
|
||||||
INSERT INTO classroom_compliance_entry
|
INSERT INTO classroom_compliance_entry
|
||||||
(class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
|
(class_id, student_id, date, absent, comment)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
PreparedStatement ps = conn.prepareStatement(query);
|
PreparedStatement ps = conn.prepareStatement(query);
|
||||||
scope(exit) ps.close();
|
scope(exit) ps.close();
|
||||||
ps.setUlong(1, classId);
|
ps.setUlong(1, classId);
|
||||||
|
@ -396,84 +332,23 @@ private void insertNewEntry(
|
||||||
ps.setString(3, dateStr);
|
ps.setString(3, dateStr);
|
||||||
ps.setBoolean(4, absent);
|
ps.setBoolean(4, absent);
|
||||||
ps.setString(5, comment);
|
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();
|
ps.executeUpdate();
|
||||||
updateEntryCommentChecklistItems(conn, entryId, checklistItems);
|
// Now insert checklist items, if any.
|
||||||
|
if (checklistItems.length > 0) {
|
||||||
infoF!"Updated entry %d"(entryId);
|
const ckQuery = "
|
||||||
}
|
INSERT INTO classroom_compliance_entry_checklist_item
|
||||||
|
(class_id, student_id, date, item, checked, category)
|
||||||
private void updateEntryCommentChecklistItems(Connection conn, ulong entryId, string[] items) {
|
VALUES (?, ?, ?, ?, ?, ?)";
|
||||||
PreparedStatement ps1 = conn.prepareStatement(
|
PreparedStatement ckPs = conn.prepareStatement(ckQuery);
|
||||||
"DELETE FROM classroom_compliance_entry_comment_checklist WHERE entry_id = ?"
|
scope(exit) ckPs.close();
|
||||||
);
|
foreach (ck; checklistItems) {
|
||||||
scope(exit) ps1.close();
|
ckPs.setUlong(1, classId);
|
||||||
ps1.setUlong(1, entryId);
|
ckPs.setUlong(2, studentId);
|
||||||
ps1.executeUpdate();
|
ckPs.setString(3, dateStr);
|
||||||
const checklistItemsQuery = "
|
ckPs.setString(4, ck.item);
|
||||||
INSERT INTO classroom_compliance_entry_comment_checklist (entry_id, item)
|
ckPs.setBoolean(5, ck.checked);
|
||||||
VALUES (?, ?)
|
ckPs.setString(6, ck.category);
|
||||||
";
|
ckPs.executeUpdate();
|
||||||
PreparedStatement ps2 = conn.prepareStatement(checklistItemsQuery);
|
}
|
||||||
scope(exit) ps2.close();
|
|
||||||
foreach (item; items) {
|
|
||||||
ps2.setUlong(1, entryId);
|
|
||||||
ps2.setString(2, item);
|
|
||||||
ps2.executeUpdate();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,9 +28,6 @@ void getFullExport(ref HttpRequestContext ctx) {
|
||||||
e.date AS entry_date,
|
e.date AS entry_date,
|
||||||
e.created_at AS entry_created_at,
|
e.created_at AS entry_created_at,
|
||||||
e.absent AS absent,
|
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
|
e.comment AS comment
|
||||||
FROM classroom_compliance_class c
|
FROM classroom_compliance_class c
|
||||||
LEFT JOIN classroom_compliance_student s
|
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 Date", (r, i) => r.getDate(i).toISOExtString()),
|
||||||
CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
|
CSVColumnDef("Entry Created At", (r, i) => formatCreationTimestamp(r.getUlong(i))),
|
||||||
CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
|
CSVColumnDef("Absence", (r, i) => r.getBoolean(i) ? "Absent" : "Present"),
|
||||||
CSVColumnDef("Phone Compliant", &formatPhoneCompliance),
|
|
||||||
CSVColumnDef("Classroom Readiness", &formatClassroomReadiness),
|
|
||||||
CSVColumnDef("Behavior Rating", &formatBehaviorRating),
|
|
||||||
CSVColumnDef("Comment", &formatComment)
|
CSVColumnDef("Comment", &formatComment)
|
||||||
];
|
];
|
||||||
// Write headers first.
|
// Write headers first.
|
||||||
|
|
|
@ -157,26 +157,55 @@ void getStudentEntries(ref HttpRequestContext ctx) {
|
||||||
scope(exit) conn.close();
|
scope(exit) conn.close();
|
||||||
User user = getUserOrThrow(ctx, conn);
|
User user = getUserOrThrow(ctx, conn);
|
||||||
auto student = getStudentOrThrow(ctx, conn, user);
|
auto student = getStudentOrThrow(ctx, conn, user);
|
||||||
auto entries = findAll(
|
|
||||||
conn,
|
const query = import("source/api_modules/classroom_compliance/queries/find_entries_by_student.sql");
|
||||||
"SELECT id, class_id, student_id, date, created_at,
|
PreparedStatement ps = conn.prepareStatement(query);
|
||||||
absent, comment,
|
scope(exit) ps.close();
|
||||||
(
|
ps.setUlong(1, student.id);
|
||||||
SELECT STRING_AGG(ck.item, '|||')
|
ResultSet rs = ps.executeQuery();
|
||||||
FROM classroom_compliance_entry_comment_checklist ck
|
|
||||||
WHERE ck.entry_id = id
|
ClassroomComplianceEntry entry;
|
||||||
),
|
ClassroomComplianceEntryChecklistItem[] checklistItems;
|
||||||
phone_compliant, classroom_readiness, behavior_rating
|
JSONValue responseArray = JSONValue.emptyArray;
|
||||||
FROM classroom_compliance_entry
|
|
||||||
WHERE student_id = ?
|
bool hasNextRow = rs.next();
|
||||||
GROUP BY classroom_compliance_entry.id
|
while (hasNextRow) {
|
||||||
ORDER BY date DESC",
|
entry.date = rs.getDate(1);
|
||||||
&ClassroomComplianceEntry.parse,
|
entry.classId = rs.getUlong(2);
|
||||||
student.id
|
entry.createdAt = rs.getUlong(3);
|
||||||
);
|
entry.absent = rs.getBoolean(4);
|
||||||
JSONValue response = JSONValue.emptyArray;
|
entry.comment = rs.getString(5);
|
||||||
foreach (entry; entries) response.array ~= entry.toJsonObj();
|
entry.studentId = student.id;
|
||||||
ctx.response.writeBodyString(response.toJSON(), "application/json");
|
bool hasChecklistItem = !rs.isNull(6);
|
||||||
|
if (hasChecklistItem) {
|
||||||
|
checklistItems ~= ClassroomComplianceEntryChecklistItem(
|
||||||
|
rs.getString(6),
|
||||||
|
rs.getBoolean(7),
|
||||||
|
rs.getString(8)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
void getStudentOverview(ref HttpRequestContext ctx) {
|
||||||
|
@ -187,7 +216,9 @@ void getStudentOverview(ref HttpRequestContext ctx) {
|
||||||
|
|
||||||
const ulong entryCount = count(
|
const ulong entryCount = count(
|
||||||
conn,
|
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
|
student.id
|
||||||
);
|
);
|
||||||
if (entryCount == 0) {
|
if (entryCount == 0) {
|
||||||
|
@ -197,37 +228,20 @@ void getStudentOverview(ref HttpRequestContext ctx) {
|
||||||
}
|
}
|
||||||
const ulong absenceCount = count(
|
const ulong absenceCount = count(
|
||||||
conn,
|
conn,
|
||||||
"SELECT COUNT(id) FROM classroom_compliance_entry WHERE student_id = ? AND absent = true",
|
"SELECT COUNT(DISTINCT(class_id, date))
|
||||||
|
FROM classroom_compliance_entry
|
||||||
|
WHERE student_id = ? AND absent = true",
|
||||||
student.id
|
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)
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Calculate derived statistics.
|
// Calculate derived statistics.
|
||||||
const ulong attendanceCount = entryCount - absenceCount;
|
const ulong attendanceCount = entryCount - absenceCount;
|
||||||
double attendanceRate = attendanceCount / cast(double) entryCount;
|
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;
|
JSONValue response = JSONValue.emptyObject;
|
||||||
response.object["attendanceRate"] = JSONValue(attendanceRate);
|
response.object["attendanceRate"] = JSONValue(attendanceRate);
|
||||||
response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate);
|
// response.object["phoneComplianceRate"] = JSONValue(phoneComplianceRate);
|
||||||
response.object["behaviorScore"] = JSONValue(behaviorScore);
|
// response.object["behaviorScore"] = JSONValue(behaviorScore);
|
||||||
response.object["entryCount"] = JSONValue(entryCount);
|
response.object["entryCount"] = JSONValue(entryCount);
|
||||||
ctx.response.writeBodyString(response.toJSON(), "application/json");
|
ctx.response.writeBodyString(response.toJSON(), "application/json");
|
||||||
}
|
}
|
||||||
|
|
|
@ -47,52 +47,19 @@ struct ClassroomComplianceStudent {
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ClassroomComplianceEntry {
|
struct ClassroomComplianceEntry {
|
||||||
ulong id;
|
|
||||||
ulong classId;
|
ulong classId;
|
||||||
ulong studentId;
|
ulong studentId;
|
||||||
Date date;
|
Date date;
|
||||||
|
|
||||||
ulong createdAt;
|
ulong createdAt;
|
||||||
bool absent;
|
bool absent;
|
||||||
string comment;
|
string comment;
|
||||||
string[] checklistItems;
|
}
|
||||||
Optional!bool phoneCompliant;
|
|
||||||
Optional!bool classroomReadiness;
|
|
||||||
Optional!ubyte behaviorRating;
|
|
||||||
|
|
||||||
static ClassroomComplianceEntry parse(DataSetReader r) {
|
struct ClassroomComplianceEntryChecklistItem {
|
||||||
ClassroomComplianceEntry entry;
|
string item;
|
||||||
entry.id = r.getUlong(1);
|
bool checked;
|
||||||
entry.classId = r.getUlong(2);
|
string category;
|
||||||
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 ClassroomComplianceClassNote {
|
struct ClassroomComplianceClassNote {
|
||||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -18,6 +18,16 @@ private struct ScoringParameters {
|
||||||
DateRange dateRange;
|
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.
|
* Gets an associative array that maps student ids to their (optional) scores.
|
||||||
* Scores are calculated based on aggregate statistics from their entries. A
|
* Scores are calculated based on aggregate statistics from their entries. A
|
||||||
|
@ -48,23 +58,7 @@ Optional!double[ulong] getScores(
|
||||||
params.dateRange.to
|
params.dateRange.to
|
||||||
);
|
);
|
||||||
|
|
||||||
const query = "
|
const query = import("source/api_modules/classroom_compliance/queries/find_scoring_parameters.sql");
|
||||||
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
|
|
||||||
";
|
|
||||||
PreparedStatement ps = conn.prepareStatement(query);
|
PreparedStatement ps = conn.prepareStatement(query);
|
||||||
scope(exit) ps.close();
|
scope(exit) ps.close();
|
||||||
ps.setDate(1, params.dateRange.from);
|
ps.setDate(1, params.dateRange.from);
|
||||||
|
@ -72,22 +66,20 @@ Optional!double[ulong] getScores(
|
||||||
ps.setUlong(3, classId);
|
ps.setUlong(3, classId);
|
||||||
foreach (DataSetReader r; ps.executeQuery()) {
|
foreach (DataSetReader r; ps.executeQuery()) {
|
||||||
ulong studentId = r.getUlong(1);
|
ulong studentId = r.getUlong(1);
|
||||||
uint entryCount = r.getUint(2);
|
ulong entryCount = r.getUlong(2);
|
||||||
uint absenceCount = r.getUint(3);
|
ChecklistItemCategoryStats[string] categoryStats;
|
||||||
uint phoneNonComplianceCount = r.getUint(4);
|
categoryStats["classroom_readiness"] = ChecklistItemCategoryStats(
|
||||||
uint notClassroomReadyCount = r.getUint(5);
|
r.getUlong(3),
|
||||||
uint behaviorGoodCount = r.getUint(6);
|
r.getUlong(4)
|
||||||
uint behaviorMediocreCount = r.getUint(7);
|
);
|
||||||
uint behaviorPoorCount = r.getUint(8);
|
categoryStats["behavior"] = ChecklistItemCategoryStats(
|
||||||
|
r.getUlong(5),
|
||||||
|
r.getUlong(6)
|
||||||
|
);
|
||||||
scores[studentId] = calculateScore(
|
scores[studentId] = calculateScore(
|
||||||
params.expr,
|
params.expr,
|
||||||
entryCount,
|
entryCount,
|
||||||
absenceCount,
|
categoryStats
|
||||||
phoneNonComplianceCount,
|
|
||||||
notClassroomReadyCount,
|
|
||||||
behaviorGoodCount,
|
|
||||||
behaviorMediocreCount,
|
|
||||||
behaviorPoorCount
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return scores;
|
return scores;
|
||||||
|
@ -132,49 +124,32 @@ private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period)
|
||||||
*/
|
*/
|
||||||
private Optional!double calculateScore(
|
private Optional!double calculateScore(
|
||||||
in Expr scoreExpression,
|
in Expr scoreExpression,
|
||||||
uint entryCount,
|
ulong entryCount,
|
||||||
uint absenceCount,
|
in ChecklistItemCategoryStats[string] categoryStats
|
||||||
uint phoneNonComplianceCount,
|
|
||||||
uint notClassroomReadyCount,
|
|
||||||
uint behaviorGoodCount,
|
|
||||||
uint behaviorMediocreCount,
|
|
||||||
uint behaviorPoorCount
|
|
||||||
) {
|
) {
|
||||||
if (
|
if (entryCount == 0) return Optional!double.empty;
|
||||||
entryCount == 0
|
|
||||||
|| entryCount <= absenceCount
|
|
||||||
) return Optional!double.empty;
|
|
||||||
|
|
||||||
const uint presentCount = entryCount - absenceCount;
|
double classroomReadinessScore = 0;
|
||||||
|
if ("classroom_readiness" in categoryStats) {
|
||||||
// Phone subscore:
|
classroomReadinessScore = categoryStats["classroom_readiness"].getUncheckedRatio();
|
||||||
uint phoneCompliantCount;
|
|
||||||
if (presentCount < phoneNonComplianceCount) {
|
|
||||||
phoneCompliantCount = 0;
|
|
||||||
} else {
|
|
||||||
phoneCompliantCount = presentCount - phoneNonComplianceCount;
|
|
||||||
}
|
}
|
||||||
double phoneScore = phoneCompliantCount / cast(double) presentCount;
|
|
||||||
// Classroom readiness score:
|
double behaviorScore = 0;
|
||||||
uint classroomReadyCount;
|
if ("behavior" in categoryStats) {
|
||||||
if (presentCount < notClassroomReadyCount) {
|
behaviorScore = categoryStats["behavior"].getUncheckedRatio();
|
||||||
classroomReadyCount = 0;
|
|
||||||
} else {
|
|
||||||
classroomReadyCount = presentCount - notClassroomReadyCount;
|
|
||||||
}
|
}
|
||||||
double classroomReadinessScore = classroomReadyCount / cast(double) presentCount;
|
|
||||||
|
|
||||||
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
|
try {
|
||||||
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
|
double score = scoreExpression.eval([
|
||||||
double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount;
|
"classroom_readiness": classroomReadinessScore,
|
||||||
double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore);
|
"behavior": behaviorScore,
|
||||||
|
]);
|
||||||
return Optional!double.of(scoreExpression.eval([
|
import std.math : isNaN;
|
||||||
"phone": phoneScore,
|
if (isNaN(score)) return Optional!double.empty;
|
||||||
"classroom_readiness": classroomReadinessScore,
|
return Optional!double.of(score);
|
||||||
"behavior": typicalBehaviorScore,
|
} catch (ExpressionEvaluationException e) {
|
||||||
"behavior_good": behaviorGoodScore,
|
import handy_httpd;
|
||||||
"behavior_mediocre": behaviorMediocreScore,
|
throw new HttpStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to compute score: " ~ e.msg);
|
||||||
"behavior_poor": behaviorPoorScore
|
}
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,18 +80,7 @@ void addClassroomComplianceSampleData(ref Random rand, ulong adminUserId, Connec
|
||||||
bool missingEntry = uniform01(rand) < 0.05;
|
bool missingEntry = uniform01(rand) < 0.05;
|
||||||
if (missingEntry) continue;
|
if (missingEntry) continue;
|
||||||
|
|
||||||
bool absent = uniform01(rand) < 0.05;
|
addEntry(conn, classId, studentId, entryDate, rand);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -130,35 +119,49 @@ void addEntry(
|
||||||
ulong classId,
|
ulong classId,
|
||||||
ulong studentId,
|
ulong studentId,
|
||||||
Date date,
|
Date date,
|
||||||
bool absent,
|
ref Random rand
|
||||||
bool phoneCompliant,
|
|
||||||
ubyte behaviorRating,
|
|
||||||
string comment
|
|
||||||
) {
|
) {
|
||||||
|
bool absent = uniform01(rand) < 0.05;
|
||||||
|
bool hasComment = uniform01(rand) < 0.25;
|
||||||
|
string comment = hasComment ? "This is a sample comment." : "";
|
||||||
|
|
||||||
const entryQuery = "
|
const entryQuery = "
|
||||||
INSERT INTO classroom_compliance_entry
|
INSERT INTO classroom_compliance_entry
|
||||||
(class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
|
(class_id, student_id, date, absent, comment)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
|
VALUES (?, ?, ?, ?, ?)";
|
||||||
PreparedStatement ps = conn.prepareStatement(entryQuery);
|
PreparedStatement ps = conn.prepareStatement(entryQuery);
|
||||||
scope(exit) ps.close();
|
scope(exit) ps.close();
|
||||||
ps.setUlong(1, classId);
|
ps.setUlong(1, classId);
|
||||||
ps.setUlong(2, studentId);
|
ps.setUlong(2, studentId);
|
||||||
ps.setDate(3, date);
|
ps.setDate(3, date);
|
||||||
ps.setBoolean(4, absent);
|
ps.setBoolean(4, absent);
|
||||||
if (comment is null) {
|
ps.setString(5, comment);
|
||||||
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();
|
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) {
|
void deleteAllData(Connection conn) {
|
||||||
|
@ -168,7 +171,7 @@ void deleteAllData(Connection conn) {
|
||||||
const tables = [
|
const tables = [
|
||||||
"announcement",
|
"announcement",
|
||||||
"classroom_compliance_class_note",
|
"classroom_compliance_class_note",
|
||||||
"classroom_compliance_entry_comment_checklist",
|
"classroom_compliance_entry_checklist_item",
|
||||||
"classroom_compliance_entry",
|
"classroom_compliance_entry",
|
||||||
"classroom_compliance_student",
|
"classroom_compliance_student",
|
||||||
"classroom_compliance_class",
|
"classroom_compliance_class",
|
||||||
|
|
|
@ -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 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_CLASSROOM_READY = '🍎'
|
||||||
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
|
export const EMOJI_NOT_CLASSROOM_READY = '🐛'
|
||||||
export const EMOJI_PRESENT = '✅'
|
export const EMOJI_PRESENT = '✅'
|
||||||
|
@ -12,6 +10,30 @@ export const EMOJI_BEHAVIOR_GOOD = '😇'
|
||||||
export const EMOJI_BEHAVIOR_MEDIOCRE = '😐'
|
export const EMOJI_BEHAVIOR_MEDIOCRE = '😐'
|
||||||
export const EMOJI_BEHAVIOR_POOR = '😡'
|
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 {
|
export interface Class {
|
||||||
id: number
|
id: number
|
||||||
number: number
|
number: number
|
||||||
|
@ -40,31 +62,55 @@ export interface Student {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Entry {
|
export interface Entry {
|
||||||
id: number
|
|
||||||
date: string
|
date: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
absent: boolean
|
absent: boolean
|
||||||
phoneCompliant: boolean | null
|
|
||||||
classroomReadiness: boolean | null
|
|
||||||
behaviorRating: number | null
|
|
||||||
comment: string
|
comment: string
|
||||||
checklistItems: string[]
|
checklistItems: EntryChecklistItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryChecklistItem {
|
||||||
|
item: string
|
||||||
|
checked: boolean
|
||||||
|
category: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDefaultEntry(dateStr: string): Entry {
|
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 {
|
return {
|
||||||
id: 0,
|
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
absent: false,
|
absent: false,
|
||||||
phoneCompliant: null,
|
|
||||||
classroomReadiness: true,
|
|
||||||
behaviorRating: 3,
|
|
||||||
comment: '',
|
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 {
|
export interface EntriesResponseStudent {
|
||||||
id: number
|
id: number
|
||||||
classId: number
|
classId: number
|
||||||
|
@ -106,8 +152,6 @@ export interface ScoresResponse {
|
||||||
|
|
||||||
export interface StudentStatisticsOverview {
|
export interface StudentStatisticsOverview {
|
||||||
attendanceRate: number
|
attendanceRate: number
|
||||||
phoneComplianceRate: number
|
|
||||||
behaviorScore: number
|
|
||||||
entryCount: number
|
entryCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -136,7 +136,7 @@ function resetScoreParameters() {
|
||||||
|
|
||||||
<EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
|
<EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
|
||||||
|
|
||||||
<div>
|
<div v-if="entriesTable?.selectedView !== 'Whiteboard'">
|
||||||
<h3 style="margin-bottom: 0.25em;">Notes</h3>
|
<h3 style="margin-bottom: 0.25em;">Notes</h3>
|
||||||
<form @submit.prevent="submitNote">
|
<form @submit.prevent="submitNote">
|
||||||
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
|
<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()" />
|
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div v-if="entriesTable?.selectedView !== 'Whiteboard'">
|
||||||
<h3 style="margin-bottom: 0.25em;">Scoring</h3>
|
<h3 style="margin-bottom: 0.25em;">Scoring</h3>
|
||||||
<p style="margin-top: 0.25em; margin-bottom: 0.25em;">
|
<p style="margin-top: 0.25em; margin-bottom: 0.25em;">
|
||||||
Change how scores are calculated for this class here.
|
Change how scores are calculated for this class here.
|
||||||
|
@ -162,24 +162,12 @@ function resetScoreParameters() {
|
||||||
can use the following variables:
|
can use the following variables:
|
||||||
</p>
|
</p>
|
||||||
<ul class="form-input-hint">
|
<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
|
<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>
|
score.</li>
|
||||||
<li><span class="score-expression-variable">behavior_good</span> - The proportion of days that the student had
|
<li><span class="score-expression-variable">behavior</span> - The student's behavior score.</li>
|
||||||
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>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p class="form-input-hint">
|
<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:
|
below would achieve that:
|
||||||
</p>
|
</p>
|
||||||
<p style="font-family: 'SourceCodePro', monospace; font-size: smaller;">
|
<p style="font-family: 'SourceCodePro', monospace; font-size: smaller;">
|
||||||
|
|
|
@ -254,7 +254,8 @@ function addAllEntriesForDate(dateStr: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
loadEntries
|
loadEntries,
|
||||||
|
selectedView
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
|
@ -314,7 +315,7 @@ defineExpose({
|
||||||
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
|
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
|
||||||
<EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date"
|
<EntryTableCell v-for="(entry, date) in getVisibleStudentEntries(student)" :key="date"
|
||||||
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp"
|
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" />
|
<StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" />
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
<script setup lang="ts">
|
<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 EntryTableCell from './entries_table/EntryTableCell.vue';
|
||||||
import { ref, type Ref } from 'vue';
|
import { ref, type Ref } from 'vue';
|
||||||
|
|
||||||
const sampleEntry: Ref<Entry | null> = ref({
|
const sampleEntry: Ref<Entry | null> = ref(getDefaultEntry('2025-01-01'))
|
||||||
id: 1,
|
const sampleStudent: EntriesResponseStudent = {
|
||||||
date: '2025-01-01',
|
classId: 123,
|
||||||
createdAt: new Date().getTime(),
|
deskNumber: 123,
|
||||||
absent: false,
|
id: 123,
|
||||||
phoneCompliant: null,
|
name: "John Smith",
|
||||||
classroomReadiness: true,
|
removed: false,
|
||||||
behaviorRating: 3,
|
score: 0,
|
||||||
comment: '',
|
entries: {}
|
||||||
checklistItems: []
|
}
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -83,19 +82,15 @@ const sampleEntry: Ref<Entry | null> = ref({
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<EntryTableCell date-str="2025-01-01" :last-save-state-timestamp="0" v-model="sampleEntry"
|
<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>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Attendance: {{ EMOJI_PRESENT }} for present, and {{ EMOJI_ABSENT }} for absent.</li>
|
<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
|
<li>If you'd like to add a comment or mark some issue, click 💬 and enter your comment in the popup.</li>
|
||||||
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>Click the 🗑️ to remove the entry.</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>
|
</ul>
|
||||||
<p>
|
<p>
|
||||||
When editing, changed entries are highlighted, and you need to click <strong>Save</strong> or <strong>Discard
|
When editing, changed entries are highlighted, and you need to click <strong>Save</strong> or <strong>Discard
|
||||||
|
|
|
@ -36,7 +36,7 @@ onMounted(() => {
|
||||||
<p v-if="noEntries" class="align-center">
|
<p v-if="noEntries" class="align-center">
|
||||||
This student doesn't have any entries yet.
|
This student doesn't have any entries yet.
|
||||||
</p>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,38 +1,34 @@
|
||||||
<script setup lang="ts">
|
<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<{
|
const props = defineProps<{
|
||||||
entry: Entry
|
entry: Entry
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function getFormattedDate() {
|
const classroomReadinessCompliance = computed(() => getComplianceLevel(props.entry, 'Classroom Readiness'))
|
||||||
const d = new Date(props.entry.date + 'T00:00:00')
|
const behaviorCompliance = computed(() => getComplianceLevel(props.entry, 'Behavior'))
|
||||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
||||||
return days[d.getDay()] + ', ' + d.toLocaleDateString()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="student-entry-item">
|
<div class="student-entry-item">
|
||||||
<h6>{{ getFormattedDate() }}</h6>
|
<h6>{{ getFormattedDate(entry.date) }}</h6>
|
||||||
<div class="icons-container">
|
<div class="icons-container">
|
||||||
<span v-if="entry.absent">{{ EMOJI_ABSENT }}</span>
|
<span v-if="entry.absent">{{ EMOJI_ABSENT }}</span>
|
||||||
<span v-if="!entry.absent">{{ EMOJI_PRESENT }}</span>
|
<span v-if="!entry.absent">{{ EMOJI_PRESENT }}</span>
|
||||||
|
|
||||||
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
|
<span v-if="classroomReadinessCompliance === ComplianceLevel.Good">{{ EMOJI_CLASSROOM_READY }}</span>
|
||||||
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</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="behaviorCompliance === ComplianceLevel.Good">{{ EMOJI_BEHAVIOR_GOOD }}</span>
|
||||||
<span v-if="entry.classroomReadiness === false">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
|
<span v-if="behaviorCompliance === ComplianceLevel.Mediocre">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
||||||
|
<span v-if="behaviorCompliance === ComplianceLevel.Poor">{{ EMOJI_BEHAVIOR_POOR }}</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>
|
|
||||||
</div>
|
</div>
|
||||||
<p v-if="entry.comment.trim().length > 0" class="comment">
|
<p v-if="entry.comment.trim().length > 0" class="comment">
|
||||||
{{ entry.comment }}
|
{{ entry.comment }}
|
||||||
</p>
|
</p>
|
||||||
<ul v-if="entry.checklistItems">
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
<script setup lang="ts">
|
<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 ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import StudentEntriesList from './StudentEntriesList.vue'
|
import StudentEntriesList from './StudentEntriesList.vue'
|
||||||
import { APIError } from '@/api/base'
|
import { APIError } from '@/api/base'
|
||||||
|
import { formatScorePercent, getFormattedDate } from '@/util'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
classId: string
|
classId: string
|
||||||
|
@ -107,12 +108,6 @@ async function deleteThisStudent() {
|
||||||
await router.replace(`/classroom-compliance/classes/${cls.value.id}`)
|
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() {
|
async function addLabel() {
|
||||||
if (!cls.value || !student.value || cls.value.archived) return
|
if (!cls.value || !student.value || cls.value.archived) return
|
||||||
const newLabels = [...labels.value, newLabel.value.trim()]
|
const newLabels = [...labels.value, newLabel.value.trim()]
|
||||||
|
@ -146,7 +141,8 @@ async function deleteLabel(label: string) {
|
||||||
|
|
||||||
<div class="button-bar align-center">
|
<div class="button-bar align-center">
|
||||||
<button type="button"
|
<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="deleteThisStudent" :disabled="cls?.archived">Delete</button>
|
||||||
<button type="button" @click="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week
|
<button type="button" @click="weekOverviewDialog?.showModal()" :disabled="lastWeeksEntries.length < 1">Week
|
||||||
overview</button>
|
overview</button>
|
||||||
|
@ -158,8 +154,8 @@ async function deleteLabel(label: string) {
|
||||||
<tr>
|
<tr>
|
||||||
<th>Desk Number</th>
|
<th>Desk Number</th>
|
||||||
<td>
|
<td>
|
||||||
{{ student.deskNumber }}
|
<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>
|
<span v-if="student.deskNumber === 0" style="font-style: italic; font-size: small;">No assigned desk</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -170,14 +166,6 @@ async function deleteLabel(label: string) {
|
||||||
<th>Attendance Rate</th>
|
<th>Attendance Rate</th>
|
||||||
<td>{{ (statistics.attendanceRate * 100).toFixed(1) }}%</td>
|
<td>{{ (statistics.attendanceRate * 100).toFixed(1) }}%</td>
|
||||||
</tr>
|
</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">
|
<tr v-if="statistics">
|
||||||
<th>Total Entries</th>
|
<th>Total Entries</th>
|
||||||
<td>{{ statistics.entryCount }}</td>
|
<td>{{ statistics.entryCount }}</td>
|
||||||
|
@ -199,24 +187,30 @@ async function deleteLabel(label: string) {
|
||||||
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
|
<dialog ref="weekOverviewDialog" method="dialog" class="weekly-overview-dialog">
|
||||||
<div>
|
<div>
|
||||||
<h2>This week's overview for <span v-text="student.name"></span></h2>
|
<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">
|
<div v-for="entry in lastWeeksEntries" :key="entry.date" class="weekly-overview-dialog-day">
|
||||||
<h4>{{ getFormattedDate(entry) }}</h4>
|
<h4>{{ getFormattedDate(entry.date) }}</h4>
|
||||||
|
|
||||||
<div v-if="entry.classroomReadiness !== null">
|
<div v-if="entry.absent">
|
||||||
<h5>Classroom Readiness</h5>
|
<p style="color: gray;">Absent</p>
|
||||||
<p v-if="entry.classroomReadiness">✅ Ready for class</p>
|
|
||||||
<p v-if="!entry.classroomReadiness">❌ Not ready for class</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="entry.behaviorRating !== null">
|
|
||||||
<h5>Behavior</h5>
|
<div v-if="getComplianceLevel(entry, 'Classroom Readiness') !== null">
|
||||||
<p v-if="entry.behaviorRating === 1">
|
<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
|
Poor
|
||||||
</p>
|
</p>
|
||||||
<p v-if="entry.behaviorRating === 2">
|
<p v-if="getComplianceLevel(entry, 'Behavior') === ComplianceLevel.Mediocre">
|
||||||
Mediocre
|
Mediocre
|
||||||
</p>
|
</p>
|
||||||
<p v-if="entry.behaviorRating === 3">
|
<p v-if="getComplianceLevel(entry, 'Behavior') === ComplianceLevel.Good">
|
||||||
Good
|
Good
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -226,10 +220,12 @@ async function deleteLabel(label: string) {
|
||||||
<p>{{ entry.comment }}</p>
|
<p>{{ entry.comment }}</p>
|
||||||
</div>
|
</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>
|
<h5>Classroom Readiness & Behavioral Issues</h5>
|
||||||
<ul>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,27 +1,12 @@
|
||||||
<script setup lang="ts">
|
<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'
|
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<{
|
const props = defineProps<{
|
||||||
dateStr: string
|
dateStr: string
|
||||||
lastSaveStateTimestamp: number
|
lastSaveStateTimestamp: number
|
||||||
|
student: EntriesResponseStudent
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>()
|
}>()
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
|
@ -31,15 +16,30 @@ defineEmits<{
|
||||||
const model = defineModel<Entry | null>({
|
const model = defineModel<Entry | null>({
|
||||||
required: false,
|
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 initialEntryJson: Ref<string> = ref('')
|
||||||
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
|
const previouslyRemovedEntry: Ref<Entry | null> = ref(null)
|
||||||
|
|
||||||
const entryChanged = computed(() => JSON.stringify(model.value) !== initialEntryJson.value)
|
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 previousCommentValue: Ref<string> = ref('')
|
||||||
const previousCommentChecklistItems: Ref<string[]> = ref([])
|
const previousCommentChecklistItems: Ref<EntryChecklistItem[]> = ref([])
|
||||||
const commentEditorDialog = useTemplateRef('commentEditorDialog')
|
const cellEditorDialog = useTemplateRef('cellEditorDialog')
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initialEntryJson.value = JSON.stringify(model.value)
|
initialEntryJson.value = JSON.stringify(model.value)
|
||||||
|
@ -57,65 +57,43 @@ function toggleAbsence() {
|
||||||
model.value.absent = !model.value.absent
|
model.value.absent = !model.value.absent
|
||||||
if (model.value.absent) {
|
if (model.value.absent) {
|
||||||
// Remove additional data if student is absent.
|
// Remove additional data if student is absent.
|
||||||
model.value.classroomReadiness = null
|
model.value.checklistItems = []
|
||||||
model.value.behaviorRating = null
|
|
||||||
} else {
|
} else {
|
||||||
// Populate default additional data if student is no longer absent.
|
// 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 we have an initial entry known, restore data from that.
|
||||||
if (initialEntryJson.value) {
|
if (initialEntryJson.value) {
|
||||||
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
const initialEntry = JSON.parse(initialEntryJson.value) as Entry
|
||||||
if (initialEntry === null) return
|
if (initialEntry === null) return
|
||||||
if (initialEntry.absent) return
|
if (initialEntry.absent) return
|
||||||
if (initialEntry.classroomReadiness) {
|
if (initialEntry.checklistItems.length > 0) {
|
||||||
model.value.classroomReadiness = initialEntry.classroomReadiness
|
model.value.checklistItems.push(...initialEntry.checklistItems)
|
||||||
}
|
|
||||||
if (initialEntry.phoneCompliant) {
|
|
||||||
model.value.phoneCompliant = initialEntry.phoneCompliant
|
|
||||||
}
|
|
||||||
if (initialEntry.behaviorRating) {
|
|
||||||
model.value.behaviorRating = initialEntry.behaviorRating
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePhoneCompliance() {
|
function showEditor() {
|
||||||
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() {
|
|
||||||
if (!model.value) return
|
if (!model.value) return
|
||||||
previousCommentValue.value = model.value?.comment
|
previousCommentValue.value = model.value?.comment
|
||||||
previousCommentChecklistItems.value = [...model.value?.checklistItems]
|
previousCommentChecklistItems.value = JSON.parse(JSON.stringify(model.value.checklistItems))
|
||||||
commentEditorDialog.value?.showModal()
|
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) {
|
if (model.value) {
|
||||||
model.value.comment = previousCommentValue.value
|
model.value.comment = previousCommentValue.value
|
||||||
model.value.checklistItems = previousCommentChecklistItems.value
|
model.value.checklistItems = previousCommentChecklistItems.value
|
||||||
}
|
}
|
||||||
commentEditorDialog.value?.close()
|
cellEditorDialog.value?.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeEntry() {
|
function removeEntry() {
|
||||||
|
@ -143,11 +121,7 @@ function addEntry() {
|
||||||
<span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span>
|
<span v-if="model.absent" title="Absent">{{ EMOJI_ABSENT }}</span>
|
||||||
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
|
<span v-if="!model.absent" title="Present">{{ EMOJI_PRESENT }}</span>
|
||||||
</div>
|
</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"
|
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleClassroomReadiness"
|
||||||
v-if="!model.absent && model.classroomReadiness !== null">
|
v-if="!model.absent && model.classroomReadiness !== null">
|
||||||
<span v-if="model.classroomReadiness" title="Ready for Class">{{ EMOJI_CLASSROOM_READY }}</span>
|
<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 === 2" title="Mediocre Behavior">{{ EMOJI_BEHAVIOR_MEDIOCRE }}</span>
|
||||||
<span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
<span v-if="model.behaviorRating === 1" title="Poor Behavior">{{ EMOJI_BEHAVIOR_POOR }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status-item" @click="showCommentEditor">
|
-->
|
||||||
|
<div class="status-item" @click="showEditor">
|
||||||
<span v-if="hasComment"
|
<span v-if="hasComment"
|
||||||
style="position: relative; float: right; top: 0px; right: 5px; font-size: 6px;">🔴</span>
|
style="position: relative; float: right; top: 0px; right: 5px; font-size: 6px;">🔴</span>
|
||||||
<span title="Comments">💬</span>
|
<span title="Comments">💬</span>
|
||||||
|
@ -179,23 +154,35 @@ function addEntry() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- A comment editor dialog that shows up when the user edits their comment. -->
|
<!-- A comment editor dialog that shows up when the user edits their comment. -->
|
||||||
<dialog ref="commentEditorDialog" v-if="model">
|
<dialog ref="cellEditorDialog" v-if="model">
|
||||||
<textarea v-model="model.comment" style="min-width: 300px; min-height: 100px;"
|
<h2 style="margin-top: 0;">
|
||||||
@keydown.enter="commentEditorDialog?.close()" :readonly="disabled"></textarea>
|
Entry for {{ student.name }} on {{ getFormattedDate(dateStr) }}
|
||||||
<div>
|
<span v-if="model.absent" style="color: gray;">Absent</span>
|
||||||
<div v-for="options, category in COMMENT_CHECKLIST_ITEMS" :key="category">
|
</h2>
|
||||||
<h3 v-text="category"></h3>
|
<!-- First part of the editor has each category of checklist items. -->
|
||||||
<label v-for="opt in options" :key="opt">
|
<div style="display: flex; flex-direction: row;">
|
||||||
<input type="checkbox" v-model="model.checklistItems" :value="opt" :disabled="disabled" />
|
<div v-for="options, category in checklistItemsByCategory" :key="category"
|
||||||
<span v-text="opt"></span>
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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;">
|
<div class="button-bar" style="text-align: right;">
|
||||||
<button type="button" @click="commentEditorDialog?.close()" :disabled="disabled">Confirm</button>
|
<button type="button" @click="cellEditorDialog?.close()" :disabled="disabled">Confirm</button>
|
||||||
<button type="button" @click="model.comment = ''; model.checklistItems = []; commentEditorDialog?.close()"
|
<button type="button" @click="clearEditor" :disabled="disabled">Clear</button>
|
||||||
:disabled="disabled">Clear</button>
|
<button type="button" @click="cancelEdit">Cancel</button>
|
||||||
<button type="button" @click="cancelCommentEdit">Cancel</button>
|
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { formatScorePercent } from '@/util';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
score: number | null
|
score: number | null
|
||||||
}>()
|
}>()
|
||||||
|
@ -6,7 +8,7 @@ defineProps<{
|
||||||
<template>
|
<template>
|
||||||
<td style="text-align: right; padding-right: 0.25em;">
|
<td style="text-align: right; padding-right: 0.25em;">
|
||||||
<span v-if="score !== null" class="text-mono">
|
<span v-if="score !== null" class="text-mono">
|
||||||
{{ (score * 100).toFixed(1) }}%
|
{{ formatScorePercent(score) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="score === null" style="font-style: italic; color: gray;">No score</span>
|
<span v-if="score === null" style="font-style: italic; color: gray;">No score</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -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) + '%'
|
||||||
|
}
|
Loading…
Reference in New Issue