Added changes for new school year.
Build and Test App / Build-and-test-App (push) Failing after 22s Details
Build and Test API / Build-and-test-API (push) Successful in 50s Details

This commit is contained in:
Andrew Lalis 2025-09-01 16:22:29 -04:00
parent 2c08f1bdbd
commit 6177943db7
23 changed files with 476 additions and 140 deletions

View File

@ -6,8 +6,9 @@ CREATE TABLE classroom_compliance_class (
user_id BIGINT NOT NULL
REFERENCES auth_user(id)
ON UPDATE CASCADE ON DELETE CASCADE,
score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * phone + 0.5 * behavior',
score_expression VARCHAR(255) NOT NULL DEFAULT '0.5 * classroom_readiness + 0.5 * behavior',
score_period VARCHAR(64) NOT NULL DEFAULT 'week',
archived BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT unique_class_numbers_per_school_year
UNIQUE(number, school_year, user_id)
);
@ -36,15 +37,24 @@ CREATE TABLE classroom_compliance_entry (
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 phone_compliant IS NULL AND behavior_rating IS NULL) OR
(NOT absent AND phone_compliant IS NOT NULL AND behavior_rating IS NOT NULL)
(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 (
entry_id BIGINT NOT NULL
REFERENCES classroom_compliance_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE,
item VARCHAR(2000) NOT NULL,
PRIMARY KEY (entry_id, item)
);
CREATE TABLE classroom_compliance_class_note (
id BIGSERIAL PRIMARY KEY,
class_id BIGINT NOT NULL

View File

@ -10,9 +10,9 @@ import data_utils;
import api_modules.auth : getAdminUserOrThrow;
struct Announcement {
const ulong id;
const string type;
const string message;
ulong id;
string type;
string message;
static Announcement parse(DataSetReader r) {
return Announcement(

View File

@ -13,12 +13,12 @@ import db;
import data_utils;
struct User {
const ulong id;
const string username;
const string passwordHash;
const ulong createdAt;
const bool isLocked;
const bool isAdmin;
ulong id;
string username;
string passwordHash;
ulong createdAt;
bool isLocked;
bool isAdmin;
static User parse(DataSetReader r) {
return User(

View File

@ -16,6 +16,7 @@ void registerApiEndpoints(PathHandler handler) {
const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong";
handler.addMapping(Method.GET, CLASS_PATH, &getClass);
handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass);
handler.addMapping(Method.POST, CLASS_PATH ~ "/archived", &setArchived);
handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes);
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);

View File

@ -48,7 +48,7 @@ void getClasses(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx, conn);
const query = "
SELECT
c.id, c.number, c.school_year,
c.id, c.number, c.school_year, c.archived,
COUNT(DISTINCT s.id) AS student_count,
COUNT(DISTINCT e.id) AS entry_count,
MAX(e.date) AS last_entry_date
@ -63,6 +63,7 @@ void getClasses(ref HttpRequestContext ctx) {
ulong id;
ushort number;
string schoolYear;
bool archived;
uint studentCount;
uint entryCount;
string lastEntryDate;
@ -74,9 +75,10 @@ void getClasses(ref HttpRequestContext ctx) {
r.getUlong(1),
r.getUshort(2),
r.getString(3),
r.getUint(4),
r.getBoolean(4),
r.getUint(5),
r.getDate(6).toISOExtString()
r.getUint(6),
r.getDate(7).toISOExtString()
),
user.id
);
@ -96,6 +98,7 @@ void deleteClass(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
const query = "DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
@ -119,6 +122,7 @@ void createClassNote(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct Payload {string content;}
Payload payload = readJsonPayload!(Payload)(ctx);
const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id";
@ -137,6 +141,7 @@ void deleteClassNote(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?";
update(conn, query, cls.id, noteId);
@ -147,10 +152,20 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
update(conn, query, cls.id);
}
void setArchived(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
const query = "UPDATE classroom_compliance_class SET archived = ? WHERE id = ?";
update(conn, query, !cls.archived, cls.id);
}
void updateScoreParameters(ref HttpRequestContext ctx) {
import api_modules.classroom_compliance.score_eval : validateExpression;
import std.algorithm : canFind;
@ -158,6 +173,7 @@ void updateScoreParameters(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct Payload {
string scoreExpression;
string scorePeriod;

View File

@ -22,7 +22,9 @@ struct EntriesTableEntry {
ulong createdAt;
bool absent;
string comment;
string[] checklistItems;
Optional!bool phoneCompliant;
Optional!bool classroomReadiness;
Optional!ubyte behaviorRating;
JSONValue toJsonObj() const {
@ -32,12 +34,21 @@ struct EntriesTableEntry {
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 {
obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value);
obj.object["behaviorRating"] = JSONValue(behaviorRating.value);
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;
}
@ -125,7 +136,14 @@ void getEntries(ref HttpRequestContext ctx) {
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,
@ -155,25 +173,34 @@ void getEntries(ref HttpRequestContext ctx) {
const absent = r.getBoolean(4);
Optional!bool phoneCompliant = absent
? Optional!bool.empty
: Optional!bool.of(r.getBoolean(6));
: 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(7));
: 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(8),
r.getString(9),
r.getUlong(12),
r.getUshort(10),
r.getBoolean(11)
r.getUlong(10),
r.getString(11),
r.getUlong(14),
r.getUshort(12),
r.getBoolean(13)
);
string dateStr = entryData.date.toISOExtString();
@ -252,6 +279,7 @@ void saveEntries(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
JSONValue bodyContent = ctx.request.readBodyAsJson();
try {
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
@ -265,7 +293,16 @@ void saveEntries(ref HttpRequestContext ctx) {
Optional!ClassroomComplianceEntry existingEntry = findOne(
conn,
"SELECT * FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
"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
);
@ -333,17 +370,25 @@ private void insertNewEntry(
bool absent = payload.object["absent"].boolean;
string comment = payload.object["comment"].str;
if (comment is null) comment = "";
Optional!bool phoneCompliant = Optional!bool.empty;
string[] checklistItems;
if ("checklistItems" in payload.object) {
checklistItems = payload.object["checklistItems"].array
.map!(v => v.str)
.array;
}
Optional!bool classroomReadiness = Optional!bool.empty;
Optional!ubyte behaviorRating = Optional!ubyte.empty;
if (!absent) {
phoneCompliant = Optional!bool.of(payload.object["phoneCompliant"].boolean);
classroomReadiness = Optional!bool.of(payload.object["classroomReadiness"].boolean);
behaviorRating = Optional!ubyte.of(cast(ubyte) payload.object["behaviorRating"].integer);
}
// Do the main insert first.
import std.variant;
Variant newEntryId;
const query = "
INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?)";
(class_id, student_id, date, absent, comment, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setUlong(1, classId);
@ -355,10 +400,11 @@ private void insertNewEntry(
ps.setNull(6);
ps.setNull(7);
} else {
ps.setBoolean(6, phoneCompliant.value);
ps.setBoolean(6, classroomReadiness.value);
ps.setUbyte(7, behaviorRating.value);
}
ps.executeUpdate();
ps.executeUpdate(newEntryId);
updateEntryCommentChecklistItems(conn, newEntryId.coerce!ulong, checklistItems);
infoF!"Created new entry for student %d: %s"(studentId, payload);
}
@ -374,15 +420,21 @@ private void updateEntry(
bool absent = obj.object["absent"].boolean;
string comment = obj.object["comment"].str;
if (comment is null) comment = "";
Optional!bool phoneCompliant = Optional!bool.empty;
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) {
phoneCompliant = Optional!bool.of(obj.object["phoneCompliant"].boolean);
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 = ?, phone_compliant = ?, behavior_rating = ?
SET absent = ?, comment = ?, classroom_readiness = ?, behavior_rating = ?
WHERE class_id = ? AND student_id = ? AND date = ? AND id = ?
";
PreparedStatement ps = conn.prepareStatement(query);
@ -393,7 +445,7 @@ private void updateEntry(
ps.setNull(3);
ps.setNull(4);
} else {
ps.setBoolean(3, phoneCompliant.value);
ps.setBoolean(3, classroomReadiness.value);
ps.setUbyte(4, behaviorRating.value);
}
ps.setUlong(5, classId);
@ -401,6 +453,27 @@ private void updateEntry(
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();
}
}

View File

@ -29,6 +29,7 @@ void getFullExport(ref HttpRequestContext ctx) {
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
@ -62,6 +63,7 @@ void getFullExport(ref HttpRequestContext ctx) {
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)
];
@ -95,6 +97,11 @@ private string formatPhoneCompliance(DataSetReader r, int i) {
return r.getBoolean(i) ? "Compliant" : "Non-compliant";
}
private string formatClassroomReadiness(DataSetReader r, int i) {
if (r.isNull(i)) return "N/A";
return r.getBoolean(i) ? "Ready" : "Not Ready";
}
private string formatBehaviorRating(DataSetReader r, int i) {
if (r.isNull(i)) return "N/A";
ubyte score = r.getUbyte(i);

View File

@ -15,6 +15,7 @@ void createStudent(ref HttpRequestContext ctx) {
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
struct StudentPayload {
string name;
ushort deskNumber;
@ -83,6 +84,8 @@ void updateStudent(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user);
struct StudentUpdatePayload {
string name;
@ -139,6 +142,8 @@ void deleteStudent(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user);
update(
conn,
@ -154,7 +159,18 @@ void getStudentEntries(ref HttpRequestContext ctx) {
auto student = getStudentOrThrow(ctx, conn, user);
auto entries = findAll(
conn,
"SELECT * FROM classroom_compliance_entry WHERE student_id = ? ORDER BY date DESC",
"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
);
@ -221,6 +237,8 @@ void moveStudentToOtherClass(ref HttpRequestContext ctx) {
scope(exit) conn.close();
conn.setAutoCommit(false);
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
if (cls.archived) throw new HttpStatusException(HttpStatus.FORBIDDEN, "Class is archived.");
auto student = getStudentOrThrow(ctx, conn, user);
struct Payload {
ulong classId;

View File

@ -4,20 +4,23 @@ import ddbc : DataSetReader;
import handy_httpd.components.optional;
import std.json;
import std.datetime;
import std.string : split;
struct ClassroomComplianceClass {
const ulong id;
const ushort number;
const string schoolYear;
const ulong userId;
const string scoreExpression;
const string scorePeriod;
ulong id;
ushort number;
string schoolYear;
bool archived;
ulong userId;
string scoreExpression;
string scorePeriod;
static ClassroomComplianceClass parse(DataSetReader r) {
return ClassroomComplianceClass(
r.getUlong(1),
r.getUshort(2),
r.getString(3),
r.getBoolean(7),
r.getUlong(4),
r.getString(5),
r.getString(6)
@ -26,11 +29,11 @@ struct ClassroomComplianceClass {
}
struct ClassroomComplianceStudent {
const ulong id;
const string name;
const ulong classId;
const ushort deskNumber;
const bool removed;
ulong id;
string name;
ulong classId;
ushort deskNumber;
bool removed;
static ClassroomComplianceStudent parse(DataSetReader r) {
return ClassroomComplianceStudent(
@ -44,35 +47,32 @@ struct ClassroomComplianceStudent {
}
struct ClassroomComplianceEntry {
const ulong id;
const ulong classId;
const ulong studentId;
const Date date;
const ulong createdAt;
const bool absent;
const string comment;
const Optional!bool phoneCompliant;
const Optional!ubyte behaviorRating;
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) {
Optional!bool phone = !r.isNull(8)
? Optional!bool.of(r.getBoolean(8))
: Optional!bool.empty;
Optional!ubyte behavior = !r.isNull(9)
? Optional!ubyte.of(r.getUbyte(9))
: Optional!ubyte.empty;
return ClassroomComplianceEntry(
r.getUlong(1),
r.getUlong(2),
r.getUlong(3),
r.getDate(4),
r.getUlong(5),
r.getBoolean(6),
r.getString(7),
phone,
behavior
);
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 {
@ -84,28 +84,22 @@ struct ClassroomComplianceEntry {
obj.object["createdAt"] = JSONValue(createdAt);
obj.object["absent"] = JSONValue(absent);
obj.object["comment"] = JSONValue(comment);
if (absent) {
if (!phoneCompliant.isNull || !behaviorRating.isNull) {
throw new Exception("Illegal entry state! Absent is true while values are not null!");
}
obj.object["phoneCompliant"] = JSONValue(null);
obj.object["behaviorRating"] = JSONValue(null);
} else {
if (phoneCompliant.isNull || behaviorRating.isNull) {
throw new Exception("Illegal entry state! Absent is false while values are null!");
}
obj.object["phoneCompliant"] = JSONValue(phoneCompliant.value);
obj.object["behaviorRating"] = JSONValue(behaviorRating.value);
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 {
const ulong id;
const ulong classId;
const ulong createdAt;
const string content;
ulong id;
ulong classId;
ulong createdAt;
string content;
static ClassroomComplianceClassNote parse(DataSetReader r) {
return ClassroomComplianceClassNote(

View File

@ -15,7 +15,7 @@ enum ScorePeriod : string {
private struct ScoringParameters {
Expr expr;
const DateRange dateRange;
DateRange dateRange;
}
/**
@ -54,6 +54,7 @@ Optional!double[ulong] getScores(
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
@ -74,14 +75,16 @@ Optional!double[ulong] getScores(
uint entryCount = r.getUint(2);
uint absenceCount = r.getUint(3);
uint phoneNonComplianceCount = r.getUint(4);
uint behaviorGoodCount = r.getUint(5);
uint behaviorMediocreCount = r.getUint(6);
uint behaviorPoorCount = r.getUint(7);
uint notClassroomReadyCount = r.getUint(5);
uint behaviorGoodCount = r.getUint(6);
uint behaviorMediocreCount = r.getUint(7);
uint behaviorPoorCount = r.getUint(8);
scores[studentId] = calculateScore(
params.expr,
entryCount,
absenceCount,
phoneNonComplianceCount,
notClassroomReadyCount,
behaviorGoodCount,
behaviorMediocreCount,
behaviorPoorCount
@ -132,6 +135,7 @@ private Optional!double calculateScore(
uint entryCount,
uint absenceCount,
uint phoneNonComplianceCount,
uint notClassroomReadyCount,
uint behaviorGoodCount,
uint behaviorMediocreCount,
uint behaviorPoorCount
@ -151,6 +155,14 @@ private Optional!double calculateScore(
phoneCompliantCount = presentCount - phoneNonComplianceCount;
}
double phoneScore = phoneCompliantCount / cast(double) presentCount;
// Classroom readiness score:
uint classroomReadyCount;
if (presentCount < notClassroomReadyCount) {
classroomReadyCount = 0;
} else {
classroomReadyCount = presentCount - notClassroomReadyCount;
}
double classroomReadinessScore = classroomReadyCount / cast(double) presentCount;
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
@ -159,6 +171,7 @@ private Optional!double calculateScore(
return Optional!double.of(scoreExpression.eval([
"phone": phoneScore,
"classroom_readiness": classroomReadinessScore,
"behavior": typicalBehaviorScore,
"behavior_good": behaviorGoodScore,
"behavior_mediocre": behaviorMediocreScore,

View File

@ -35,7 +35,8 @@ void initializeSchema() {
scope(exit) stmt.close();
const string AUTH_SCHEMA = import("schema/auth.sql");
const string CLASSROOM_COMPLIANCE_SCHEMA = import("schema/classroom_compliance.sql");
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA];
const string ANNOUNCEMENT_SCHEMA = import("schema/announcement.sql");
const schemas = [AUTH_SCHEMA, CLASSROOM_COMPLIANCE_SCHEMA, ANNOUNCEMENT_SCHEMA];
uint schemaNumber = 1;
foreach (schema; schemas) {
infoF!"Intializing schema #%d."(schemaNumber++);

View File

@ -137,8 +137,8 @@ void addEntry(
) {
const entryQuery = "
INSERT INTO classroom_compliance_entry
(class_id, student_id, date, absent, comment, phone_compliant, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?)";
(class_id, student_id, date, absent, comment, phone_compliant, classroom_readiness, behavior_rating)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
PreparedStatement ps = conn.prepareStatement(entryQuery);
scope(exit) ps.close();
ps.setUlong(1, classId);
@ -150,12 +150,13 @@ void addEntry(
} else {
ps.setString(5, comment);
}
ps.setNull(6); // Always set phone_compliant as null from now on.
if (absent) {
ps.setNull(6);
ps.setNull(7);
ps.setNull(8);
} else {
ps.setBoolean(6, phoneCompliant);
ps.setUint(7, behaviorRating);
ps.setBoolean(7, true);
ps.setUint(8, behaviorRating);
}
ps.executeUpdate();
}
@ -167,6 +168,7 @@ void deleteAllData(Connection conn) {
const tables = [
"announcement",
"classroom_compliance_class_note",
"classroom_compliance_entry_comment_checklist",
"classroom_compliance_entry",
"classroom_compliance_student",
"classroom_compliance_class",

View File

@ -4,6 +4,8 @@ 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 = '✅'
export const EMOJI_ABSENT = '❌'
export const EMOJI_BEHAVIOR_GOOD = '😇'
@ -16,6 +18,7 @@ export interface Class {
schoolYear: string
scoreExpression: string
scorePeriod: string
archived: boolean
}
export interface ClassesResponseClass {
@ -25,6 +28,7 @@ export interface ClassesResponseClass {
studentCount: number
entryCount: number
lastEntryDate: string
archived: boolean
}
export interface Student {
@ -41,8 +45,10 @@ export interface Entry {
createdAt: number
absent: boolean
phoneCompliant: boolean | null
classroomReadiness: boolean | null
behaviorRating: number | null
comment: string
checklistItems: string[]
}
export function getDefaultEntry(dateStr: string): Entry {
@ -51,9 +57,11 @@ export function getDefaultEntry(dateStr: string): Entry {
date: dateStr,
createdAt: Date.now(),
absent: false,
phoneCompliant: true,
phoneCompliant: null,
classroomReadiness: true,
behaviorRating: 3,
comment: '',
checklistItems: [],
}
}
@ -152,6 +160,10 @@ export class ClassroomComplianceAPIClient extends APIClient {
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
}
toggleArchived(classId: number): APIResponse<void> {
return super.postWithNoExpectedResponse(`/classes/${classId}/archived`, {})
}
updateScoreParameters(
classId: number,
scoreExpression: string,

View File

@ -9,7 +9,9 @@ const router = useRouter()
</script>
<template>
<div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)">
<h3>Class <span v-text="cls.number"></span></h3>
<h3>Class <span v-text="cls.number"></span>&nbsp;<span v-text="cls.schoolYear"
style="font-weight: lighter; font-size: small;"></span>
</h3>
<p>
{{ cls.studentCount }} students,
{{ cls.entryCount }} entries.

View File

@ -4,6 +4,7 @@ import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
note: ClassNote
disabled?: boolean
}>()
const emit = defineEmits(['noteDeleted'])
const authStore = useAuthStore()
@ -21,7 +22,7 @@ async function deleteNote() {
<p class="class-note-item-content">{{ note.content }}</p>
<div class="class-note-item-attributes-container">
<p class="class-note-item-timestamp">{{ new Date(note.createdAt).toLocaleString() }}</p>
<button class="class-note-item-delete-button" @click="deleteNote()">Delete</button>
<button class="class-note-item-delete-button" @click="deleteNote()" :disabled="disabled">Delete</button>
</div>
</div>
</template>

View File

@ -17,6 +17,7 @@ const notes: Ref<ClassNote[]> = ref([])
const noteContent: Ref<string> = ref('')
const scoreExpression: Ref<string> = ref('')
const scorePeriod: Ref<string> = ref('')
const archived = computed(() => cls.value !== null && cls.value.archived)
const canUpdateScoreParameters = computed(() => {
return cls.value && (
cls.value.scoreExpression !== scoreExpression.value ||
@ -53,6 +54,12 @@ async function deleteThisClass() {
await router.replace('/classroom-compliance')
}
async function toggleArchived() {
if (!cls.value) return
await apiClient.toggleArchived(cls.value.id).handleErrorsWithAlertNoBody()
loadClass()
}
async function submitNote() {
if (noteContent.value.trim().length < 1 || !cls.value) return
const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert()
@ -113,22 +120,28 @@ function resetScoreParameters() {
<h1 class="align-center" style="margin-bottom: 0;">Class <span v-text="cls.number"></span></h1>
<p class="align-center" style="margin-top: 0; margin-bottom: 2em;">For the {{ cls.schoolYear }} school year.</p>
<div class="button-bar align-center" style="margin-bottom: 1em;">
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)">Add
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)"
:disabled="archived">Add
Student</button>
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)">Import
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/import-students`)"
:disabled="archived">Import
Students</button>
<button type="button" @click="resetStudentDesks">Clear Assigned Desks</button>
<button type="button" @click="deleteThisClass">Delete this Class</button>
<button type="button" @click="resetStudentDesks" :disabled="archived">Clear Assigned Desks</button>
<button type="button" @click="deleteThisClass" :disabled="archived">Delete this Class</button>
<button type="button" @click="toggleArchived">
<span v-if="archived">Unarchive</span>
<span v-if="!archived">Archive</span>
</button>
</div>
<EntriesTable :classId="cls.id" ref="entries-table" />
<EntriesTable :classId="cls.id" :disabled="archived" ref="entries-table" />
<div>
<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"></textarea>
<button style="vertical-align: top; margin-left: 0.5em;" type="submit">Add Note</button>
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1" v-model="noteContent"
:readonly="archived"></textarea>
<button style="vertical-align: top; margin-left: 0.5em;" type="submit" :disabled="archived">Add Note</button>
</form>
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
</div>
@ -142,7 +155,7 @@ function resetScoreParameters() {
<div>
<label for="score-expression-input">Expression</label>
<textarea id="score-expression-input" v-model="scoreExpression" class="text-mono" minlength="1"
maxlength="255" style="min-width: 500px; min-height: 50px;"></textarea>
maxlength="255" style="min-width: 500px; min-height: 50px;" :readonly="archived"></textarea>
</div>
<p class="form-input-hint">
The expression to use to calculate each student's score. This should be a simple mathematical expression that
@ -169,7 +182,7 @@ function resetScoreParameters() {
</p>
<div>
<label for="score-period-select">Period</label>
<select v-model="scorePeriod">
<select v-model="scorePeriod" :disabled="archived">
<option value="week">Weekly</option>
</select>
</div>
@ -177,8 +190,9 @@ function resetScoreParameters() {
The period over which to calculate scores.
</p>
<div class="button-bar">
<button type="submit" :disabled="!canUpdateScoreParameters">Save</button>
<button type="button" :disabled="!canUpdateScoreParameters" @click="resetScoreParameters">Reset</button>
<button type="submit" :disabled="!canUpdateScoreParameters || archived">Save</button>
<button type="button" :disabled="!canUpdateScoreParameters || archived"
@click="resetScoreParameters">Reset</button>
</div>
</form>
</div>

View File

@ -1,11 +1,13 @@
<script setup lang="ts">
import { ClassroomComplianceAPIClient, type ClassesResponseClass } from '@/api/classroom_compliance'
import { useAuthStore } from '@/stores/auth'
import { type Ref, ref, onMounted } from 'vue'
import { type Ref, ref, onMounted, computed } from 'vue'
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
import { useRouter } from 'vue-router'
const classes: Ref<ClassesResponseClass[]> = ref([])
const archivedClasses = computed(() => classes.value.filter(c => c.archived))
const activeClasses = computed(() => classes.value.filter(c => !c.archived))
const authStore = useAuthStore()
const router = useRouter()
@ -23,7 +25,11 @@ onMounted(() => {
<button type="button" @click="router.push('/classroom-compliance/edit-class')">Add Class</button>
</div>
<div>
<ClassItem v-for="cls in classes" :key="cls.id" :cls="cls" />
<ClassItem v-for="cls in activeClasses" :key="cls.id" :cls="cls" />
</div>
<div v-if="archivedClasses.length > 0">
<h3 style="text-align: center;">Archived Classes</h3>
<ClassItem v-for="cls in archivedClasses" :key="cls.id" :cls="cls" />
</div>
</div>
</template>

View File

@ -16,7 +16,8 @@ import StudentNameCell from '@/apps/classroom_compliance/entries_table/StudentNa
const authStore = useAuthStore()
const props = defineProps<{
classId: number
classId: number,
disabled: boolean
}>()
const apiClient = new ClassroomComplianceAPIClient(authStore)
@ -160,6 +161,11 @@ async function showNextWeek() {
}
async function saveEdits() {
if (props.disabled) {
console.warn('Cannot save edits when disabled.')
await loadEntries()
return
}
if (!lastSaveState.value) {
console.warn('No lastSaveState, cannot determine what edits were made.')
await loadEntries()
@ -237,6 +243,7 @@ function getVisibleStudentEntries(student: EntriesResponseStudent): Record<strin
}
function addAllEntriesForDate(dateStr: string) {
if (props.disabled) return
for (let i = 0; i < students.value.length; i++) {
const student = students.value[i]
if (student.removed) continue
@ -293,7 +300,7 @@ defineExpose({
<th v-if="selectedView !== TableView.WHITEBOARD">#</th>
<th>Student</th>
<th v-if="assignedDesks">Desk</th>
<DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date"
<DateHeaderCell v-for="date in getVisibleDates()" :key="date" :date-str="date" :disabled="disabled"
@add-all-entries-clicked="addAllEntriesForDate(date)" />
<th v-if="selectedView !== TableView.WHITEBOARD">Score</th>
</tr>
@ -305,7 +312,8 @@ defineExpose({
<StudentNameCell :student="student" :class-id="classId" />
<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" />
v-model="student.entries[date]" :date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp"
:disabled="disabled" />
<StudentScoreCell :score="student.score" v-if="selectedView !== TableView.WHITEBOARD" />
</tr>
</tbody>

View File

@ -1,12 +1,12 @@
<script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, type Entry } from '@/api/classroom_compliance';
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';
const props = defineProps<{
entry: Entry
}>()
function getFormattedDate() {
const d = new Date(props.entry.date)
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()
}
@ -21,6 +21,9 @@ function getFormattedDate() {
<span v-if="entry.phoneCompliant === true">{{ EMOJI_PHONE_COMPLIANT }}</span>
<span v-if="entry.phoneCompliant === false">{{ EMOJI_PHONE_NONCOMPLIANT }}</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>
@ -28,6 +31,9 @@ function getFormattedDate() {
<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>
</ul>
</div>
</template>
<style scoped>

View File

@ -2,7 +2,7 @@
import { ClassroomComplianceAPIClient, type Class, type Entry, type Student, type StudentStatisticsOverview } from '@/api/classroom_compliance'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { useAuthStore } from '@/stores/auth'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { useRouter } from 'vue-router'
import StudentEntriesList from './StudentEntriesList.vue'
import { APIError } from '@/api/base'
@ -18,9 +18,38 @@ const cls: Ref<Class | null> = ref(null)
const student: Ref<Student | null> = ref(null)
const entries: Ref<Entry[]> = ref([])
const statistics: Ref<StudentStatisticsOverview | null> = ref(null)
// Filtered set of entries for "last week", used in the week overview dialog.
const lastWeeksEntries = computed(() => {
const now = new Date()
now.setHours(0, 0, 0, 0)
const toDate = new Date()
if (now.getDay() >= 1 && now.getDay() <= 5) {
// If we're currently in a week, shift the to-date to the Friday of this week.
const dayDiff = 5 - now.getDay()
toDate.setDate(now.getDate() + dayDiff)
} else {
// If it's saturday or sunday, shift back to the previous Friday.
if (now.getDay() === 6) {
toDate.setDate(now.getDate() - 1)
} else {
toDate.setDate(now.getDate() - 2)
}
}
const fromDate = new Date()
fromDate.setHours(0, 0, 0, 0)
fromDate.setDate(toDate.getDate() - 4)
const filtered = entries.value
.filter(e => new Date(e.date + 'T00:00:00').getTime() >= fromDate.getTime() &&
new Date(e.date + 'T00:00:00').getTime() <= toDate.getTime())
filtered.sort((a, b) => new Date(b.date + 'T00:00:00').getTime() - new Date(a.date + 'T00:00:00').getTime())
return filtered
})
const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteConfirmDialog = useTemplateRef('deleteConfirmDialog')
const weekOverviewDialog = useTemplateRef('weekOverviewDialog')
onMounted(async () => {
const classIdNumber = parseInt(props.classId, 10)
cls.value = await apiClient.getClass(classIdNumber).handleErrorsWithAlert()
@ -58,6 +87,12 @@ async function deleteThisStudent() {
await apiClient.deleteStudent(cls.value.id, student.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()
}
</script>
<template>
<div v-if="student" class="centered-content">
@ -73,6 +108,7 @@ async function deleteThisStudent() {
<button type="button"
@click="router.push(`/classroom-compliance/classes/${student.classId}/edit-student?studentId=${student.id}`)">Edit</button>
<button type="button" @click="deleteThisStudent">Delete</button>
<button type="button" @click="weekOverviewDialog?.showModal()">Week overview</button>
</div>
<table class="student-properties-table">
@ -116,6 +152,49 @@ async function deleteThisStudent() {
</p>
<p>This <strong>cannot</strong> be undone!</p>
</ConfirmDialog>
<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-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>
<div v-if="entry.behaviorRating !== null">
<h5>Behavior</h5>
<p v-if="entry.behaviorRating === 1">
Poor
</p>
<p v-if="entry.behaviorRating === 2">
Mediocre
</p>
<p v-if="entry.behaviorRating === 3">
Good
</p>
</div>
<div v-if="entry.comment.length > 0">
<h5>Instructor Remarks</h5>
<p>{{ entry.comment }}</p>
</div>
<div v-if="entry.checklistItems.length > 0">
<h5>Classroom Readiness & Behavioral Issues</h5>
<ul>
<li v-for="item in entry.checklistItems" :key="item" v-text="item"></li>
</ul>
</div>
</div>
</div>
<div class="button-bar align-right">
<button @click.prevent="weekOverviewDialog?.close()">Close</button>
</div>
</dialog>
</div>
</template>
<style scoped>
@ -133,4 +212,27 @@ async function deleteThisStudent() {
text-align: right;
font-family: 'SourceCodePro', monospace;
}
.weekly-overview-dialog-day {
margin-left: 1rem;
}
.weekly-overview-dialog h4 {
margin: 1rem 0 -0.5rem -1rem;
}
.weekly-overview-dialog h5 {
margin: 1rem 0 0.5rem 0;
}
.weekly-overview-dialog p {
margin: 0.5rem 0 0.5rem 1rem;
font-size: 12px;
}
.weekly-overview-dialog ul {
margin: 0.5rem 0;
padding-left: 1.5rem;
font-size: 12px;
}
</style>

View File

@ -3,6 +3,7 @@ import { computed } from 'vue';
const props = defineProps<{
dateStr: string
disabled?: boolean
}>()
defineEmits<{
@ -27,7 +28,7 @@ function getWeekday(date: Date): string {
<template>
<th>
<div class="date-header-container">
<div class="date-header-button">
<div class="date-header-button" v-if="!disabled">
<span title="Add All Entries" @click="$emit('addAllEntriesClicked')">+</span>
</div>
<div>

View File

@ -1,10 +1,27 @@
<script setup lang="ts">
import { EMOJI_ABSENT, EMOJI_BEHAVIOR_GOOD, EMOJI_BEHAVIOR_MEDIOCRE, EMOJI_BEHAVIOR_POOR, EMOJI_PHONE_COMPLIANT, EMOJI_PHONE_NONCOMPLIANT, EMOJI_PRESENT, getDefaultEntry, type Entry } from '@/api/classroom_compliance'
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 { 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"
],
"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
disabled?: boolean
}>()
defineEmits<{
(e: 'editComment'): void
@ -17,7 +34,7 @@ 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)
const hasComment = computed(() => model.value && (model.value.comment.trim().length > 0 || model.value.checklistItems.length > 0))
const previousCommentValue: Ref<string> = ref('')
const commentEditorDialog = useTemplateRef('commentEditorDialog')
@ -34,21 +51,24 @@ onMounted(() => {
})
function toggleAbsence() {
if (model.value) {
if (model.value && !props.disabled) {
model.value.absent = !model.value.absent
if (model.value.absent) {
// Remove additional data if student is absent.
model.value.phoneCompliant = null
model.value.classroomReadiness = null
model.value.behaviorRating = null
} else {
// Populate default additional data if student is no longer absent.
model.value.phoneCompliant = true
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
}
@ -61,13 +81,19 @@ function toggleAbsence() {
}
function togglePhoneCompliance() {
if (model.value && model.value.phoneCompliant !== null) {
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) {
if (model.value && model.value.behaviorRating && !props.disabled) {
model.value.behaviorRating = model.value.behaviorRating - 1
if (model.value.behaviorRating < 1) {
model.value.behaviorRating = 3
@ -89,6 +115,7 @@ function cancelCommentEdit() {
}
function removeEntry() {
if (props.disabled) return
if (model.value) {
previouslyRemovedEntry.value = JSON.parse(JSON.stringify(model.value))
}
@ -96,6 +123,7 @@ function removeEntry() {
}
function addEntry() {
if (props.disabled) return
if (previouslyRemovedEntry.value) {
model.value = JSON.parse(JSON.stringify(previouslyRemovedEntry.value))
} else {
@ -107,15 +135,22 @@ function addEntry() {
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
<div v-if="model" class="cell-container">
<div>
<div class="status-item" @click="toggleAbsence">
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleAbsence">
<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" @click="togglePhoneCompliance" v-if="!model.absent">
<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" @click="toggleBehaviorRating" v-if="!model.absent">
<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>
<span v-if="!model.classroomReadiness" title="Not Ready for Class">{{ EMOJI_NOT_CLASSROOM_READY }}</span>
</div>
<div class="status-item" :class="{ 'status-item-disabled': disabled }" @click="toggleBehaviorRating"
v-if="!model.absent">
<span v-if="model.behaviorRating === 3" title="Good Behavior">{{ EMOJI_BEHAVIOR_GOOD }}</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>
@ -142,10 +177,20 @@ function addEntry() {
<!-- 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()"></textarea>
@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" />
<span v-text="opt"></span>
</label>
</div>
</div>
<div class="button-bar" style="text-align: right;">
<button type="button" @click="commentEditorDialog?.close()">Confirm</button>
<button type="button" @click="model.comment = ''; commentEditorDialog?.close()">Clear Comment</button>
<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>
</div>
</dialog>
@ -175,6 +220,10 @@ td {
cursor: pointer;
}
.status-item-disabled {
cursor: default !important;
}
.status-item+.status-item {
margin-left: 0.5em;
}

View File

@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth';
import { onMounted, onUnmounted, ref, useTemplateRef, type Ref } from 'vue';
import ConfirmDialog from './ConfirmDialog.vue';
const REFRESH_INTERVAL_MS = 5000
const REFRESH_INTERVAL_MS = 30000
const authStore = useAuthStore()
const client = new AnnouncementAPIClient(authStore)