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 && ( + +   + + + )} + + ); +}; + +const NoticeRow = ({ + wideScreen, icon, bgClass, message, +}) => { + const textClass = wideScreen ? 'h4 m-0 align-bottom' : 'h5 align-bottom'; + return ( +
+
{icon}
+
+ {message} +
+
+ ); +}; const CourseGradeFooter = ({ passingGrade }) => { const intl = useIntl(); const courseId = useContextId(); const { - courseGrade: { - isPassing, - letterGrade, - }, - gradingPolicy: { - gradeRange, - }, + assignmentTypeGradeSummary, + courseGrade: { isPassing, letterGrade }, + gradingPolicy: { gradeRange }, } = useModel('progress', courseId); + const latestDueDate = getLatestDueDateInFuture(assignmentTypeGradeSummary); const wideScreen = useWindowSize().width >= breakpoints.medium.minWidth; + const hasLetterGrades = Object.keys(gradeRange).length > 1; - const hasLetterGrades = Object.keys(gradeRange).length > 1; // A pass/fail course will only have one key + // build footer text let footerText = intl.formatMessage(messages.courseGradeFooterNonPassing, { passingGrade }); - if (isPassing) { if (hasLetterGrades) { const minGradeRangeCutoff = gradeRange[letterGrade] * 100; @@ -47,42 +78,63 @@ const CourseGradeFooter = ({ passingGrade }) => { } } - const icon = isPassing ? - : ; + const passingIcon = isPassing ? ( + + ) : ( + + ); return ( -
-
- {icon} -
-
- {!wideScreen && ( - - {footerText} - {hasLetterGrades && ( - -   - - - )} - - )} - {wideScreen && ( - +
+ {footerText} - {hasLetterGrades && ( - -   - - - )} - + )} -
+ /> + {latestDueDate && ( + } + bgClass="bg-warning-100" + message={intl.formatMessage(messages.courseGradeFooterDueDateNotice, { + dueDate: intl.formatDate(latestDueDate, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + }), + })} + /> + )}
); }; +ResponsiveText.propTypes = { + wideScreen: PropTypes.bool.isRequired, + children: PropTypes.node.isRequired, + hasLetterGrades: PropTypes.bool.isRequired, + passingGrade: PropTypes.number.isRequired, +}; + +NoticeRow.propTypes = { + wideScreen: PropTypes.bool.isRequired, + icon: PropTypes.element.isRequired, + bgClass: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, +}; + CourseGradeFooter.propTypes = { passingGrade: PropTypes.number.isRequired, }; diff --git a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx index 36ba44e926..8e1c6b2985 100644 --- a/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx +++ b/src/course-home/progress-tab/grades/course-grade/CurrentGradeTooltip.jsx @@ -13,6 +13,7 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const courseId = useContextId(); const { + assignmentTypeGradeSummary, courseGrade: { isPassing, percent, @@ -25,6 +26,8 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { const isLocaleRtl = isRtl(getLocale()); + const hasHiddenGrades = assignmentTypeGradeSummary.some((assignmentType) => assignmentType.hasHiddenContribution !== 'none'); + if (isLocaleRtl) { currentGradeDirection = currentGrade < 50 ? '-' : ''; } @@ -56,6 +59,15 @@ const CurrentGradeTooltip = ({ tooltipClassName }) => { > {intl.formatMessage(messages.currentGradeLabel)} + + {hasHiddenGrades ? ` + ${intl.formatMessage(messages.hiddenScoreLabel)}` : ''} + ); }; diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx index ffc5e2c890..6066997a9f 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummary.jsx @@ -10,14 +10,12 @@ const GradeSummary = () => { const courseId = useContextId(); const { - gradingPolicy: { - assignmentPolicies, - }, + assignmentTypeGradeSummary, } = useModel('progress', courseId); const [allOfSomeAssignmentTypeIsLocked, setAllOfSomeAssignmentTypeIsLocked] = useState(false); - if (assignmentPolicies.length === 0) { + if (assignmentTypeGradeSummary.length === 0) { return null; } diff --git a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx index b6e5ceafbb..44129521e1 100644 --- a/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx +++ b/src/course-home/progress-tab/grades/grade-summary/GradeSummaryTable.jsx @@ -2,6 +2,7 @@ import PropTypes from 'prop-types'; import { getLocale, isRtl, useIntl } from '@edx/frontend-platform/i18n'; import { DataTable } from '@openedx/paragon'; +import { Lock } from '@openedx/paragon/icons'; import { useContextId } from '../../../../data/hooks'; import { useModel } from '../../../../generic/model-store'; @@ -16,9 +17,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { const courseId = useContextId(); const { - gradingPolicy: { - assignmentPolicies, - }, + assignmentTypeGradeSummary, gradesFeatureIsFullyLocked, sectionScores, } = useModel('progress', courseId); @@ -55,7 +54,7 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { return false; }; - const gradeSummaryData = assignmentPolicies.map((assignment) => { + const gradeSummaryData = assignmentTypeGradeSummary.map((assignment) => { const { averageGrade, numDroppable, @@ -80,13 +79,24 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { const locked = !gradesFeatureIsFullyLocked && hasNoAccessToAssignmentsOfType(assignmentType); const isLocaleRtl = isRtl(getLocale()); + let weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`; + let gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`; + + if (assignment.hasHiddenContribution === 'all') { + gradeDisplay = ; + weightedGradeDisplay = ; + } else if (assignment.hasHiddenContribution === 'some') { + gradeDisplay = `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; + weightedGradeDisplay = `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}% + ${intl.formatMessage(messages.hiddenScoreLabel)}`; + } + return { type: { footnoteId, footnoteMarker, type: assignmentType, locked, }, weight: { weight: `${(weight * 100).toFixed(0)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, - grade: { grade: `${getGradePercent(averageGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, - weightedGrade: { weightedGrade: `${getGradePercent(weightedGrade)}${isLocaleRtl ? '\u200f' : ''}%`, locked }, + grade: { grade: gradeDisplay, locked }, + weightedGrade: { weightedGrade: weightedGradeDisplay, locked }, }; }); const getAssignmentTypeCell = (value) => ( @@ -102,6 +112,16 @@ const GradeSummaryTable = ({ setAllOfSomeAssignmentTypeIsLocked }) => { return ( <> +
    +
  • + {intl.formatMessage(messages.hiddenScoreLabel)}: + {intl.formatMessage(messages.hiddenScoreInfoText)} +
  • +
  • + : + {` ${intl.formatMessage(messages.hiddenScoreLockInfoText)}`} +
  • +
{ const intl = useIntl(); - - const { data } = useContext(DataTableContext); - - const rawGrade = data.reduce( - (grade, currentValue) => { - const { weightedGrade } = currentValue.weightedGrade; - const percent = weightedGrade.replace(/%/g, '').trim(); - return grade + parseFloat(percent); - }, - 0, - ).toFixed(2); - const courseId = useContextId(); const { @@ -36,8 +21,16 @@ const GradeSummaryTableFooter = () => { isPassing, percent, }, + finalGrades, } = useModel('progress', courseId); + const getGradePercent = (grade) => { + const percentage = grade * 100; + return Number.isInteger(percentage) ? percentage.toFixed(0) : percentage.toFixed(2); + }; + + const rawGrade = getGradePercent(finalGrades); + const bgColor = isPassing ? 'bg-success-100' : 'bg-warning-100'; const totalGrade = (percent * 100).toFixed(0); diff --git a/src/course-home/progress-tab/grades/messages.ts b/src/course-home/progress-tab/grades/messages.ts index a052096c4f..2754374461 100644 --- a/src/course-home/progress-tab/grades/messages.ts +++ b/src/course-home/progress-tab/grades/messages.ts @@ -21,6 +21,11 @@ const messages = defineMessages({ defaultMessage: 'Your current grade is {currentGrade}%. A weighted grade of {passingGrade}% is required to pass in this course.', description: 'Alt text for the grade chart bar', }, + courseGradeFooterDueDateNotice: { + id: 'progress.courseGrade.footer.dueDateNotice', + defaultMessage: 'Some assignment scores are not yet included in your total grade. These grades will be released by {dueDate}.', + description: 'This is shown when there are pending assignments with a due date in the future', + }, courseGradeFooterGenericPassing: { id: 'progress.courseGrade.footer.generic.passing', defaultMessage: 'You’re currently passing this course', @@ -148,6 +153,21 @@ const messages = defineMessages({ + "Your weighted grade is what's used to determine if you pass the course.", description: 'The content of (tip box) for the grade summary section', }, + hiddenScoreLabel: { + id: 'progress.hiddenScoreLabel', + defaultMessage: 'Hidden Scores', + description: 'Text to indicate that some scores are hidden', + }, + hiddenScoreInfoText: { + id: 'progress.hiddenScoreInfoText', + defaultMessage: 'Scores from assignments that count toward your final grade but some are not shown here.', + description: 'Information text about hidden score label', + }, + hiddenScoreLockInfoText: { + id: 'progress.hiddenScoreLockInfoText', + defaultMessage: 'Scores for an assignment type are hidden but still counted toward the course grade.', + description: 'Information text about hidden score label when learners have limited access to grades feature', + }, noAccessToAssignmentType: { id: 'progress.noAcessToAssignmentType', defaultMessage: 'You do not have access to assignments of type {assignmentType}', diff --git a/src/course-home/progress-tab/utils.ts b/src/course-home/progress-tab/utils.ts index 29dd42de85..aeb40f5099 100644 --- a/src/course-home/progress-tab/utils.ts +++ b/src/course-home/progress-tab/utils.ts @@ -5,3 +5,15 @@ export const showUngradedAssignments = () => ( getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === 'true' || getConfig().SHOW_UNGRADED_ASSIGNMENT_PROGRESS === true ); + +export const getLatestDueDateInFuture = (assignmentTypeGradeSummary) => { + let latest = null; + assignmentTypeGradeSummary.forEach((assignment) => { + const assignmentLastGradePublishDate = assignment.lastGradePublishDate; + if (assignmentLastGradePublishDate && (!latest || new Date(assignmentLastGradePublishDate) > new Date(latest)) + && new Date(assignmentLastGradePublishDate) > new Date()) { + latest = assignmentLastGradePublishDate; + } + }); + return latest; +}; From 51844a69656a58d60736c9b4ce7bd6c2662932fd Mon Sep 17 00:00:00 2001 From: nsprenkle Date: Mon, 5 Jan 2026 15:59:05 -0500 Subject: [PATCH 2/2] chore: update snapshots --- .../data/__snapshots__/redux.test.js.snap | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/course-home/data/__snapshots__/redux.test.js.snap b/src/course-home/data/__snapshots__/redux.test.js.snap index 1b0363e660..02cbc490e6 100644 --- a/src/course-home/data/__snapshots__/redux.test.js.snap +++ b/src/course-home/data/__snapshots__/redux.test.js.snap @@ -765,6 +765,19 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal "progress": { "course-v1:edX+DemoX+Demo_Course": { "accessExpiration": null, + "assignmentTypeGradeSummary": [ + { + "averageGrade": 1, + "hasHiddenContribution": "none", + "lastGradePublishDate": null, + "numDroppable": 1, + "numTotal": 2, + "shortLabel": "HW", + "type": "Homework", + "weight": 1, + "weightedGrade": 1, + }, + ], "certificateData": {}, "completionSummary": { "completeCount": 1, @@ -780,17 +793,17 @@ exports[`Data layer integration tests Test fetchProgressTab Should fetch, normal "creditCourseRequirements": null, "end": "3027-03-31T00:00:00Z", "enrollmentMode": "audit", + "finalGrades": 0.5, "gradesFeatureIsFullyLocked": false, "gradesFeatureIsPartiallyLocked": false, "gradingPolicy": { "assignmentPolicies": [ { - "averageGrade": "1.0000", "numDroppable": 1, + "numTotal": 2, "shortLabel": "HW", "type": "Homework", "weight": 1, - "weightedGrade": 1, }, ], "gradeRange": {