From cdd1fe9b4fba804dbe5730c6eb6546b4bbabbf83 Mon Sep 17 00:00:00 2001
From: andrewlalis <andrewlalisofficial@gmail.com>
Date: Mon, 24 Feb 2025 18:08:52 -0500
Subject: [PATCH] Added score evaluation.

---
 api/dub.json                                  |   1 +
 api/dub.selections.json                       |   1 +
 api/schema/classroom_compliance.sql           |   2 +
 .../api_modules/classroom_compliance/api.d    |   1 +
 .../classroom_compliance/api_class.d          |  48 +++
 .../classroom_compliance/api_entry.d          | 111 +-----
 .../api_modules/classroom_compliance/model.d  |   6 +-
 .../api_modules/classroom_compliance/score.d  | 167 +++++++++
 .../classroom_compliance/score_eval.d         | 346 ++++++++++++++++++
 app/src/api/classroom_compliance.ts           |  13 +
 .../apps/classroom_compliance/ClassView.vue   |  93 ++++-
 11 files changed, 677 insertions(+), 112 deletions(-)
 create mode 100644 api/source/api_modules/classroom_compliance/score.d
 create mode 100644 api/source/api_modules/classroom_compliance/score_eval.d

diff --git a/api/dub.json b/api/dub.json
index ca4ed85..5562374 100644
--- a/api/dub.json
+++ b/api/dub.json
@@ -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.",
diff --git a/api/dub.selections.json b/api/dub.selections.json
index fd1d65f..4f4c3f3 100644
--- a/api/dub.selections.json
+++ b/api/dub.selections.json
@@ -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",
diff --git a/api/schema/classroom_compliance.sql b/api/schema/classroom_compliance.sql
index b1315e2..1e20a4c 100644
--- a/api/schema/classroom_compliance.sql
+++ b/api/schema/classroom_compliance.sql
@@ -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)
 );
diff --git a/api/source/api_modules/classroom_compliance/api.d b/api/source/api_modules/classroom_compliance/api.d
index ee24897..864e9ca 100644
--- a/api/source/api_modules/classroom_compliance/api.d
+++ b/api/source/api_modules/classroom_compliance/api.d
@@ -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);
diff --git a/api/source/api_modules/classroom_compliance/api_class.d b/api/source/api_modules/classroom_compliance/api_class.d
index 31568ac..b67a9a6 100644
--- a/api/source/api_modules/classroom_compliance/api_class.d
+++ b/api/source/api_modules/classroom_compliance/api_class.d
@@ -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);
+}
diff --git a/api/source/api_modules/classroom_compliance/api_entry.d b/api/source/api_modules/classroom_compliance/api_entry.d
index 4c85599..66b90fb 100644
--- a/api/source/api_modules/classroom_compliance/api_entry.d
+++ b/api/source/api_modules/classroom_compliance/api_entry.d
@@ -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);
-}
diff --git a/api/source/api_modules/classroom_compliance/model.d b/api/source/api_modules/classroom_compliance/model.d
index 88b122c..40c2169 100644
--- a/api/source/api_modules/classroom_compliance/model.d
+++ b/api/source/api_modules/classroom_compliance/model.d
@@ -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)
         );
     }
 }
diff --git a/api/source/api_modules/classroom_compliance/score.d b/api/source/api_modules/classroom_compliance/score.d
new file mode 100644
index 0000000..896f158
--- /dev/null
+++ b/api/source/api_modules/classroom_compliance/score.d
@@ -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
+    ]));
+}
diff --git a/api/source/api_modules/classroom_compliance/score_eval.d b/api/source/api_modules/classroom_compliance/score_eval.d
new file mode 100644
index 0000000..1f3db47
--- /dev/null
+++ b/api/source/api_modules/classroom_compliance/score_eval.d
@@ -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);
+    }
+}
diff --git a/app/src/api/classroom_compliance.ts b/app/src/api/classroom_compliance.ts
index 51163b3..82395f9 100644
--- a/app/src/api/classroom_compliance.ts
+++ b/app/src/api/classroom_compliance.ts
@@ -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`)
   }
diff --git a/app/src/apps/classroom_compliance/ClassView.vue b/app/src/apps/classroom_compliance/ClassView.vue
index 2d4a9d3..b707b14 100644
--- a/app/src/apps/classroom_compliance/ClassView.vue
+++ b/app/src/apps/classroom_compliance/ClassView.vue
@@ -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>