Added lots more improvements, including scores.

This commit is contained in:
Andrew Lalis 2024-12-28 00:00:10 -05:00
parent 3a682e046d
commit 3f47be9653
21 changed files with 487 additions and 198 deletions

View File

@ -35,6 +35,5 @@ CREATE TABLE classroom_compliance_entry_phone (
CREATE TABLE classroom_compliance_entry_behavior (
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE,
rating INTEGER NOT NULL,
comment TEXT
rating INTEGER NOT NULL
);

View File

@ -25,7 +25,7 @@ private struct UserResponse {
bool isAdmin;
}
Optional!User getUser(ref HttpRequestContext ctx) {
Optional!User getUser(ref HttpRequestContext ctx, ref Database db) {
import std.base64;
import std.string : startsWith;
import std.digest.sha;
@ -40,7 +40,6 @@ Optional!User getUser(ref HttpRequestContext ctx) {
size_t idx = countUntil(decoded, ':');
string username = decoded[0..idx];
auto passwordHash = toHexString(sha256Of(decoded[idx+1 .. $]));
Database db = getDb();
Optional!User optUser = findOne!(User)(db, "SELECT * FROM user WHERE username = ?", username);
if (!optUser.isNull && optUser.value.passwordHash != passwordHash) {
return Optional!User.empty;
@ -48,8 +47,8 @@ Optional!User getUser(ref HttpRequestContext ctx) {
return optUser;
}
User getUserOrThrow(ref HttpRequestContext ctx) {
Optional!User optUser = getUser(ctx);
User getUserOrThrow(ref HttpRequestContext ctx, ref Database db) {
Optional!User optUser = getUser(ctx, db);
if (optUser.isNull) {
throw new HttpStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials.");
}
@ -57,7 +56,8 @@ User getUserOrThrow(ref HttpRequestContext ctx) {
}
void loginEndpoint(ref HttpRequestContext ctx) {
Optional!User optUser = getUser(ctx);
Database db = getDb();
Optional!User optUser = getUser(ctx, db);
if (optUser.isNull) {
ctx.response.status = HttpStatus.UNAUTHORIZED;
ctx.response.writeBodyString("Invalid credentials.");

View File

@ -6,7 +6,6 @@ import d2sqlite3;
import slf4d;
import std.typecons : Nullable;
import std.datetime;
import std.format;
import std.json;
import std.algorithm;
import std.array;
@ -47,7 +46,6 @@ struct ClassroomComplianceEntryPhone {
struct ClassroomComplianceEntryBehavior {
const ulong entryId;
const ubyte rating;
const string comment;
}
void registerApiEndpoints(PathHandler handler) {
@ -71,13 +69,13 @@ void registerApiEndpoints(PathHandler handler) {
}
void createClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
struct ClassPayload {
ushort number;
string schoolYear;
}
auto payload = readJsonPayload!(ClassPayload)(ctx);
auto db = getDb();
const bool classNumberExists = canFind(
db,
"SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?",
@ -104,8 +102,8 @@ void createClass(ref HttpRequestContext ctx) {
}
void getClasses(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto classes = findAll!(ClassroomComplianceClass)(
db,
"SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC",
@ -115,21 +113,21 @@ void getClasses(ref HttpRequestContext ctx) {
}
void getClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
writeJsonBody(ctx, cls);
}
void deleteClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
db.execute("DELETE FROM classroom_compliance_class WHERE id = ? AND user_id = ?", cls.id, user.id);
}
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User user) {
ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
ulong classId = ctx.request.getPathParamAs!ulong("classId");
auto db = getDb();
return findOne!(ClassroomComplianceClass)(
db,
"SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?",
@ -139,15 +137,15 @@ ClassroomComplianceClass getClassOrThrow(ref HttpRequestContext ctx, in User use
}
void createStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
struct StudentPayload {
string name;
ushort deskNumber;
bool removed;
}
auto payload = readJsonPayload!(StudentPayload)(ctx);
auto db = getDb();
bool studentExists = canFind(
db,
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
@ -183,9 +181,9 @@ void createStudent(ref HttpRequestContext ctx) {
}
void getStudents(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
auto students = findAll!(ClassroomComplianceStudent)(
db,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
@ -195,14 +193,16 @@ void getStudents(ref HttpRequestContext ctx) {
}
void getStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
writeJsonBody(ctx, student);
}
void updateStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
struct StudentUpdatePayload {
string name;
ushort deskNumber;
@ -216,7 +216,6 @@ void updateStudent(ref HttpRequestContext ctx) {
&& payload.removed == student.removed
) return;
// Check that the new name doesn't already exist.
auto db = getDb();
bool newNameExists = payload.name != student.name && canFind(
db,
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?",
@ -256,9 +255,9 @@ void updateStudent(ref HttpRequestContext ctx) {
}
void deleteStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
db.execute(
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
student.id,
@ -266,10 +265,9 @@ void deleteStudent(ref HttpRequestContext ctx) {
);
}
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User user) {
ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, ref Database db, in User user) {
ulong classId = ctx.request.getPathParamAs!ulong("classId");
ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
auto db = getDb();
string query = "
SELECT s.*
FROM classroom_compliance_student s
@ -286,8 +284,9 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
}
void getEntries(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
// Default to getting entries from the last 5 days.
SysTime now = Clock.currTime();
Date toDate = Date(now.year, now.month, now.day);
@ -310,10 +309,19 @@ void getEntries(ref HttpRequestContext ctx) {
return;
}
}
if (fromDate > toDate) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid date range. From-date must be less than or equal to the to-date.");
return;
}
if (toDate - fromDate > days(10)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Date range is too big. Only ranges of 10 days or less are allowed.");
return;
}
infoF!"Getting entries from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
auto db = getDb();
// First prepare a list of all students, including ones which don't have any entries.
ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
db,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC",
@ -326,10 +334,11 @@ void getEntries(ref HttpRequestContext ctx) {
obj.object["name"] = JSONValue(s.name);
obj.object["removed"] = JSONValue(s.removed);
obj.object["entries"] = JSONValue.emptyObject;
obj.object["score"] = JSONValue(null);
return obj;
}).array;
const query = "
const entriesQuery = "
SELECT
entry.id,
entry.date,
@ -340,8 +349,7 @@ void getEntries(ref HttpRequestContext ctx) {
student.desk_number,
student.removed,
phone.compliant,
behavior.rating,
behavior.comment
behavior.rating
FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_entry_phone phone
ON phone.entry_id = entry.id
@ -357,9 +365,9 @@ void getEntries(ref HttpRequestContext ctx) {
student.id ASC,
entry.date ASC
";
ResultRange result = db.execute(query, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
ResultRange entriesResult = db.execute(entriesQuery, cls.id, fromDate.toISOExtString(), toDate.toISOExtString());
// Serialize the results into a custom-formatted response object.
foreach (row; result) {
foreach (row; entriesResult) {
JSONValue entry = JSONValue.emptyObject;
entry.object["id"] = JSONValue(row.peek!ulong(0));
entry.object["date"] = JSONValue(row.peek!string(1));
@ -373,7 +381,6 @@ void getEntries(ref HttpRequestContext ctx) {
phone.object["compliant"] = JSONValue(row.peek!bool(8));
behavior = JSONValue.emptyObject;
behavior.object["rating"] = JSONValue(row.peek!ubyte(9));
behavior.object["comment"] = JSONValue(row.peek!string(10));
}
entry.object["phone"] = phone;
entry.object["behavior"] = behavior;
@ -394,6 +401,23 @@ void getEntries(ref HttpRequestContext ctx) {
}
}
// Find scores for each student for this timeframe.
Optional!double[ulong] scores = getScores(db, cls.id, fromDate, toDate);
foreach (studentId, score; scores) {
JSONValue scoreValue = score.isNull ? JSONValue(null) : JSONValue(score.value);
bool studentFound = false;
foreach (idx, student; students) {
if (studentId == student.id) {
studentObjects[idx].object["score"] = scoreValue;
studentFound = true;
break;
}
}
if (!studentFound) {
throw new Exception("Failed to find student.");
}
}
JSONValue response = JSONValue.emptyObject;
// Provide the list of dates that we're providing data for, to make it easier for the frontend.
response.object["dates"] = JSONValue.emptyArray;
@ -417,23 +441,18 @@ void getEntries(ref HttpRequestContext ctx) {
}
void saveEntries(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
JSONValue bodyContent = ctx.request.readBodyAsJson();
auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
JSONValue bodyContent = ctx.request.readBodyAsJson();
db.begin();
try {
foreach (JSONValue studentObj; bodyContent.object["students"].array) {
ulong studentId = studentObj.object["id"].integer();
JSONValue entries = studentObj.object["entries"];
foreach (string dateStr, JSONValue entry; entries.object) {
if (entry.isNull) {
db.execute(
"DELETE FROM classroom_compliance_entry WHERE class_id = ? AND student_id = ? AND date = ?",
cls.id,
studentId,
dateStr
);
infoF!"Deleted entry for student %s on %s"(studentId, dateStr);
deleteEntry(db, cls.id, studentId, dateStr);
continue;
}
@ -449,12 +468,7 @@ void saveEntries(ref HttpRequestContext ctx) {
if (creatingNewEntry) {
if (!existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString(
format!"Cannot create a new entry for student %d on %s when one already exists."(
studentId,
dateStr
)
);
ctx.response.writeBodyString("Cannot create a new entry when one already exists.");
return;
}
@ -462,11 +476,7 @@ void saveEntries(ref HttpRequestContext ctx) {
} else {
if (existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString(
format!"Cannot update entry %d because it doesn't exist."(
entryId
)
);
ctx.response.writeBodyString("Cannot update an entry which doesn't exist.");
return;
}
updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
@ -474,6 +484,36 @@ void saveEntries(ref HttpRequestContext ctx) {
}
}
db.commit();
} catch (HttpStatusException e) {
db.rollback();
ctx.response.status = e.status;
ctx.response.writeBodyString(e.message);
} catch (JSONException e) {
db.rollback();
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid JSON payload.");
warn(e);
} catch (Exception e) {
db.rollback();
ctx.response.status = HttpStatus.INTERNAL_SERVER_ERROR;
ctx.response.writeBodyString("An internal server error occurred: " ~ e.msg);
error(e);
}
}
private void deleteEntry(
ref Database db,
ulong classId,
ulong studentId,
string dateStr
) {
db.execute(
"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(
@ -507,9 +547,9 @@ private void insertNewEntry(
);
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
db.execute(
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment)
VALUES (?, ?, ?)",
entryId, behaviorRating, ""
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
VALUES (?, ?)",
entryId, behaviorRating
);
}
infoF!"Created new entry for student %d: %s"(studentId, payload);
@ -587,3 +627,102 @@ private void updateEntry(
}
infoF!"Updated entry %d"(entryId);
}
Optional!double[ulong] getScores(
ref Database db,
ulong classId,
Date fromDate,
Date toDate
) {
infoF!"Getting scores from %s to %s"(fromDate.toISOExtString(), toDate.toISOExtString());
// First populate all students with an initial "null" score.
Optional!double[ulong] scores;
ResultRange studentsResult = db.execute(
"SELECT id FROM classroom_compliance_student WHERE class_id = ?",
classId
);
foreach (row; studentsResult) {
scores[row.peek!ulong(0)] = Optional!double.empty;
}
const query = "
SELECT
e.student_id,
COUNT(e.id) AS entry_count,
SUM(e.absent) AS absence_count,
SUM(NOT p.compliant) AS phone_noncompliance_count,
SUM(b.rating = 3) AS behavior_good,
SUM(b.rating = 2) AS behavior_mediocre,
SUM(b.rating = 1) AS behavior_poor
FROM classroom_compliance_entry e
LEFT JOIN classroom_compliance_entry_phone p
ON p.entry_id = e.id
LEFT JOIN classroom_compliance_entry_behavior b
ON b.entry_id = e.id
WHERE
e.date >= ?
AND e.date <= ?
AND e.class_id = ?
GROUP BY e.student_id
";
ResultRange result = db.execute(
query,
fromDate.toISOExtString(),
toDate.toISOExtString(),
classId
);
foreach (row; result) {
ulong studentId = row.peek!ulong(0);
uint entryCount = row.peek!uint(1);
uint absenceCount = row.peek!uint(2);
uint phoneNonComplianceCount = row.peek!uint(3);
uint behaviorGoodCount = row.peek!uint(4);
uint behaviorMediocreCount = row.peek!uint(5);
uint behaviorPoorCount = row.peek!uint(6);
scores[studentId] = calculateScore(
entryCount,
absenceCount,
phoneNonComplianceCount,
behaviorGoodCount,
behaviorMediocreCount,
behaviorPoorCount
);
}
return scores;
}
private Optional!double calculateScore(
uint entryCount,
uint absenceCount,
uint phoneNonComplianceCount,
uint behaviorGoodCount,
uint behaviorMediocreCount,
uint behaviorPoorCount
) {
if (
entryCount == 0
|| entryCount <= absenceCount
) return Optional!double.empty;
const uint presentCount = entryCount - absenceCount;
// Phone subscore:
uint phoneCompliantCount;
if (presentCount < phoneNonComplianceCount) {
phoneCompliantCount = 0;
} else {
phoneCompliantCount = presentCount - phoneNonComplianceCount;
}
double phoneScore = phoneCompliantCount / cast(double) presentCount;
// Behavior subscore:
double behaviorScore = (
behaviorGoodCount * 1.0
+ behaviorMediocreCount * 0.5
+ behaviorPoorCount * 0
) / cast(double) presentCount;
double score = 0.3 * phoneScore + 0.7 * behaviorScore;
return Optional!double.of(score);
}

View File

@ -15,19 +15,19 @@ async function logOut() {
<template>
<header>
<div>
<nav>
<nav class="global-navbar">
<div>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/login" v-if="!authStore.state">Login</RouterLink>
<RouterLink to="/my-account" v-if="authStore.state">My Account</RouterLink>
</div>
<span v-if="authStore.state">
Welcome, <span v-text="authStore.state.username"></span>
<div>
<RouterLink to="/login" v-if="!authStore.state">Login</RouterLink>
<span v-if="authStore.state" style="margin-right: 0.5em">
Logged in as <span v-text="authStore.state.username" style="font-weight: bold;"></span>
</span>
<button type="button" @click="logOut" v-if="authStore.state">Log out</button>
</nav>
<nav v-if="authStore.state">
Apps:
<RouterLink to="/classroom-compliance">Classroom Compliance</RouterLink>
</div>
</nav>
</div>
</header>
@ -38,4 +38,17 @@ async function logOut() {
<AlertDialog />
</template>
<style scoped></style>
<style scoped>
.global-navbar {
display: flex;
justify-content: space-between;
}
.global-navbar>div {
display: inline-block;
}
.global-navbar>div>a+a {
margin-left: 0.5em;
}
</style>

View File

@ -5,7 +5,7 @@ const BASE_URL = import.meta.env.VITE_API_URL + '/auth'
export interface User {
id: number
username: string
createdAt: Date
createdAt: number
isLocked: boolean
isAdmin: boolean
}

View File

@ -102,18 +102,12 @@ export abstract class APIClient {
protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> {
try {
const response = await promise
if (response.ok) {
return (await response.json()) as T
}
if (response.status === 400) {
return new BadRequestError(await response.text())
}
if (response.status === 401) {
return new AuthenticationError(await response.text())
}
return new InternalServerError(await response.text())
if (response.ok) return (await response.json()) as T
return this.transformErrorResponse(response)
} catch (error) {
return new NetworkError('' + error)
return new NetworkError(
'' + error + " (We couldn't connect to the remote server; it might be down!)",
)
}
}
@ -123,12 +117,17 @@ export abstract class APIClient {
try {
const response = await promise
if (response.ok) return undefined
if (response.status === 401) {
return new AuthenticationError(await response.text())
}
return new InternalServerError(await response.text())
return this.transformErrorResponse(response)
} catch (error) {
return new NetworkError('' + error)
return new NetworkError(
'' + error + " (We couldn't connect to the remote server; it might be down!)",
)
}
}
private async transformErrorResponse(r: Response): Promise<APIError> {
if (r.status === 401) return new AuthenticationError(await r.text())
if (r.status === 400) return new BadRequestError(await r.text())
return new InternalServerError(await r.text())
}
}

View File

@ -22,7 +22,6 @@ export interface EntryPhone {
export interface EntryBehavior {
rating: number
comment?: string
}
export interface Entry {
@ -51,6 +50,7 @@ export interface EntriesResponseStudent {
deskNumber: number
removed: boolean
entries: Record<string, Entry | null>
score: number | null
}
export interface EntriesResponse {
@ -73,6 +73,15 @@ export interface EntriesPayload {
students: EntriesPayloadStudent[]
}
export interface StudentScore {
id: number
score: number | null
}
export interface ScoresResponse {
scores: StudentScore[]
}
export class ClassroomComplianceAPIClient extends APIClient {
constructor(authStore: AuthStoreType) {
super(BASE_URL, authStore)
@ -132,4 +141,11 @@ export class ClassroomComplianceAPIClient extends APIClient {
saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> {
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload)
}
getScores(classId: number, fromDate: Date, toDate: Date): APIResponse<ScoresResponse> {
const params = new URLSearchParams()
params.append('from', fromDate.toISOString().substring(0, 10))
params.append('to', toDate.toISOString().substring(0, 10))
return super.get(`/classes/${classId}/scores?${params.toString()}`)
}
}

View File

@ -1,23 +1,36 @@
<script setup lang="ts">
import { type Class } from '@/api/classroom_compliance'
import { useRouter } from 'vue-router';
defineProps<{
cls: Class
}>()
const router = useRouter()
</script>
<template>
<div class="class-item">
<div class="class-item" @click="router.push(`/classroom-compliance/classes/${cls.id}`)">
<h3>Class <span v-text="cls.number"></span></h3>
<p v-text="cls.schoolYear"></p>
<div>
<RouterLink :to="'/classroom-compliance/classes/' + cls.id">View</RouterLink>
</div>
</div>
</template>
<style scoped>
.class-item {
border: 1px solid black;
border: 2px solid;
border-radius: 10px;
padding: 10px;
margin-bottom: 10px;
cursor: pointer;
max-width: 500px;
}
.class-item:hover {
border: 2px dashed;
}
.class-item+.class-item {
margin-top: 1em;
}
.class-item>h3 {
margin-top: 0;
}
</style>

View File

@ -2,7 +2,7 @@
import { useAuthStore } from '@/stores/auth'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
import { RouterLink, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
@ -35,18 +35,15 @@ async function deleteThisClass() {
<h1>Class #<span v-text="cls.number"></span></h1>
<p>ID: <span v-text="cls.id"></span></p>
<p>School Year: <span v-text="cls.schoolYear"></span></p>
<div>
<div>
<span>Actions: </span>
<RouterLink :to="'/classroom-compliance/classes/' + cls.id + '/edit-student'"
>Add Student</RouterLink
>
<div class="button-bar" style="margin-bottom: 1em;">
<button type="button" @click="router.push(`/classroom-compliance/classes/${cls.id}/edit-student`)">Add
Student</button>
<button type="button" @click="deleteThisClass">Delete this Class</button>
</div>
</div>
<EntriesTable :classId="cls.id" />
<!-- Confirmation dialog used for attempts at deleting this class. -->
<ConfirmDialog ref="deleteClassDialog">
<p>
Are you sure you want to delete this class? All data associated with it (settings, students,

View File

@ -3,10 +3,12 @@ import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compli
import { useAuthStore } from '@/stores/auth'
import { type Ref, ref, onMounted } from 'vue'
import ClassItem from '@/apps/classroom_compliance/ClassItem.vue'
import { useRouter } from 'vue-router'
const classes: Ref<Class[]> = ref([])
const authStore = useAuthStore()
const router = useRouter()
const apiClient = new ClassroomComplianceAPIClient(authStore)
onMounted(async () => {
@ -15,9 +17,11 @@ onMounted(async () => {
</script>
<template>
<div>
<div>
<RouterLink to="/classroom-compliance/edit-class">Add Class</RouterLink>
<div class="button-bar">
<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" />
</div>
</div>
</template>

View File

@ -69,7 +69,7 @@ function resetForm() {
<label for="school-year-input">School Year (example: "2024-2025")</label>
<input id="school-year-input" type="text" v-model="formData.schoolYear" required />
</div>
<div>
<div class="button-bar">
<button type="submit">Save</button>
<button type="reset">Cancel</button>
</div>

View File

@ -93,7 +93,7 @@ function resetForm() {
<span v-if="!student">Add New Student</span>
</h2>
<p>From class <span v-text="cls.number + ', ' + cls.schoolYear"></span></p>
<p>In class <span v-text="cls.number + ', ' + cls.schoolYear"></span></p>
<form @submit.prevent="submitForm" @reset.prevent="resetForm">
<div>
@ -105,10 +105,10 @@ function resetForm() {
<input id="desk-input" type="number" v-model="formData.deskNumber" />
</div>
<div>
<label for="removed-checkbox">Removed</label>
<label for="removed-checkbox" style="display: inline">Removed</label>
<input id="removed-checkbox" type="checkbox" v-model="formData.removed" />
</div>
<div>
<div class="button-bar">
<button type="submit">Save</button>
<button type="reset">Cancel</button>
</div>

View File

@ -18,8 +18,10 @@ const props = defineProps<{
const apiClient = new ClassroomComplianceAPIClient(authStore)
const students: Ref<EntriesResponseStudent[]> = ref([])
const lastSaveState: Ref<string | null> = ref(null)
const lastSaveStateTimestamp: Ref<number> = ref(0)
const dates: Ref<string[]> = ref([])
const toDate: Ref<Date> = ref(new Date())
const fromDate: Ref<Date> = ref(new Date())
@ -27,12 +29,12 @@ const fromDate: Ref<Date> = ref(new Date())
const entriesChangedSinceLastSave = computed(() => {
return lastSaveState.value === null || lastSaveState.value !== JSON.stringify(students.value)
})
const assignedDesks = computed(() => {
return students.value.length > 0 && students.value.some(s => s.deskNumber > 0)
})
onMounted(async () => {
toDate.value.setHours(0, 0, 0, 0)
fromDate.value.setHours(0, 0, 0, 0)
fromDate.value.setDate(fromDate.value.getDate() - 4)
await loadEntries()
showThisWeek()
})
async function loadEntries() {
@ -57,22 +59,27 @@ function shiftDateRange(days: number) {
fromDate.value.setDate(fromDate.value.getDate() + days)
}
async function showPreviousDay() {
shiftDateRange(-1)
async function showPreviousWeek() {
shiftDateRange(-7)
await loadEntries()
}
async function showToday() {
async function showThisWeek() {
// First set the to-date to the next upcoming end-of-week (Friday).
toDate.value = new Date()
toDate.value.setHours(0, 0, 0, 0)
while (toDate.value.getDay() < 5) {
toDate.value.setDate(toDate.value.getDate() + 1)
}
// Then set the from-date to the Monday of that week.
fromDate.value = new Date()
fromDate.value.setHours(0, 0, 0, 0)
fromDate.value.setDate(fromDate.value.getDate() - 4)
await loadEntries()
}
async function showNextDay() {
shiftDateRange(1)
async function showNextWeek() {
shiftDateRange(7)
await loadEntries()
}
@ -135,10 +142,10 @@ function addAllEntriesForDate(dateStr: string) {
</script>
<template>
<div>
<div class="buttons-bar">
<button type="button" @click="showPreviousDay">Previous Day</button>
<button type="button" @click="showToday">Today</button>
<button type="button" @click="showNextDay">Next Day</button>
<div class="button-bar">
<button type="button" @click="showPreviousWeek">Previous Week</button>
<button type="button" @click="showThisWeek">This Week</button>
<button type="button" @click="showNextWeek">Next Week</button>
<button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave">
Save
</button>
@ -146,17 +153,19 @@ function addAllEntriesForDate(dateStr: string) {
Discard Edits
</button>
</div>
<table class="entries-table">
<thead>
<tr>
<th>Student</th>
<th>Desk</th>
<th v-if="assignedDesks">Desk</th>
<th v-for="date in dates" :key="date">
<span>{{ getDate(date).toLocaleDateString() }}</span>
<span @click="addAllEntriesForDate(date)"></span>
<br />
<span>{{ getWeekday(getDate(date)) }}</span>
</th>
<th>Score</th>
</tr>
</thead>
<tbody>
@ -166,9 +175,15 @@ function addAllEntriesForDate(dateStr: string) {
<span v-text="student.name"></span>
</RouterLink>
</td>
<td v-text="student.deskNumber"></td>
<td v-if="assignedDesks" v-text="student.deskNumber"></td>
<EntryTableCell v-for="(entry, date) in student.entries" :key="date" v-model="student.entries[date]"
:date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" />
<td style="text-align: right; padding-right: 0.25em;">
<span v-if="student.score" style="font-family: monospace; font-size: large;">
{{ (student.score * 100).toFixed(1) }}%
</span>
<span v-if="!student.score" style="font-size: small; font-style: italic;">No score</span>
</td>
</tr>
</tbody>
</table>
@ -176,7 +191,7 @@ function addAllEntriesForDate(dateStr: string) {
</template>
<style scoped>
.entries-table {
margin-top: 1em;
margin-top: 0.5em;
margin-bottom: 1em;
width: 100%;
}
@ -189,15 +204,6 @@ function addAllEntriesForDate(dateStr: string) {
}
.student-removed {
background-color: lightgray;
}
.buttons-bar {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.buttons-bar>button+button {
margin-left: 0.5em;
text-decoration: line-through;
}
</style>

View File

@ -37,6 +37,17 @@ function toggleAbsence() {
// Populate default additional data if student is no longer absent.
model.value.phone = { compliant: true }
model.value.behavior = { rating: 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.absent) return
if (initialEntry.phone) {
model.value.phone = { compliant: initialEntry.phone?.compliant }
}
if (initialEntry.behavior) {
model.value.behavior = { rating: initialEntry.behavior.rating }
}
}
}
}
}
@ -73,28 +84,32 @@ function addEntry() {
</script>
<template>
<td :class="{ absent: model?.absent, changed: entryChanged, 'missing-entry': !model }">
<div v-if="model">
<div v-if="model" class="cell-container">
<div>
<div class="status-item" @click="toggleAbsence">
<span v-if="model.absent">Absent</span>
<span v-if="!model.absent">Present</span>
<span v-if="model.absent" title="Absent"></span>
<span v-if="!model.absent" title="Present"></span>
</div>
<div class="status-item" @click="togglePhoneCompliance" v-if="!model.absent">
<span v-if="model.phone?.compliant">📱</span>
<span v-if="!model.phone?.compliant">📵</span>
<span v-if="model.phone?.compliant" title="Phone Compliant">📱</span>
<span v-if="!model.phone?.compliant" title="Phone Non-Compliant">📵</span>
</div>
<div class="status-item" @click="toggleBehaviorRating" v-if="!model.absent">
<span v-if="model.behavior?.rating === 3">😇</span>
<span v-if="model.behavior?.rating === 2">😐</span>
<span v-if="model.behavior?.rating === 1">😡</span>
<span v-if="model.behavior?.rating === 3" title="Good Behavior">😇</span>
<span v-if="model.behavior?.rating === 2" title="Mediocre Behavior">😐</span>
<span v-if="model.behavior?.rating === 1" title="Poor Behavior">😡</span>
</div>
</div>
<div>
<div class="status-item" @click="removeEntry">
<span>🗑</span>
</div>
</div>
</div>
<div v-if="model === null">
<div class="status-item" @click="addEntry">
<span></span>
<span>+</span>
</div>
</div>
</td>
@ -106,7 +121,6 @@ td {
}
.missing-entry {
background-color: lightgray;
text-align: right;
}
@ -125,6 +139,12 @@ td {
}
.status-item+.status-item {
margin-left: 0.25em;
margin-left: 0.5em;
}
.cell-container {
display: flex;
justify-content: space-between;
padding: 0.25em;
}
</style>

View File

@ -52,14 +52,12 @@ async function deleteThisStudent() {
<li>Removed: <span v-text="student.removed"></span></li>
<li>Desk number: <span v-text="student.deskNumber"></span></li>
</ul>
<RouterLink
:to="
'/classroom-compliance/classes/' + student.classId + '/edit-student?studentId=' + student.id
"
>
Edit
</RouterLink>
<div class="button-bar">
<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>
</div>
<ConfirmDialog ref="deleteConfirmDialog">
<p>

21
app/src/assets/base.css Normal file
View File

@ -0,0 +1,21 @@
:root {
color-scheme: light dark;
font-family: sans-serif;
}
.button-bar {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.button-bar > button + button {
margin-left: 0.5em;
}
label {
display: block;
}
form > div + div {
margin-top: 0.5em;
}

View File

@ -1,3 +1,4 @@
import '@/assets/base.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'

View File

@ -1,6 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.vue'
import { useAuthStore } from '@/stores/auth'
import MyAccountView from '@/views/MyAccountView.vue'
function enforceAuth() {
const authStore = useAuthStore()
if (!authStore.state) return '/login'
return true
}
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -13,9 +21,15 @@ const router = createRouter({
path: '/login',
component: LoginView,
},
{
path: '/my-account',
component: MyAccountView,
beforeEnter: [enforceAuth],
},
{
path: '/classroom-compliance',
component: () => import('@/apps/classroom_compliance/MainView.vue'),
beforeEnter: [enforceAuth],
children: [
{
path: '',

View File

@ -6,5 +6,20 @@
<p>
Welcome to Teacher-Tools, a website with tools that help teachers to manager their classrooms.
</p>
<hr>
<h2>Applications</h2>
<p>
The following list of applications are available for you:
</p>
<ul>
<li>
<RouterLink to="/classroom-compliance">Classroom Compliance</RouterLink>
-
Track your students' phone usage and behavior patterns, and calculate weighted grades.
</li>
<li>
<em>... and more to come soon!</em>
</li>
</ul>
</main>
</template>

View File

@ -34,7 +34,7 @@ async function doLogin() {
<label for="password-input">Password</label>
<input id="password-input" name="password" type="password" v-model="credentials.password" />
</div>
<div>
<div class="button-bar">
<button type="button" @click="doLogin">Login</button>
</div>
</form>

View File

@ -0,0 +1,34 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore()
</script>
<template>
<main v-if="authStore.state">
<h1>My Account</h1>
<table>
<tbody>
<tr>
<th>Internal ID</th>
<td>{{ authStore.state.user.id }}</td>
</tr>
<tr>
<th>Username</th>
<td>{{ authStore.state.user.username }}</td>
</tr>
<tr>
<th>Created At</th>
<td>{{ new Date(authStore.state.user.createdAt).toLocaleString() }}</td>
</tr>
<tr>
<th>Account Locked</th>
<td>{{ authStore.state.user.isLocked }}</td>
</tr>
<tr>
<th>Administrator</th>
<td>{{ authStore.state.user.isAdmin }}</td>
</tr>
</tbody>
</table>
</main>
</template>