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 ( CREATE TABLE classroom_compliance_entry_behavior (
entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id) entry_id INTEGER PRIMARY KEY REFERENCES classroom_compliance_entry(id)
ON UPDATE CASCADE ON DELETE CASCADE, ON UPDATE CASCADE ON DELETE CASCADE,
rating INTEGER NOT NULL, rating INTEGER NOT NULL
comment TEXT
); );

View File

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

View File

@ -6,7 +6,6 @@ import d2sqlite3;
import slf4d; import slf4d;
import std.typecons : Nullable; import std.typecons : Nullable;
import std.datetime; import std.datetime;
import std.format;
import std.json; import std.json;
import std.algorithm; import std.algorithm;
import std.array; import std.array;
@ -47,7 +46,6 @@ struct ClassroomComplianceEntryPhone {
struct ClassroomComplianceEntryBehavior { struct ClassroomComplianceEntryBehavior {
const ulong entryId; const ulong entryId;
const ubyte rating; const ubyte rating;
const string comment;
} }
void registerApiEndpoints(PathHandler handler) { void registerApiEndpoints(PathHandler handler) {
@ -71,13 +69,13 @@ void registerApiEndpoints(PathHandler handler) {
} }
void createClass(ref HttpRequestContext ctx) { void createClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
User user = getUserOrThrow(ctx, db);
struct ClassPayload { struct ClassPayload {
ushort number; ushort number;
string schoolYear; string schoolYear;
} }
auto payload = readJsonPayload!(ClassPayload)(ctx); auto payload = readJsonPayload!(ClassPayload)(ctx);
auto db = getDb();
const bool classNumberExists = canFind( const bool classNumberExists = canFind(
db, db,
"SELECT id FROM classroom_compliance_class WHERE number = ? AND school_year = ? AND user_id = ?", "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) { void getClasses(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto db = getDb(); auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto classes = findAll!(ClassroomComplianceClass)( auto classes = findAll!(ClassroomComplianceClass)(
db, db,
"SELECT * FROM classroom_compliance_class WHERE user_id = ? ORDER BY school_year DESC, number ASC", "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) { void getClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
auto cls = getClassOrThrow(ctx, user); User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
writeJsonBody(ctx, cls); writeJsonBody(ctx, cls);
} }
void deleteClass(ref HttpRequestContext ctx) { void deleteClass(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb(); 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); 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"); ulong classId = ctx.request.getPathParamAs!ulong("classId");
auto db = getDb();
return findOne!(ClassroomComplianceClass)( return findOne!(ClassroomComplianceClass)(
db, db,
"SELECT * FROM classroom_compliance_class WHERE user_id = ? AND id = ?", "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) { void createStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
auto cls = getClassOrThrow(ctx, user); User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
struct StudentPayload { struct StudentPayload {
string name; string name;
ushort deskNumber; ushort deskNumber;
bool removed; bool removed;
} }
auto payload = readJsonPayload!(StudentPayload)(ctx); auto payload = readJsonPayload!(StudentPayload)(ctx);
auto db = getDb();
bool studentExists = canFind( bool studentExists = canFind(
db, db,
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", "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) { void getStudents(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto cls = getClassOrThrow(ctx, user);
auto db = getDb(); auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
auto students = findAll!(ClassroomComplianceStudent)( auto students = findAll!(ClassroomComplianceStudent)(
db, db,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", "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) { void getStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
auto student = getStudentOrThrow(ctx, user); User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
writeJsonBody(ctx, student); writeJsonBody(ctx, student);
} }
void updateStudent(ref HttpRequestContext ctx) { void updateStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
auto student = getStudentOrThrow(ctx, user); User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
struct StudentUpdatePayload { struct StudentUpdatePayload {
string name; string name;
ushort deskNumber; ushort deskNumber;
@ -216,7 +216,6 @@ void updateStudent(ref HttpRequestContext ctx) {
&& payload.removed == student.removed && payload.removed == student.removed
) return; ) return;
// Check that the new name doesn't already exist. // Check that the new name doesn't already exist.
auto db = getDb();
bool newNameExists = payload.name != student.name && canFind( bool newNameExists = payload.name != student.name && canFind(
db, db,
"SELECT id FROM classroom_compliance_student WHERE name = ? AND class_id = ?", "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) { void deleteStudent(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx);
auto student = getStudentOrThrow(ctx, user);
auto db = getDb(); auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto student = getStudentOrThrow(ctx, db, user);
db.execute( db.execute(
"DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?", "DELETE FROM classroom_compliance_student WHERE id = ? AND class_id = ?",
student.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 classId = ctx.request.getPathParamAs!ulong("classId");
ulong studentId = ctx.request.getPathParamAs!ulong("studentId"); ulong studentId = ctx.request.getPathParamAs!ulong("studentId");
auto db = getDb();
string query = " string query = "
SELECT s.* SELECT s.*
FROM classroom_compliance_student s FROM classroom_compliance_student s
@ -286,8 +284,9 @@ ClassroomComplianceStudent getStudentOrThrow(ref HttpRequestContext ctx, in User
} }
void getEntries(ref HttpRequestContext ctx) { void getEntries(ref HttpRequestContext ctx) {
User user = getUserOrThrow(ctx); auto db = getDb();
auto cls = getClassOrThrow(ctx, user); User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
// Default to getting entries from the last 5 days. // Default to getting entries from the last 5 days.
SysTime now = Clock.currTime(); SysTime now = Clock.currTime();
Date toDate = Date(now.year, now.month, now.day); Date toDate = Date(now.year, now.month, now.day);
@ -310,10 +309,19 @@ void getEntries(ref HttpRequestContext ctx) {
return; 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()); 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)( ClassroomComplianceStudent[] students = findAll!(ClassroomComplianceStudent)(
db, db,
"SELECT * FROM classroom_compliance_student WHERE class_id = ? ORDER BY name ASC", "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["name"] = JSONValue(s.name);
obj.object["removed"] = JSONValue(s.removed); obj.object["removed"] = JSONValue(s.removed);
obj.object["entries"] = JSONValue.emptyObject; obj.object["entries"] = JSONValue.emptyObject;
obj.object["score"] = JSONValue(null);
return obj; return obj;
}).array; }).array;
const query = " const entriesQuery = "
SELECT SELECT
entry.id, entry.id,
entry.date, entry.date,
@ -340,8 +349,7 @@ void getEntries(ref HttpRequestContext ctx) {
student.desk_number, student.desk_number,
student.removed, student.removed,
phone.compliant, phone.compliant,
behavior.rating, behavior.rating
behavior.comment
FROM classroom_compliance_entry entry FROM classroom_compliance_entry entry
LEFT JOIN classroom_compliance_entry_phone phone LEFT JOIN classroom_compliance_entry_phone phone
ON phone.entry_id = entry.id ON phone.entry_id = entry.id
@ -357,9 +365,9 @@ void getEntries(ref HttpRequestContext ctx) {
student.id ASC, student.id ASC,
entry.date 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. // Serialize the results into a custom-formatted response object.
foreach (row; result) { foreach (row; entriesResult) {
JSONValue entry = JSONValue.emptyObject; JSONValue entry = JSONValue.emptyObject;
entry.object["id"] = JSONValue(row.peek!ulong(0)); entry.object["id"] = JSONValue(row.peek!ulong(0));
entry.object["date"] = JSONValue(row.peek!string(1)); 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)); phone.object["compliant"] = JSONValue(row.peek!bool(8));
behavior = JSONValue.emptyObject; behavior = JSONValue.emptyObject;
behavior.object["rating"] = JSONValue(row.peek!ubyte(9)); behavior.object["rating"] = JSONValue(row.peek!ubyte(9));
behavior.object["comment"] = JSONValue(row.peek!string(10));
} }
entry.object["phone"] = phone; entry.object["phone"] = phone;
entry.object["behavior"] = behavior; 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; JSONValue response = JSONValue.emptyObject;
// Provide the list of dates that we're providing data for, to make it easier for the frontend. // Provide the list of dates that we're providing data for, to make it easier for the frontend.
response.object["dates"] = JSONValue.emptyArray; response.object["dates"] = JSONValue.emptyArray;
@ -417,23 +441,18 @@ void getEntries(ref HttpRequestContext ctx) {
} }
void saveEntries(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(); auto db = getDb();
User user = getUserOrThrow(ctx, db);
auto cls = getClassOrThrow(ctx, db, user);
JSONValue bodyContent = ctx.request.readBodyAsJson();
db.begin(); db.begin();
try {
foreach (JSONValue studentObj; bodyContent.object["students"].array) { foreach (JSONValue studentObj; bodyContent.object["students"].array) {
ulong studentId = studentObj.object["id"].integer(); ulong studentId = studentObj.object["id"].integer();
JSONValue entries = studentObj.object["entries"]; JSONValue entries = studentObj.object["entries"];
foreach (string dateStr, JSONValue entry; entries.object) { foreach (string dateStr, JSONValue entry; entries.object) {
if (entry.isNull) { if (entry.isNull) {
db.execute( deleteEntry(db, cls.id, studentId, dateStr);
"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);
continue; continue;
} }
@ -449,12 +468,7 @@ void saveEntries(ref HttpRequestContext ctx) {
if (creatingNewEntry) { if (creatingNewEntry) {
if (!existingEntry.isNull) { if (!existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString( ctx.response.writeBodyString("Cannot create a new entry when one already exists.");
format!"Cannot create a new entry for student %d on %s when one already exists."(
studentId,
dateStr
)
);
return; return;
} }
@ -462,11 +476,7 @@ void saveEntries(ref HttpRequestContext ctx) {
} else { } else {
if (existingEntry.isNull) { if (existingEntry.isNull) {
ctx.response.status = HttpStatus.BAD_REQUEST; ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString( ctx.response.writeBodyString("Cannot update an entry which doesn't exist.");
format!"Cannot update entry %d because it doesn't exist."(
entryId
)
);
return; return;
} }
updateEntry(db, cls.id, studentId, dateStr, entryId, entry); updateEntry(db, cls.id, studentId, dateStr, entryId, entry);
@ -474,6 +484,36 @@ void saveEntries(ref HttpRequestContext ctx) {
} }
} }
db.commit(); 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( private void insertNewEntry(
@ -507,9 +547,9 @@ private void insertNewEntry(
); );
ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer; ubyte behaviorRating = cast(ubyte) payload.object["behavior"].object["rating"].integer;
db.execute( db.execute(
"INSERT INTO classroom_compliance_entry_behavior (entry_id, rating, comment) "INSERT INTO classroom_compliance_entry_behavior (entry_id, rating)
VALUES (?, ?, ?)", VALUES (?, ?)",
entryId, behaviorRating, "" entryId, behaviorRating
); );
} }
infoF!"Created new entry for student %d: %s"(studentId, payload); infoF!"Created new entry for student %d: %s"(studentId, payload);
@ -587,3 +627,102 @@ private void updateEntry(
} }
infoF!"Updated entry %d"(entryId); 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> <template>
<header> <header>
<div> <div>
<nav> <nav class="global-navbar">
<div>
<RouterLink to="/">Home</RouterLink> <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"> <div>
Welcome, <span v-text="authStore.state.username"></span> <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> </span>
<button type="button" @click="logOut" v-if="authStore.state">Log out</button> <button type="button" @click="logOut" v-if="authStore.state">Log out</button>
</nav> </div>
<nav v-if="authStore.state">
Apps:
<RouterLink to="/classroom-compliance">Classroom Compliance</RouterLink>
</nav> </nav>
</div> </div>
</header> </header>
@ -38,4 +38,17 @@ async function logOut() {
<AlertDialog /> <AlertDialog />
</template> </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 { export interface User {
id: number id: number
username: string username: string
createdAt: Date createdAt: number
isLocked: boolean isLocked: boolean
isAdmin: boolean isAdmin: boolean
} }

View File

@ -102,18 +102,12 @@ export abstract class APIClient {
protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> { protected async handleAPIResponse<T>(promise: Promise<Response>): Promise<T | APIError> {
try { try {
const response = await promise const response = await promise
if (response.ok) { if (response.ok) return (await response.json()) as T
return (await response.json()) as T return this.transformErrorResponse(response)
}
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())
} catch (error) { } 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 { try {
const response = await promise const response = await promise
if (response.ok) return undefined if (response.ok) return undefined
if (response.status === 401) { return this.transformErrorResponse(response)
return new AuthenticationError(await response.text())
}
return new InternalServerError(await response.text())
} catch (error) { } 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 { export interface EntryBehavior {
rating: number rating: number
comment?: string
} }
export interface Entry { export interface Entry {
@ -51,6 +50,7 @@ export interface EntriesResponseStudent {
deskNumber: number deskNumber: number
removed: boolean removed: boolean
entries: Record<string, Entry | null> entries: Record<string, Entry | null>
score: number | null
} }
export interface EntriesResponse { export interface EntriesResponse {
@ -73,6 +73,15 @@ export interface EntriesPayload {
students: EntriesPayloadStudent[] students: EntriesPayloadStudent[]
} }
export interface StudentScore {
id: number
score: number | null
}
export interface ScoresResponse {
scores: StudentScore[]
}
export class ClassroomComplianceAPIClient extends APIClient { export class ClassroomComplianceAPIClient extends APIClient {
constructor(authStore: AuthStoreType) { constructor(authStore: AuthStoreType) {
super(BASE_URL, authStore) super(BASE_URL, authStore)
@ -132,4 +141,11 @@ export class ClassroomComplianceAPIClient extends APIClient {
saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> { saveEntries(classId: number, payload: EntriesPayload): APIResponse<void> {
return super.postWithNoExpectedResponse(`/classes/${classId}/entries`, payload) 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"> <script setup lang="ts">
import { type Class } from '@/api/classroom_compliance' import { type Class } from '@/api/classroom_compliance'
import { useRouter } from 'vue-router';
defineProps<{ defineProps<{
cls: Class cls: Class
}>() }>()
const router = useRouter()
</script> </script>
<template> <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> <h3>Class <span v-text="cls.number"></span></h3>
<p v-text="cls.schoolYear"></p> <p v-text="cls.schoolYear"></p>
<div>
<RouterLink :to="'/classroom-compliance/classes/' + cls.id">View</RouterLink>
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.class-item { .class-item {
border: 1px solid black; border: 2px solid;
border-radius: 10px;
padding: 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> </style>

View File

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

View File

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

View File

@ -93,7 +93,7 @@ function resetForm() {
<span v-if="!student">Add New Student</span> <span v-if="!student">Add New Student</span>
</h2> </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"> <form @submit.prevent="submitForm" @reset.prevent="resetForm">
<div> <div>
@ -105,10 +105,10 @@ function resetForm() {
<input id="desk-input" type="number" v-model="formData.deskNumber" /> <input id="desk-input" type="number" v-model="formData.deskNumber" />
</div> </div>
<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" /> <input id="removed-checkbox" type="checkbox" v-model="formData.removed" />
</div> </div>
<div> <div class="button-bar">
<button type="submit">Save</button> <button type="submit">Save</button>
<button type="reset">Cancel</button> <button type="reset">Cancel</button>
</div> </div>

View File

@ -18,8 +18,10 @@ const props = defineProps<{
const apiClient = new ClassroomComplianceAPIClient(authStore) const apiClient = new ClassroomComplianceAPIClient(authStore)
const students: Ref<EntriesResponseStudent[]> = ref([]) const students: Ref<EntriesResponseStudent[]> = ref([])
const lastSaveState: Ref<string | null> = ref(null) const lastSaveState: Ref<string | null> = ref(null)
const lastSaveStateTimestamp: Ref<number> = ref(0) const lastSaveStateTimestamp: Ref<number> = ref(0)
const dates: Ref<string[]> = ref([]) const dates: Ref<string[]> = ref([])
const toDate: Ref<Date> = ref(new Date()) const toDate: Ref<Date> = ref(new Date())
const fromDate: 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(() => { const entriesChangedSinceLastSave = computed(() => {
return lastSaveState.value === null || lastSaveState.value !== JSON.stringify(students.value) 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 () => { onMounted(async () => {
toDate.value.setHours(0, 0, 0, 0) showThisWeek()
fromDate.value.setHours(0, 0, 0, 0)
fromDate.value.setDate(fromDate.value.getDate() - 4)
await loadEntries()
}) })
async function loadEntries() { async function loadEntries() {
@ -57,22 +59,27 @@ function shiftDateRange(days: number) {
fromDate.value.setDate(fromDate.value.getDate() + days) fromDate.value.setDate(fromDate.value.getDate() + days)
} }
async function showPreviousDay() { async function showPreviousWeek() {
shiftDateRange(-1) shiftDateRange(-7)
await loadEntries() 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 = new Date()
toDate.value.setHours(0, 0, 0, 0) 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 = new Date()
fromDate.value.setHours(0, 0, 0, 0) fromDate.value.setHours(0, 0, 0, 0)
fromDate.value.setDate(fromDate.value.getDate() - 4) fromDate.value.setDate(fromDate.value.getDate() - 4)
await loadEntries() await loadEntries()
} }
async function showNextDay() { async function showNextWeek() {
shiftDateRange(1) shiftDateRange(7)
await loadEntries() await loadEntries()
} }
@ -135,10 +142,10 @@ function addAllEntriesForDate(dateStr: string) {
</script> </script>
<template> <template>
<div> <div>
<div class="buttons-bar"> <div class="button-bar">
<button type="button" @click="showPreviousDay">Previous Day</button> <button type="button" @click="showPreviousWeek">Previous Week</button>
<button type="button" @click="showToday">Today</button> <button type="button" @click="showThisWeek">This Week</button>
<button type="button" @click="showNextDay">Next Day</button> <button type="button" @click="showNextWeek">Next Week</button>
<button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave"> <button type="button" @click="saveEdits" :disabled="!entriesChangedSinceLastSave">
Save Save
</button> </button>
@ -146,17 +153,19 @@ function addAllEntriesForDate(dateStr: string) {
Discard Edits Discard Edits
</button> </button>
</div> </div>
<table class="entries-table"> <table class="entries-table">
<thead> <thead>
<tr> <tr>
<th>Student</th> <th>Student</th>
<th>Desk</th> <th v-if="assignedDesks">Desk</th>
<th v-for="date in dates" :key="date"> <th v-for="date in dates" :key="date">
<span>{{ getDate(date).toLocaleDateString() }}</span> <span>{{ getDate(date).toLocaleDateString() }}</span>
<span @click="addAllEntriesForDate(date)"></span> <span @click="addAllEntriesForDate(date)"></span>
<br /> <br />
<span>{{ getWeekday(getDate(date)) }}</span> <span>{{ getWeekday(getDate(date)) }}</span>
</th> </th>
<th>Score</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -166,9 +175,15 @@ function addAllEntriesForDate(dateStr: string) {
<span v-text="student.name"></span> <span v-text="student.name"></span>
</RouterLink> </RouterLink>
</td> </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]" <EntryTableCell v-for="(entry, date) in student.entries" :key="date" v-model="student.entries[date]"
:date-str="date" :last-save-state-timestamp="lastSaveStateTimestamp" /> :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> </tr>
</tbody> </tbody>
</table> </table>
@ -176,7 +191,7 @@ function addAllEntriesForDate(dateStr: string) {
</template> </template>
<style scoped> <style scoped>
.entries-table { .entries-table {
margin-top: 1em; margin-top: 0.5em;
margin-bottom: 1em; margin-bottom: 1em;
width: 100%; width: 100%;
} }
@ -189,15 +204,6 @@ function addAllEntriesForDate(dateStr: string) {
} }
.student-removed { .student-removed {
background-color: lightgray; text-decoration: line-through;
}
.buttons-bar {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
.buttons-bar>button+button {
margin-left: 0.5em;
} }
</style> </style>

View File

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

View File

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

View File

@ -1,6 +1,14 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue' import HomeView from '@/views/HomeView.vue'
import LoginView from '@/views/LoginView.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({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -13,9 +21,15 @@ const router = createRouter({
path: '/login', path: '/login',
component: LoginView, component: LoginView,
}, },
{
path: '/my-account',
component: MyAccountView,
beforeEnter: [enforceAuth],
},
{ {
path: '/classroom-compliance', path: '/classroom-compliance',
component: () => import('@/apps/classroom_compliance/MainView.vue'), component: () => import('@/apps/classroom_compliance/MainView.vue'),
beforeEnter: [enforceAuth],
children: [ children: [
{ {
path: '', path: '',

View File

@ -6,5 +6,20 @@
<p> <p>
Welcome to Teacher-Tools, a website with tools that help teachers to manager their classrooms. Welcome to Teacher-Tools, a website with tools that help teachers to manager their classrooms.
</p> </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> </main>
</template> </template>

View File

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