Added score evaluation.

This commit is contained in:
Andrew Lalis 2025-02-24 18:08:52 -05:00
parent 2e1c61ad3c
commit cdd1fe9b4f
11 changed files with 677 additions and 112 deletions

View File

@ -6,6 +6,7 @@
"dependencies": {
"asdf": "~>0.7.17",
"ddbc": "~>0.6.2",
"expression": "~>1.0.2",
"handy-httpd": "~>8.4.3"
},
"description": "A minimal D application.",

View File

@ -6,6 +6,7 @@
"ddbc": "0.6.2",
"derelict-pq": "2.2.0",
"derelict-util": "2.0.6",
"expression": "1.0.2",
"handy-httpd": "8.4.5",
"httparsed": "1.2.1",
"mir-algorithm": "3.22.3",

View File

@ -6,6 +6,8 @@ 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.3 * phone + 0.7 * (behavior_good * 1 + behavior_mediocre * 0.5)',
score_period VARCHAR(64) NOT NULL DEFAULT 'week',
CONSTRAINT unique_class_numbers_per_school_year
UNIQUE(number, school_year, user_id)
);

View File

@ -20,6 +20,7 @@ void registerApiEndpoints(PathHandler handler) {
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
handler.addMapping(Method.PUT, CLASS_PATH ~ "/reset-desk-numbers", &resetStudentDesks);
handler.addMapping(Method.PUT, CLASS_PATH ~ "/score-parameters", &updateScoreParameters);
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);

View File

@ -150,3 +150,51 @@ void resetStudentDesks(ref HttpRequestContext ctx) {
const query = "UPDATE classroom_compliance_student SET desk_number = 0 WHERE class_id = ?";
update(conn, query, cls.id);
}
void updateScoreParameters(ref HttpRequestContext ctx) {
import api_modules.classroom_compliance.score_eval : validateExpression;
import std.algorithm : canFind;
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
struct Payload {
string scoreExpression;
string scorePeriod;
}
Payload payload = readJsonPayload!(Payload)(ctx);
bool scoreExpressionChanged = cls.scoreExpression != payload.scoreExpression;
bool scorePeriodChanged = cls.scorePeriod != payload.scorePeriod;
if (scoreExpressionChanged && !validateExpression(payload.scoreExpression)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid score expression.");
return;
}
const VALID_SCORE_PERIODS = ["week"];
if (scorePeriodChanged && !canFind(VALID_SCORE_PERIODS, payload.scorePeriod)) {
ctx.response.status = HttpStatus.BAD_REQUEST;
ctx.response.writeBodyString("Invalid score period.");
return;
}
conn.setAutoCommit(false);
if (scoreExpressionChanged) {
update(
conn,
"UPDATE classroom_compliance_class SET score_expression = ? WHERE id = ?",
payload.scoreExpression,
cls.id
);
}
if (scorePeriodChanged) {
update(
conn,
"UPDATE classroom_compliance_class SET score_period = ? WHERE id = ?",
payload.scorePeriod,
cls.id
);
}
conn.commit();
// Reload the class and then write it to the output.
auto updatedClass = getClassOrThrow(ctx, conn, user);
writeJsonBody(ctx, updatedClass);
}

View File

@ -12,6 +12,7 @@ import slf4d;
import api_modules.auth;
import api_modules.classroom_compliance.model;
import api_modules.classroom_compliance.util;
import api_modules.classroom_compliance.score;
import db;
import data_utils;
@ -202,7 +203,7 @@ void getEntries(ref HttpRequestContext ctx) {
}
// Find scores for each student for this timeframe.
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange);
Optional!double[ulong] scores = getScores(conn, cls.id, dateRange.to);
foreach (studentId, score; scores) {
bool studentFound = false;
foreach (ref studentObj; studentObjects) {
@ -403,111 +404,3 @@ private void updateEntry(
infoF!"Updated entry %d"(entryId);
}
/**
* Gets an associative array that maps student ids to their (optional) scores.
* Scores are calculated based on aggregate statistics from their entries.
* Params:
* conn = The database connection.
* classId = The id of the class to filter by.
* dateRange = The date range to calculate scores for.
* Returns: A map of scores.
*/
Optional!double[ulong] getScores(
Connection conn,
ulong classId,
in DateRange dateRange
) {
Optional!double[ulong] scores;
const query = "
SELECT
student_id,
COUNT(id) AS entry_count,
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
FROM classroom_compliance_entry
WHERE
date >= ?
AND date <= ?
AND class_id = ?
GROUP BY student_id
";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setDate(1, dateRange.from);
ps.setDate(2, dateRange.to);
ps.setUlong(3, classId);
foreach (DataSetReader r; ps.executeQuery()) {
ulong studentId = r.getUlong(1);
uint entryCount = r.getUint(2);
uint absenceCount = r.getUint(3);
uint phoneNonComplianceCount = r.getUint(4);
uint behaviorGoodCount = r.getUint(5);
uint behaviorMediocreCount = r.getUint(6);
uint behaviorPoorCount = r.getUint(7);
scores[studentId] = calculateScore(
entryCount,
absenceCount,
phoneNonComplianceCount,
behaviorGoodCount,
behaviorMediocreCount,
behaviorPoorCount
);
}
return scores;
}
/**
* Calculates the score for a particular student, using the following formula:
* 1. Ignore all absent days.
* 2. Calculate phone score as compliantDays / total.
* 3. Calculate behavior score as:
* sum(goodBehaviorDays + 0.5 * mediocreBehaviorDays) / total
* 4. Final score is 0.3 * phoneScore + 0.7 * behaviorScore.
* Params:
* entryCount = The number of entries for a student.
* absenceCount = The number of absences the student has.
* phoneNonComplianceCount = The number of times the student was not phone-compliant.
* behaviorGoodCount = The number of days of good behavior.
* behaviorMediocreCount = The number of days of mediocre behavior.
* behaviorPoorCount = The number of days of poor behavior.
* Returns: The score, or an empty optional if there isn't enough data.
*/
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

@ -10,13 +10,17 @@ struct ClassroomComplianceClass {
const ushort number;
const string schoolYear;
const ulong userId;
const string scoreExpression;
const string scorePeriod;
static ClassroomComplianceClass parse(DataSetReader r) {
return ClassroomComplianceClass(
r.getUlong(1),
r.getUshort(2),
r.getString(3),
r.getUlong(4)
r.getUlong(4),
r.getString(5),
r.getString(6)
);
}
}

View File

@ -0,0 +1,167 @@
module api_modules.classroom_compliance.score;
import handy_httpd.components.optional;
import ddbc;
import slf4d;
import std.datetime;
import api_modules.classroom_compliance.util;
import api_modules.classroom_compliance.score_eval;
import db;
enum ScorePeriod : string {
WEEK = "week"
}
private struct ScoringParameters {
Expr expr;
const DateRange dateRange;
}
/**
* Gets an associative array that maps student ids to their (optional) scores.
* Scores are calculated based on aggregate statistics from their entries. A
* student's score will be empty if they do not have any data available, or are
* absent for all dates within the grading period.
* Params:
* conn = The database connection.
* classId = The id of the class to filter by.
* date = The date to calculate scores for.
* Returns: A map of scores.
*/
Optional!double[ulong] getScores(
Connection conn,
ulong classId,
Date date
) {
Optional!double[ulong] scores;
auto optScoringParams = getScoringParameters(conn, classId, date);
if (optScoringParams.isNull) {
warnF!"Unable to obtain scoring parameters for class %d on %s."(classId, date.toISOExtString());
return scores;
}
const ScoringParameters params = optScoringParams.value;
debugF!"Calculating scores for class %d using expression (%s) from %s to %s."(
classId,
params.expr.toString(),
params.dateRange.from,
params.dateRange.to
);
const query = "
SELECT
student_id,
COUNT(id) AS entry_count,
SUM(CASE WHEN absent = TRUE THEN 1 ELSE 0 END) AS absence_count,
SUM(CASE WHEN phone_compliant = FALSE THEN 1 ELSE 0 END) AS phone_noncompliance_count,
SUM(CASE WHEN behavior_rating = 3 THEN 1 ELSE 0 END) AS behavior_good,
SUM(CASE WHEN behavior_rating = 2 THEN 1 ELSE 0 END) AS behavior_mediocre,
SUM(CASE WHEN behavior_rating = 1 THEN 1 ELSE 0 END) AS behavior_poor
FROM classroom_compliance_entry
WHERE
date >= ?
AND date <= ?
AND class_id = ?
GROUP BY student_id
";
PreparedStatement ps = conn.prepareStatement(query);
scope(exit) ps.close();
ps.setDate(1, params.dateRange.from);
ps.setDate(2, params.dateRange.to);
ps.setUlong(3, classId);
foreach (DataSetReader r; ps.executeQuery()) {
ulong studentId = r.getUlong(1);
uint entryCount = r.getUint(2);
uint absenceCount = r.getUint(3);
uint phoneNonComplianceCount = r.getUint(4);
uint behaviorGoodCount = r.getUint(5);
uint behaviorMediocreCount = r.getUint(6);
uint behaviorPoorCount = r.getUint(7);
scores[studentId] = calculateScore(
params.expr,
entryCount,
absenceCount,
phoneNonComplianceCount,
behaviorGoodCount,
behaviorMediocreCount,
behaviorPoorCount
);
}
return scores;
}
private Optional!ScoringParameters getScoringParameters(Connection conn, ulong classId, in Date date) {
const q = "SELECT score_expression, score_period FROM classroom_compliance_class WHERE id = ?";
PreparedStatement ps = conn.prepareStatement(q);
scope(exit) ps.close();
ps.setUlong(1, classId);
ResultSet rs = ps.executeQuery();
scope(exit) rs.close();
if (!rs.first()) return Optional!ScoringParameters.empty;
string expr = rs.getString(1);
string period = rs.getString(2);
return Optional!ScoringParameters.of(ScoringParameters(
parseExpression(expr),
getDateRangeFromPeriodAndDate(date, period)
));
}
private DateRange getDateRangeFromPeriodAndDate(in Date date, in string period) {
if (period == ScorePeriod.WEEK) {
Date friday = date;
if (date.dayOfWeek() == DayOfWeek.sun) {
friday = date - days(2);
} else {
int diff = DayOfWeek.fri - date.dayOfWeek();
friday = date + days(diff);
}
Date monday = friday - days(4);
return DateRange(monday, friday);
} else {
throw new Exception("Unsupported period: " ~ period);
}
}
/**
* Calculates the score for a particular student, using the formula defined
* by the class.
* Returns: The score, or an empty optional if there isn't enough data.
*/
private Optional!double calculateScore(
in Expr scoreExpression,
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;
double behaviorGoodScore = behaviorGoodCount / cast(double) presentCount;
double behaviorMediocreScore = behaviorMediocreCount / cast(double) presentCount;
double behaviorPoorScore = behaviorPoorCount / cast(double) presentCount;
double typicalBehaviorScore = (1.0 * behaviorGoodScore + 0.5 * behaviorMediocreScore);
return Optional!double.of(scoreExpression.eval([
"phone": phoneScore,
"behavior": typicalBehaviorScore,
"behavior_good": behaviorGoodScore,
"behavior_mediocre": behaviorMediocreScore,
"behavior_poor": behaviorPoorScore
]));
}

View File

@ -0,0 +1,346 @@
module api_modules.classroom_compliance.score_eval;
import std.regex;
import std.uni;
import std.string;
import std.range;
import std.array;
import std.conv;
Expr parseExpression(string expr) {
Token[] tokens = lexer(expr);
return parseExpr(tokens);
}
double evaluateExpression(string expr, double[string] ctx = (double[string]).init) {
Expr ast = parseExpression(expr);
return ast.eval(ctx);
}
unittest {
assert(evaluateExpression("3 + 4") == 7.0);
assert(evaluateExpression("3 + 4 + 1") == 8.0);
assert(evaluateExpression("3 * 4 + 1") == 13.0);
assert(evaluateExpression("3 * (4 + 1)") == 15.0);
assert(evaluateExpression("(1 + 2) * (3 + 5)") == 24.0);
Expr e1 = parseExpression("x + 1");
assert(e1.eval(["x": 1.0]) == 2.0);
assert(e1.eval(["x": 3.0]) == 4.0);
Expr e2 = parseExpression("0.3 * phone + 0.7 * (behavior_good * 1 + behavior_mediocre * 0.5)");
// Test a perfect score first.
assert(e2.eval([
"phone": 1.0,
"behavior_good": 1.0,
"behavior_mediocre": 0.0
]) == 1.0);
// Test 0.5 phone score.
assert(e2.eval([
"phone": 0.5,
"behavior_good": 1.0,
"behavior_mediocre": 0.0
]) == 0.85);
// Test 0.75 phone, 0.5 good behavior, 0.5 mediocre behavior.
double d = e2.eval([
"phone": 0.75,
"behavior_good": 0.5,
"behavior_mediocre": 0.5
]);
assert(d > 0.7499 && d < 0.7501);
}
bool validateExpression(string expr) {
try {
auto _ = parseExpression(expr);
return true;
} catch (ExpressionParsingException e) {
return false;
}
}
class ExpressionParsingException : Exception {
this(string msg) {
super(msg);
}
}
class UnexpectedTokenException : ExpressionParsingException {
Token token;
this(Token token) {
super("Unexpected token found while parsing expression: " ~ token.toString());
this.token = token;
}
}
class ExpressionEvaluationException : Exception {
this(string msg) {
super(msg);
}
}
class UndefinedVariableException : ExpressionEvaluationException {
string variableName;
this(string variableName) {
super("No value is defined for the variable \"" ~ variableName ~ "\".");
this.variableName = variableName;
}
}
private enum TokenType {
ADD,
SUB,
MUL,
DIV,
PAREN_OPEN,
PAREN_CLOSE,
NUMBER,
VARIABLE
}
private interface Token {
TokenType getType() const;
string toString() const;
}
private class SimpleToken : Token {
const TokenType type;
this(TokenType type) {
this.type = type;
}
override TokenType getType() const {
return type;
}
override string toString() const {
return type.to!string;
}
}
private class NumberToken : Token {
const double value;
this(double value) {
this.value = value;
}
override TokenType getType() const {
return TokenType.NUMBER;
}
override string toString() const {
return value.to!string;
}
}
private class VariableToken : Token {
const string name;
this(string name) {
this.name = name;
}
override TokenType getType() const {
return TokenType.VARIABLE;
}
override string toString() const {
return name;
}
}
private Token[] lexer(string expr) {
auto app = appender!(Token[]);
size_t idx = 0;
while (idx < expr.length) {
const char c = expr[idx];
if (isWhite(c)) {
idx++;
continue;
}
if (isNumber(c)) {
app ~= lexNumber(expr, idx);
} else if (isAlpha(c)) {
app ~= lexVariable(expr, idx);
} else {
switch (c) {
case '+':
app ~= new SimpleToken(TokenType.ADD);
break;
case '-':
app ~= new SimpleToken(TokenType.SUB);
break;
case '*':
app ~= new SimpleToken(TokenType.MUL);
break;
case '/':
app ~= new SimpleToken(TokenType.DIV);
break;
case '(':
app ~= new SimpleToken(TokenType.PAREN_OPEN);
break;
case ')':
app ~= new SimpleToken(TokenType.PAREN_CLOSE);
break;
default:
throw new Exception("Invalid token: " ~ expr);
}
idx++;
}
}
return app[];
}
unittest {
Token[] t1 = lexer("3 + 4");
assert(t1.length == 3);
assert(t1[0].getType() == TokenType.NUMBER);
assert((cast(NumberToken) t1[0]).value == 3.0);
assert(t1[1].getType() == TokenType.ADD);
assert(t1[2].getType() == TokenType.NUMBER);
assert((cast(NumberToken) t1[2]).value == 4.0);
}
private NumberToken lexNumber(string s, ref size_t idx) {
const r = regex("\\d+(\\.\\d+)?");
auto captures = matchFirst(s[idx .. $], r);
if (captures.empty) {
throw new ExpressionParsingException("Failed to read number from string: " ~ s[idx .. $]);
}
idx += captures.front.length;
return new NumberToken(captures.front.to!double);
}
private VariableToken lexVariable(string s, ref size_t idx) {
const r = regex("[a-zA-Z_]+");
auto captures = matchFirst(s[idx .. $], r);
if (captures.empty) {
throw new ExpressionParsingException("Failed to read variable from string: " ~ s[idx .. $]);
}
idx += captures.front.length;
return new VariableToken(captures.front);
}
interface Expr {
double eval(double[string] context) const;
string toString() const;
}
private class BinaryOpExpr : Expr {
private TokenType op;
private Expr left;
private Expr right;
this(TokenType op, Expr left, Expr right) {
this.op = op;
this.left = left;
this.right = right;
}
double eval(double[string] context) const {
const l = left.eval(context);
const r = right.eval(context);
switch (op) {
case TokenType.ADD:
return l + r;
case TokenType.SUB:
return l - r;
case TokenType.MUL:
return l * r;
case TokenType.DIV:
return l / r;
default:
throw new ExpressionEvaluationException("Invalid binary operation: " ~ op.to!string);
}
}
override string toString() const {
return "(" ~ left.toString() ~ " " ~ this.op.to!string ~ " " ~ right.toString() ~ ")";
}
}
private class NumberLiteralExpr : Expr {
private double value;
this(double value) {
this.value = value;
}
double eval(double[string] context) const {
return value;
}
override string toString() const {
return value.to!string;
}
}
private class VariableExpr : Expr {
private string variable;
this(string variable) {
this.variable = variable;
}
double eval(double[string] context) const {
if (variable !in context) throw new UndefinedVariableException(variable);
return context[variable];
}
override string toString() const {
return variable;
}
}
private Expr parseExpr(ref Token[] tokens) {
Expr left = parseTerm(tokens);
if (empty(tokens)) return left;
Token t = front(tokens);
if (t.getType() == TokenType.ADD) {
popFront(tokens);
Expr right = parseExpr(tokens);
return new BinaryOpExpr(TokenType.ADD, left, right);
} else if (t.getType() == TokenType.SUB) {
popFront(tokens);
Expr right = parseExpr(tokens);
return new BinaryOpExpr(TokenType.SUB, left, right);
} else {
throw new UnexpectedTokenException(t);
}
}
private Expr parseTerm(ref Token[] tokens) {
Expr left = parseFactor(tokens);
if (empty(tokens)) return left;
Token t = front(tokens);
if (t.getType() == TokenType.MUL) {
popFront(tokens);
Expr right = parseFactor(tokens);
return new BinaryOpExpr(TokenType.MUL, left, right);
} else if (t.getType() == TokenType.DIV) {
popFront(tokens);
Expr right = parseFactor(tokens);
return new BinaryOpExpr(TokenType.DIV, left, right);
} else {
return left;
}
}
private Expr parseFactor(ref Token[] tokens) {
Token t = front(tokens);
if (t.getType() == TokenType.VARIABLE) {
VariableToken vt = cast(VariableToken) t;
popFront(tokens);
return new VariableExpr(vt.name);
} else if (t.getType() == TokenType.NUMBER) {
NumberToken nt = cast(NumberToken) t;
popFront(tokens);
return new NumberLiteralExpr(nt.value);
} else if (t.getType() == TokenType.PAREN_OPEN) {
int i = 1;
int pCount = 1;
while (i < tokens.length && pCount > 0) {
if (tokens[i].getType() == TokenType.PAREN_OPEN) pCount++;
if (tokens[i].getType() == TokenType.PAREN_CLOSE) pCount--;
i++;
}
if (pCount != 0) throw new ExpressionParsingException("Unbalanced parentheses.");
Token[] subExprTokens = tokens[1 .. i - 1];
for (size_t idx = 0; idx < i; idx++) popFront(tokens);
return parseExpr(subExprTokens);
} else {
throw new UnexpectedTokenException(t);
}
}

View File

@ -14,6 +14,8 @@ export interface Class {
id: number
number: number
schoolYear: string
scoreExpression: string
scorePeriod: string
}
export interface ClassesResponseClass {
@ -150,6 +152,17 @@ export class ClassroomComplianceAPIClient extends APIClient {
return new APIResponse(this.handleAPIResponseWithNoBody(promise))
}
updateScoreParameters(
classId: number,
scoreExpression: string,
scorePeriod: string,
): APIResponse<Class> {
return super.put(`/classes/${classId}/score-parameters`, {
scoreExpression: scoreExpression,
scorePeriod: scorePeriod,
})
}
getStudents(classId: number): APIResponse<Student[]> {
return super.get(`/classes/${classId}/students`)
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth'
import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import { computed, onMounted, ref, useTemplateRef, type Ref } from 'vue'
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
import { useRouter } from 'vue-router'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
@ -15,12 +15,20 @@ const router = useRouter()
const cls: Ref<Class | null> = ref(null)
const notes: Ref<ClassNote[]> = ref([])
const noteContent: Ref<string> = ref('')
const scoreExpression: Ref<string> = ref('')
const scorePeriod: Ref<string> = ref('')
const canUpdateScoreParameters = computed(() => {
return cls.value && (
cls.value.scoreExpression !== scoreExpression.value ||
cls.value.scorePeriod !== scorePeriod.value
)
})
const entriesTable = useTemplateRef('entries-table')
const deleteClassDialog = useTemplateRef('deleteClassDialog')
const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteClassDialog = useTemplateRef('deleteClassDialog')
onMounted(() => {
loadClass()
})
@ -31,6 +39,8 @@ function loadClass() {
if (result) {
cls.value = result
refreshNotes()
scoreExpression.value = cls.value.scoreExpression
scorePeriod.value = cls.value.scorePeriod
} else {
router.back();
}
@ -75,6 +85,28 @@ async function resetStudentDesks() {
// Reload the table!
await entriesTable.value?.loadEntries()
}
async function submitScoreParametersUpdate() {
if (!cls.value) return
const result = await apiClient.updateScoreParameters(
cls.value.id,
scoreExpression.value,
scorePeriod.value
).handleErrorsWithAlert()
if (result === null) return
// The class was updated successfully, so update the page's values.
cls.value = result
scoreExpression.value = cls.value.scoreExpression
scorePeriod.value = cls.value.scorePeriod
// Reload the table with the updated scores.
await entriesTable.value?.loadEntries()
}
function resetScoreParameters() {
if (!cls.value) return
scoreExpression.value = cls.value.scoreExpression
scorePeriod.value = cls.value.scorePeriod
}
</script>
<template>
<div v-if="cls">
@ -101,6 +133,56 @@ async function resetStudentDesks() {
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
</div>
<div>
<h3 style="margin-bottom: 0.25em;">Scoring</h3>
<p style="margin-top: 0.25em; margin-bottom: 0.25em;">
Change how scores are calculated for this class here.
</p>
<form @submit.prevent="submitScoreParametersUpdate">
<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>
</div>
<p class="form-input-hint">
The expression to use to calculate each student's score. This should be a simple mathematical expression that
can use the following variables:
</p>
<ul class="form-input-hint">
<li><span class="score-expression-variable">phone</span> - The student's phone score, defined as the number of
compliant days divided by total days present.</li>
<li><span class="score-expression-variable">behavior_good</span> - The proportion of days that the student had
good behavior.</li>
<li><span class="score-expression-variable">behavior_mediocre</span> - The proportion of days that the student
had mediocre behavior.</li>
<li><span class="score-expression-variable">behavior_poor</span> - The proportion of days that the student had
poor behavior.</li>
<li><span class="score-expression-variable">behavior</span> - A general behavior score, where a student gets
full points for good behavior, half-points for mediocre behavior, and no points for poor behavior.</li>
</ul>
<p class="form-input-hint">
As an example, a common scoring expression might be 50% phone-compliance, and 50% behavior. The expression
below would achieve that:
</p>
<p style="font-family: 'SourceCodePro', monospace; font-size: smaller;">
0.5 * phone + 0.5 * behavior
</p>
<div>
<label for="score-period-select">Period</label>
<select v-model="scorePeriod">
<option value="week">Weekly</option>
</select>
</div>
<p class="form-input-hint">
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>
</div>
</form>
</div>
<!-- Confirmation dialog used for attempts at deleting this class. -->
<ConfirmDialog ref="deleteClassDialog">
<p>
@ -111,3 +193,10 @@ async function resetStudentDesks() {
</ConfirmDialog>
</div>
</template>
<style scoped>
.score-expression-variable {
font-family: 'SourceCodePro', monospace;
font-style: normal;
color: rgb(165, 210, 253);
}
</style>