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 (
|
||||
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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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;">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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