Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions install/supabase/functions/canvasgradebook/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ function normalizePercent(percentCorrect) {
// Canvas collapses plain-text newlines in submission comments, so the header
// lines are joined with <br>. The feedback is already simplified HTML produced
// by the client (markdownToHtml), so it is appended as-is rather than escaped.
export function buildCanvasComment({ feedback, normalizedPercent, normalizedPoints, postedGrade, autoGrade }) {
export function buildCanvasComment({ feedback, normalizedPercent, normalizedPoints, postedGrade, autoGrade, dateDue }) {
const lines = [];
const date = new Date().toISOString();
const suggestedGrade = Math.round(((normalizedPercent / 100) * normalizedPoints + Number.EPSILON) * 100) / 100;
lines.push('MasteryLS feedback');
lines.push(`Suggested grade: ${suggestedGrade}/${normalizedPoints} (${normalizedPercent}%)`);
lines.push(`Auto grade: ${autoGrade ? 'enabled' : 'disabled'}`);
lines.push(`Submitted at: ${new Date().toISOString()}`);
lines.push(`Submitted at: ${date}`);
if (dateDue) {
lines.push(`Grace Day Potential: ${calculateGraceDaysEarned({ dateSubmitted: date, dateDue })}`);
}
if (typeof postedGrade === 'number') {
lines.push(`Posted grade: ${postedGrade}`);
}
Expand All @@ -35,6 +39,28 @@ export function buildCanvasComment({ feedback, normalizedPercent, normalizedPoin
return comment;
}

export function calculateGraceDaysEarned({ dateSubmitted, dateDue }) {
const submitted = new Date(dateSubmitted);
const due = new Date(dateDue);
const msPerDay = 1000 * 60 * 60 * 24;
const isLate = submitted > due;
const round = isLate ? Math.ceil : Math.floor;
const direction = isLate ? -1 : 1;
let graceDaysEarned = 0;
let currentDate = new Date(submitted);
while (currentDate.toDateString() !== due.toDateString()) {
if(currentDate.getDay() === 6) {
currentDate = new Date(currentDate.getTime() + direction * msPerDay);
continue;
}
graceDaysEarned += direction;
currentDate = new Date(currentDate.getTime() + direction * msPerDay);
}

if( graceDaysEarned === 0 && isLate ) graceDaysEarned = -1;
return graceDaysEarned;
}

export function createCanvasGradebookHandler({ createSupabaseClientFromAuthHeader, getEnv, fetchFn = fetch }) {
return async function handleCanvasGradebook(req) {
if (req.method === 'OPTIONS') {
Expand Down Expand Up @@ -213,6 +239,13 @@ export function createCanvasGradebookHandler({ createSupabaseClientFromAuthHeade
return new Response(JSON.stringify({ error: 'Unable to resolve Canvas assignment id' }), { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
}

// Mastery updates never attach a comment, so there's no need to look up the due date.
let dateDue = null;
if (!isMastery) {
const assignment = await canvasApi(`/courses/${courseId}/assignments/${assignmentId}`);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super happy that we are making another API call. Don't we already have the assignment?

dateDue = assignment?.due_at || null;
}

if (submissionUrl) {
await canvasApi(`/courses/${courseId}/assignments/${assignmentId}/submissions`, 'POST', {
submission: {
Expand Down Expand Up @@ -248,7 +281,7 @@ export function createCanvasGradebookHandler({ createSupabaseClientFromAuthHeade
? {}
: {
comment: {
text_comment: buildCanvasComment({ feedback, normalizedPercent, normalizedPoints, postedGrade, autoGrade }),
text_comment: buildCanvasComment({ feedback, normalizedPercent, normalizedPoints, postedGrade, autoGrade, dateDue }),
},
}),
};
Expand Down
56 changes: 52 additions & 4 deletions install/supabase/functions/canvasgradebook/handler.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createCanvasGradebookHandler, buildCanvasComment } from './handler.js';
import { createCanvasGradebookHandler, buildCanvasComment, calculateGraceDaysEarned } from './handler.js';

function createMockSupabase({ user, roles }) {
return {
Expand Down Expand Up @@ -48,6 +48,9 @@ function buildFetchStub() {
if (url.includes('/quizzes/')) {
return new Response(JSON.stringify({ assignment_id: 555 }), { status: 200 });
}
if (url.includes('/assignments/') && !url.includes('/submissions') && method === 'GET') {
return new Response(JSON.stringify({ id: 999, due_at: '2026-05-10T23:59:00Z' }), { status: 200 });
}
if (url.includes('/submissions') && method === 'POST') {
return new Response(JSON.stringify({ id: 321, submission_type: 'online_url' }), { status: 200 });
}
Expand All @@ -74,6 +77,51 @@ function getSubmissionRequest(calls, method = 'PUT') {
return JSON.parse(call.init.body || '{}');
}

function graceDayTest({ dateSubmitted, dateDue, expectedGraceDays}) {
const result = calculateGraceDaysEarned({ dateSubmitted, dateDue });
assert.equal(result, expectedGraceDays);
}

test('calculateGraceDaysEarned returns 0 for submission on the due date', () => {
graceDayTest({
dateSubmitted: new Date('2026-09-10T06:00:00Z'), // A thursday
dateDue: new Date('2026-09-10T12:00:00Z'), // A thursday
expectedGraceDays: 0
});
});

test('calculateGraceDaysEarned returns -1 for submission slightly after the due date', () => {
graceDayTest({
dateSubmitted: new Date('2026-09-09T12:00:01Z'), // A wednesday
dateDue: new Date('2026-09-09T12:00:00Z'), // A wednesday
expectedGraceDays: -1

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be clearer if the comment was

// One second late

});
});

test('calculateGraceDaysEarned returns -1 for submission sunday after a saturday due date', () => {
graceDayTest({
dateSubmitted: new Date('2026-09-13T12:00:00Z'), // A sunday
dateDue: new Date('2026-09-12T12:00:00Z'), // A saturday
expectedGraceDays: -1
});
});

test('calculateGraceDaysEarned returns -1 for submission sunday after a friday due date', () => {
graceDayTest({
dateSubmitted: new Date('2026-09-13T12:00:00Z'), // A sunday
dateDue: new Date('2026-09-11T12:00:00Z'), // A friday
expectedGraceDays: -1
});
});

@leesjensen leesjensen Sep 11, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this right? if due on saturday and turned in on sunday is -1 then shouldn't due on friday turned in on sunday be -2?

The rule is pretend that sundays don't exist. Depending on how you calculate doesn't exist, I think one of these have to be wrong.

We should also have a test for due on Friday turn in on Tuesday. -3.

What about positive tests?

Due on Monday turned in on the Saturday before. (+1)
Due on Friday turned in on the Monday before. (+4)
Due on Monday turned in three weeks earlier (+18).


test('calculateGraceDaysEarned returns 1 for submission saturday before a monday due date', () => {
graceDayTest({
dateSubmitted: new Date('2026-09-12T12:00:00Z'), // A saturday
dateDue: new Date('2026-09-14T12:00:00Z'), // A friday
expectedGraceDays: 1
});
});

test('canvasgradebook allows root user', async () => {
const { fetchFn, calls } = buildFetchStub();
const handler = createCanvasGradebookHandler({
Expand All @@ -99,7 +147,7 @@ test('canvasgradebook allows root user', async () => {
);

assert.equal(response.status, 200);
assert.equal(calls.length, 2);
assert.equal(calls.length, 3);
const submissionRequest = getSubmissionRequest(calls, 'PUT');
assert.equal(submissionRequest.submission.posted_grade, 90);
assert.ok(typeof submissionRequest.comment?.text_comment === 'string');
Expand Down Expand Up @@ -137,7 +185,7 @@ test('canvasgradebook allows learner self-match', async () => {
assert.equal(body.submission.url, 'https://example.com/project');
assert.ok(body.submission.submitted_at);
assert.notEqual(body.submission.workflow_state, 'unsubmitted');
assert.equal(calls.length, 3);
assert.equal(calls.length, 4);
const submissionRequest = getSubmissionRequest(calls, 'PUT');
assert.equal(submissionRequest.submission.posted_grade, 160);
assert.ok(submissionRequest.comment.text_comment.includes('Suggested grade: 160/200 (80%)'));
Expand Down Expand Up @@ -197,7 +245,7 @@ test('canvasgradebook can submit comment and url without posting grade when auto
);

assert.equal(response.status, 200);
assert.equal(calls.length, 3);
assert.equal(calls.length, 4);

const submitAttemptRequest = getSubmissionRequest(calls, 'POST');
assert.equal(submitAttemptRequest.submission.submission_type, 'online_url');
Expand Down