From f6f86d5833cee48c1475eaa9b8ef6f517135afeb Mon Sep 17 00:00:00 2001
From: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
Date: Fri, 24 Oct 2025 22:55:12 +0500
Subject: [PATCH 1/2] refactor: shift grade summary calculation to backend
(#1797)
Refactors the grade summary logic to delegate all calculation responsibilities to the backend.
Previously, the frontend was performing grade summary computations using data fetched from the API. Now, the API itself provides the fully computed grade summary, simplifying the frontend and ensuring consistent results across clients.
Additionally, a "Hidden Grades" label has been added in the grade summary table to clearly indicate sections where grades are not visible to learners.
Finally, for visibility settings that depend on the due date, this PR adds a banner on the Progress page indicating that grades are not yet released, along with the relevant due date information.
---
.../__factories__/progressTabData.factory.js | 14 ++
src/course-home/data/api.js | 92 -----------
.../progress-tab/ProgressTab.test.jsx | 152 ++++++++----------
.../grades/course-grade/CourseGradeFooter.jsx | 126 ++++++++++-----
.../course-grade/CurrentGradeTooltip.jsx | 12 ++
.../grades/grade-summary/GradeSummary.jsx | 6 +-
.../grade-summary/GradeSummaryTable.jsx | 32 +++-
.../grade-summary/GradeSummaryTableFooter.jsx | 23 +--
.../progress-tab/grades/messages.ts | 20 +++
src/course-home/progress-tab/utils.ts | 12 ++
10 files changed, 254 insertions(+), 235 deletions(-)
diff --git a/src/course-home/data/__factories__/progressTabData.factory.js b/src/course-home/data/__factories__/progressTabData.factory.js
index 1ff83241ce..3a3508f99e 100644
--- a/src/course-home/data/__factories__/progressTabData.factory.js
+++ b/src/course-home/data/__factories__/progressTabData.factory.js
@@ -17,7 +17,21 @@ Factory.define('progressTabData')
percent: 1,
is_passing: true,
},
+ final_grades: 0.5,
credit_course_requirements: null,
+ assignment_type_grade_summary: [
+ {
+ type: 'Homework',
+ short_label: 'HW',
+ weight: 1,
+ average_grade: 1,
+ weighted_grade: 1,
+ num_droppable: 1,
+ num_total: 2,
+ has_hidden_contribution: 'none',
+ last_grade_publish_date: null,
+ },
+ ],
section_scores: [
{
display_name: 'First section',
diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js
index 7db6f503a6..e2d24401db 100644
--- a/src/course-home/data/api.js
+++ b/src/course-home/data/api.js
@@ -3,93 +3,6 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
import { appendBrowserTimezoneToUrl } from '../../utils';
-const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
- let dropCount = numDroppable;
- // Drop the lowest grades
- while (dropCount && points.length >= dropCount) {
- const lowestScore = Math.min(...points);
- const lowestScoreIndex = points.indexOf(lowestScore);
- points.splice(lowestScoreIndex, 1);
- dropCount--;
- }
- let averageGrade = 0;
- let weightedGrade = 0;
- if (points.length) {
- // Calculate the average grade for the assignment and round it. This rounding is not ideal and does not accurately
- // reflect what a learner's grade would be, however, we must have parity with the current grading behavior that
- // exists in edx-platform.
- averageGrade = (points.reduce((a, b) => a + b, 0) / points.length).toFixed(4);
- weightedGrade = averageGrade * assignmentWeight;
- }
- return { averageGrade, weightedGrade };
-};
-
-function normalizeAssignmentPolicies(assignmentPolicies, sectionScores) {
- const gradeByAssignmentType = {};
- assignmentPolicies.forEach(assignment => {
- // Create an array with the number of total assignments and set the scores to 0
- // as placeholders for assignments that have not yet been released
- gradeByAssignmentType[assignment.type] = {
- grades: Array(assignment.numTotal).fill(0),
- numAssignmentsCreated: 0,
- numTotalExpectedAssignments: assignment.numTotal,
- };
- });
-
- sectionScores.forEach((chapter) => {
- chapter.subsections.forEach((subsection) => {
- if (!(subsection.hasGradedAssignment && subsection.showGrades && subsection.numPointsPossible)) {
- return;
- }
- const {
- assignmentType,
- numPointsEarned,
- numPointsPossible,
- } = subsection;
-
- // If a subsection's assignment type does not match an assignment policy in Studio,
- // we won't be able to include it in this accumulation of grades by assignment type.
- // This may happen if a course author has removed/renamed an assignment policy in Studio and
- // neglected to update the subsection's of that assignment type
- if (!gradeByAssignmentType[assignmentType]) {
- return;
- }
-
- let {
- numAssignmentsCreated,
- } = gradeByAssignmentType[assignmentType];
-
- numAssignmentsCreated++;
- if (numAssignmentsCreated <= gradeByAssignmentType[assignmentType].numTotalExpectedAssignments) {
- // Remove a placeholder grade so long as the number of recorded created assignments is less than the number
- // of expected assignments
- gradeByAssignmentType[assignmentType].grades.shift();
- }
- // Add the graded assignment to the list
- gradeByAssignmentType[assignmentType].grades.push(numPointsEarned ? numPointsEarned / numPointsPossible : 0);
- // Record the created assignment
- gradeByAssignmentType[assignmentType].numAssignmentsCreated = numAssignmentsCreated;
- });
- });
-
- return assignmentPolicies.map((assignment) => {
- const { averageGrade, weightedGrade } = calculateAssignmentTypeGrades(
- gradeByAssignmentType[assignment.type].grades,
- assignment.weight,
- assignment.numDroppable,
- );
-
- return {
- averageGrade,
- numDroppable: assignment.numDroppable,
- shortLabel: assignment.shortLabel,
- type: assignment.type,
- weight: assignment.weight,
- weightedGrade,
- };
- });
-}
-
/**
* Tweak the metadata for consistency
* @param metadata the data to normalize
@@ -237,11 +150,6 @@ export async function getProgressTabData(courseId, targetUserId) {
const { data } = await getAuthenticatedHttpClient().get(url);
const camelCasedData = camelCaseObject(data);
- camelCasedData.gradingPolicy.assignmentPolicies = normalizeAssignmentPolicies(
- camelCasedData.gradingPolicy.assignmentPolicies,
- camelCasedData.sectionScores,
- );
-
// We replace gradingPolicy.gradeRange with the original data to preserve the intended casing for the grade.
// For example, if a grade range key is "A", we do not want it to be camel cased (i.e. "A" would become "a")
// in order to preserve a course team's desired grade formatting.
diff --git a/src/course-home/progress-tab/ProgressTab.test.jsx b/src/course-home/progress-tab/ProgressTab.test.jsx
index 361fb14235..9195823c04 100644
--- a/src/course-home/progress-tab/ProgressTab.test.jsx
+++ b/src/course-home/progress-tab/ProgressTab.test.jsx
@@ -661,29 +661,23 @@ describe('Progress Tab', () => {
expect(screen.getByText('Grade summary')).toBeInTheDocument();
});
- it('does not render Grade Summary when assignment policies are not populated', async () => {
+ it('does not render Grade Summary when assignment type grade summary is not populated', async () => {
setTabData({
- grading_policy: {
- assignment_policies: [],
- grade_range: {
- pass: 0.75,
- },
- },
- section_scores: [],
+ assignment_type_grade_summary: [],
});
await fetchAndRender();
expect(screen.queryByText('Grade summary')).not.toBeInTheDocument();
});
- it('calculates grades correctly when number of droppable assignments equals total number of assignments', async () => {
+ it('shows lock icon when all subsections of assignment type are hidden', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
- num_droppable: 2,
- num_total: 2,
- short_label: 'HW',
- type: 'Homework',
+ num_droppable: 0,
+ num_total: 1,
+ short_label: 'Final',
+ type: 'Final Exam',
weight: 1,
},
],
@@ -691,19 +685,25 @@ describe('Progress Tab', () => {
pass: 0.75,
},
},
+ assignment_type_grade_summary: [
+ {
+ type: 'Final Exam',
+ weight: 0.4,
+ average_grade: 0.0,
+ weighted_grade: 0.0,
+ last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
+ has_hidden_contribution: 'all',
+ short_label: 'Final',
+ num_droppable: 0,
+ },
+ ],
});
await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 1 100% 0% 0%' })).toBeInTheDocument();
- });
- it('calculates grades correctly when number of droppable assignments is less than total number of assignments', async () => {
- await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
+ // Should show lock icon for grade and weighted grade
+ expect(screen.getAllByTestId('lock-icon')).toHaveLength(2);
});
- it('calculates grades correctly when number of droppable assignments is zero', async () => {
+
+ it('shows percent plus hidden grades when some subsections of assignment type are hidden', async () => {
setTabData({
grading_policy: {
assignment_policies: [
@@ -719,41 +719,36 @@ describe('Progress Tab', () => {
pass: 0.75,
},
},
- });
- await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 100% 50% 50%' })).toBeInTheDocument();
- });
- it('calculates grades correctly when number of total assignments is less than the number of assignments created', async () => {
- setTabData({
- grading_policy: {
- assignment_policies: [
- {
- num_droppable: 1,
- num_total: 1, // two assignments created in the factory, but 1 is expected per Studio settings
- short_label: 'HW',
- type: 'Homework',
- weight: 1,
- },
- ],
- grade_range: {
- pass: 0.75,
+ assignment_type_grade_summary: [
+ {
+ type: 'Homework',
+ weight: 1,
+ average_grade: 0.25,
+ weighted_grade: 0.25,
+ last_grade_publish_date: '2025-10-15T14:17:04.368903Z',
+ has_hidden_contribution: 'some',
+ short_label: 'HW',
+ num_droppable: 0,
},
- },
+ ],
});
await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 1 100% 100% 100%' })).toBeInTheDocument();
+ // Should show percent + hidden scores for grade and weighted grade
+ const hiddenScoresCells = screen.getAllByText(/% \+ Hidden Scores/);
+ expect(hiddenScoresCells).toHaveLength(2);
+ // Only correct visible scores should be shown (from subsection2)
+ // The correct visible score is 1/4 = 0.25 -> 25%
+ expect(hiddenScoresCells[0]).toHaveTextContent('25% + Hidden Scores');
+ expect(hiddenScoresCells[1]).toHaveTextContent('25% + Hidden Scores');
});
- it('calculates grades correctly when number of total assignments is greater than the number of assignments created', async () => {
+
+ it('displays a warning message with the latest due date when not all assignment scores are included in the total grade', async () => {
setTabData({
grading_policy: {
assignment_policies: [
{
num_droppable: 0,
- num_total: 5, // two assignments created in the factory, but 5 are expected per Studio settings
+ num_total: 2,
short_label: 'HW',
type: 'Homework',
weight: 1,
@@ -763,41 +758,36 @@ describe('Progress Tab', () => {
pass: 0.75,
},
},
- });
- await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 100% 20% 20%' })).toBeInTheDocument();
- });
- it('calculates weighted grades correctly', async () => {
- setTabData({
- grading_policy: {
- assignment_policies: [
- {
- num_droppable: 1,
- num_total: 2,
- short_label: 'HW',
- type: 'Homework',
- weight: 0.5,
- },
- {
- num_droppable: 0,
- num_total: 1,
- short_label: 'Ex',
- type: 'Exam',
- weight: 0.5,
- },
- ],
- grade_range: {
- pass: 0.75,
+ assignment_type_grade_summary: [
+ {
+ type: 'Homework',
+ weight: 1,
+ average_grade: 1,
+ weighted_grade: 1,
+ last_grade_publish_date: tomorrow.toISOString(),
+ has_hidden_contribution: 'none',
+ short_label: 'HW',
+ num_droppable: 0,
},
- },
+ ],
});
+
await fetchAndRender();
- expect(screen.getByText('Grade summary')).toBeInTheDocument();
- // The row is comprised of "{Assignment type} {footnote - optional} {weight} {grade} {weighted grade}"
- expect(screen.getByRole('row', { name: 'Homework 1 50% 100% 50%' })).toBeInTheDocument();
- expect(screen.getByRole('row', { name: 'Exam 50% 0% 0%' })).toBeInTheDocument();
+
+ const formattedDateTime = new Intl.DateTimeFormat('en', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: 'numeric',
+ timeZoneName: 'short',
+ }).format(tomorrow);
+
+ expect(
+ screen.getByText(
+ `Some assignment scores are not yet included in your total grade. These grades will be released by ${formattedDateTime}.`,
+ ),
+ ).toBeInTheDocument();
});
it('renders override notice', async () => {
diff --git a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx
index e075411f25..6cada4cbb4 100644
--- a/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx
+++ b/src/course-home/progress-tab/grades/course-grade/CourseGradeFooter.jsx
@@ -8,26 +8,57 @@ import { useModel } from '../../../../generic/model-store';
import GradeRangeTooltip from './GradeRangeTooltip';
import messages from '../messages';
+import { getLatestDueDateInFuture } from '../../utils';
+
+const ResponsiveText = ({
+ wideScreen, children, hasLetterGrades, passingGrade,
+}) => {
+ const className = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom';
+ const iconSize = wideScreen ? 'h3' : 'h4';
+
+ return (
+
+ {children}
+ {hasLetterGrades && (
+
+
+