diff --git a/src/course-home/data/api.js b/src/course-home/data/api.js
index 88d684c83e..abe4319fc1 100644
--- a/src/course-home/data/api.js
+++ b/src/course-home/data/api.js
@@ -1,8 +1,10 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { logInfo } from '@edx/frontend-platform/logging';
+import { getSequenceMetadata } from '../../courseware/data/api';
import { appendBrowserTimezoneToUrl } from '../../utils';
+
const calculateAssignmentTypeGrades = (points, assignmentWeight, numDroppable) => {
let dropCount = numDroppable;
// Drop the lowest grades
@@ -113,6 +115,36 @@ function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
}
export function normalizeOutlineBlocks(courseId, blocks) {
+ // Helper function to determine the effort time in seconds for a given block,
+ // using the effort_time field if it's a number, otherwise falling back to the estimated_time
+ // field (which can be a number or a string in "HH:MM:SS" format).
+ const getTimeSeconds = (block) => {
+ const payloadBase = {
+ id: block?.id,
+ type: block?.type,
+ effort_time: block?.effort_time,
+ estimated_time: block?.estimated_time,
+ };
+
+ if (typeof block.effort_time === 'number') {
+ return block.effort_time;
+ }
+
+ if (typeof block.estimated_time === 'number') {
+ return block.estimated_time;
+ }
+
+ if (typeof block.estimated_time === 'string') {
+ const parts = block.estimated_time.split(':').map(Number);
+ if (parts.length === 3 && parts.every(Number.isFinite)) {
+ const [hours, minutes, seconds] = parts;
+ const resolvedSeconds = (hours * 3600) + (minutes * 60) + seconds;
+ return resolvedSeconds;
+ }
+ }
+ return undefined;
+ };
+
const models = {
courses: {},
sections: {},
@@ -126,12 +158,16 @@ export function normalizeOutlineBlocks(courseId, blocks) {
title: block.display_name,
sectionIds: block.children || [],
hasScheduledContent: block.has_scheduled_content,
+ // Get showEstimatedTime from the course block
+ showEstimatedTime: block.show_estimated_time,
};
break;
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
+ // Get effortTime in seconds
+ effortTime: getTimeSeconds(block),
id: block.id,
title: block.display_name,
resumeBlock: block.resume_block,
@@ -146,7 +182,8 @@ export function normalizeOutlineBlocks(courseId, blocks) {
description: block.description,
due: block.due,
effortActivities: block.effort_activities,
- effortTime: block.effort_time,
+ // Get effortTime in seconds
+ effortTime: getTimeSeconds(block),
icon: block.icon,
id: block.id,
// The presence of a URL for the sequence indicates that we want this sequence to be a clickable
@@ -342,6 +379,72 @@ export function getTimeOffsetMillis(headerDate, requestTime, responseTime) {
return timeOffsetMillis;
}
+// Helper function to format estimated time in minutes, ensuring proper pluralization of "minute".
+async function hydrateMissingOutlineEffort(courseBlocks) {
+ if (!courseBlocks?.sequences) {
+ return courseBlocks;
+ }
+
+ const sequenceIdsNeedingHydration = Object.entries(courseBlocks.sequences)
+ .filter(([, sequence]) => typeof sequence?.effortTime !== 'number')
+ .map(([sequenceId]) => sequenceId);
+
+ if (sequenceIdsNeedingHydration.length === 0) {
+ return courseBlocks;
+ }
+
+ const hydrationResults = await Promise.allSettled(
+ sequenceIdsNeedingHydration.map(async (sequenceId) => {
+ const { sequence, units } = await getSequenceMetadata(sequenceId, { preview: '0' });
+
+ const unitsEffortSeconds = (units || []).reduce(
+ (total, unit) => total + (
+ typeof unit.effortTime === 'number'
+ ? unit.effortTime
+ : (typeof unit.estimatedTimeMinutes === 'number'
+ ? Math.ceil(unit.estimatedTimeMinutes * 60)
+ : 0)
+ ),
+ 0,
+ );
+
+ const hydratedEffortTime = typeof sequence?.effortTime === 'number'
+ ? sequence.effortTime
+ : (unitsEffortSeconds > 0 ? unitsEffortSeconds : undefined);
+
+ return {
+ sequenceId,
+ effortTime: hydratedEffortTime,
+ };
+ }),
+ );
+
+ const hydratedCourseBlocks = {
+ ...courseBlocks,
+ sequences: {
+ ...courseBlocks.sequences,
+ },
+ };
+
+ hydrationResults.forEach((result) => {
+ if (result.status !== 'fulfilled') {
+ return;
+ }
+
+ const { sequenceId, effortTime } = result.value;
+ if (typeof effortTime !== 'number') {
+ return;
+ }
+
+ hydratedCourseBlocks.sequences[sequenceId] = {
+ ...hydratedCourseBlocks.sequences[sequenceId],
+ effortTime,
+ };
+ });
+
+ return hydratedCourseBlocks;
+}
+
export async function getOutlineTabData(courseId) {
const url = `${getConfig().LMS_BASE_URL}/api/course_home/outline/${courseId}`;
const requestTime = Date.now();
@@ -368,7 +471,9 @@ export async function getOutlineTabData(courseId) {
const accessExpiration = camelCaseObject(data.access_expiration);
const certData = camelCaseObject(data.cert_data);
- const courseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
+ // Normalize the course blocks and then hydrate any missing effort times for sequences by summing the effort times of their child units (if available).
+ const normalizedCourseBlocks = data.course_blocks ? normalizeOutlineBlocks(courseId, data.course_blocks.blocks) : {};
+ const courseBlocks = await hydrateMissingOutlineEffort(normalizedCourseBlocks);
const courseGoals = camelCaseObject(data.course_goals);
const courseTools = camelCaseObject(data.course_tools);
const datesBannerInfo = camelCaseObject(data.dates_banner_info);
diff --git a/src/course-home/outline-tab/OutlineTab.jsx b/src/course-home/outline-tab/OutlineTab.jsx
index db33f55fc5..a9b0728745 100644
--- a/src/course-home/outline-tab/OutlineTab.jsx
+++ b/src/course-home/outline-tab/OutlineTab.jsx
@@ -47,6 +47,7 @@ const OutlineTab = () => {
courseBlocks: {
courses,
sections,
+ sequences,
},
courseGoals: {
selectedGoal,
@@ -75,6 +76,29 @@ const OutlineTab = () => {
const rootCourseId = courses && Object.keys(courses)[0];
+ // Calculate the total estimated time for the course by summing the effort time for each section and sequence in the course.
+ // The estimated time is displayed in the course outline next to the course title and is also passed down to the SequenceTitle
+ // component to be displayed next to each sequence in the outline
+ const courseMinuteCount = rootCourseId
+ ? (courses[rootCourseId].sectionIds || []).reduce((sectionTotal, sectionId) => {
+ const section = sections[sectionId];
+ if (typeof section?.effortTime === 'number') {
+ return sectionTotal + section.effortTime;
+ }
+ const sequenceTotalSeconds = (section?.sequenceIds || []).reduce(
+ (sequenceTotal, sequenceId) => sequenceTotal + (sequences[sequenceId]?.effortTime || 0),
+ 0,
+ );
+ return sectionTotal + sequenceTotalSeconds;
+ }, 0)
+ : 0;
+ const totalEstimatedMinutes = Math.ceil(courseMinuteCount / 60);
+ const totalEstimatedHours = Math.floor(totalEstimatedMinutes / 60);
+ const remainingEstimatedMinutes = totalEstimatedMinutes % 60;
+ const showOutlineEstimatedTime = rootCourseId
+ ? courses[rootCourseId]?.showEstimatedTime !== false
+ : true;
+
const hasDeadlines = courseDateBlocks && courseDateBlocks.some(x => x.dateType === 'assignment-due-date');
const logUpgradeToShiftDatesLinkClick = () => {
@@ -121,6 +145,19 @@ const OutlineTab = () => {
{title}
+ {/* Display the estimated time for the course next to the title if showOutlineEstimatedTime is true and estimated time > 0 */}
+ {showOutlineEstimatedTime && courseMinuteCount > 0 && (
+
diff --git a/src/course-home/outline-tab/messages.ts b/src/course-home/outline-tab/messages.ts
index 4c660feefa..be4d63d716 100644
--- a/src/course-home/outline-tab/messages.ts
+++ b/src/course-home/outline-tab/messages.ts
@@ -351,6 +351,22 @@ const messages = defineMessages({
defaultMessage: '{description}',
description: 'Used below an assignment title',
},
+ // New Estimated time messages that are added for the course outline
+ estimatedTimeToComplete: {
+ id: 'learning.outline.estimated-time-to-complete',
+ defaultMessage: 'Estimated Time to Complete: {minuteCount, plural, one {# minute} other {# minutes}}',
+ description: 'Shown with section, subsection, or course title when an effort estimate is available',
+ },
+ estimatedTimeToCompleteWithHours: {
+ id: 'learning.outline.estimated-time-to-complete-with-hours',
+ defaultMessage: 'Estimated Time to Complete: {hourCount, plural, one {# hour} other {# hours}} and {minuteCount, plural, one {# minute} other {# minutes}}',
+ description: 'Shown with course title when an effort estimate is at least one hour',
+ },
+ estimatedTimeMinutesAbbreviated: {
+ id: 'learning.outline.estimated-time-minutes-abbreviated',
+ defaultMessage: '{minuteCount, plural, one {# min} other {# min}}',
+ description: 'Compact effort estimate shown next to section/module titles in the outline',
+ },
});
export default messages;
diff --git a/src/course-home/outline-tab/section-outline/Section.tsx b/src/course-home/outline-tab/section-outline/Section.tsx
index f905294055..fb93bfd5c4 100644
--- a/src/course-home/outline-tab/section-outline/Section.tsx
+++ b/src/course-home/outline-tab/section-outline/Section.tsx
@@ -13,8 +13,12 @@ import SequenceLink from './SequenceLink';
interface Props {
defaultOpen: boolean;
expand: boolean;
+ // Added showOutlineEstimatedTime prop to control whether to show the estimated time for sections and sequences in the course outline.
+ showOutlineEstimatedTime?: boolean;
section: {
complete: boolean;
+ // Added effortTime to the section data to store the estimated time for the section
+ effortTime?: number;
sequenceIds: string[];
title: string;
hideFromTOC: boolean;
@@ -24,12 +28,16 @@ interface Props {
const Section: React.FC = ({
defaultOpen,
expand,
+ // Default showOutlineEstimatedTime to true to maintain existing behavior of
+ // showing estimated time in the course outline, but allow it to be turned off if needed
+ showOutlineEstimatedTime = true,
section,
}) => {
const intl = useIntl();
const courseId = useContextId();
const {
complete,
+ effortTime,
sequenceIds,
title,
hideFromTOC,
@@ -41,6 +49,12 @@ const Section: React.FC = ({
} = useModel('outline', courseId);
const [open, setOpen] = useState(defaultOpen);
+ const sectionEffortTime = typeof effortTime === 'number'
+ ? effortTime
+ : sequenceIds.reduce(
+ (total, sequenceId) => total + (sequences[sequenceId]?.effortTime || 0),
+ 0,
+ );
useEffect(() => {
setOpen(expand);
@@ -56,7 +70,15 @@ const Section: React.FC = ({
}
+ // Pass the calculated sectionEffortTime to the SectionTitle component to display the estimated time for
+ // the section in the course outline.
+ title={}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
@@ -82,6 +104,9 @@ const Section: React.FC = ({
key={sequenceId}
id={sequenceId}
sequence={sequences[sequenceId]}
+ // Pass the showOutlineEstimatedTime prop down to the SequenceLink component to control whether to
+ // show the estimated time for sequences in the course outline.
+ showOutlineEstimatedTime={showOutlineEstimatedTime}
first={index === 0}
/>
))}
diff --git a/src/course-home/outline-tab/section-outline/SectionTitle.tsx b/src/course-home/outline-tab/section-outline/SectionTitle.tsx
index 69c4ddfd98..abbb4f837b 100644
--- a/src/course-home/outline-tab/section-outline/SectionTitle.tsx
+++ b/src/course-home/outline-tab/section-outline/SectionTitle.tsx
@@ -9,10 +9,26 @@ interface Props {
complete: boolean;
hideFromTOC: boolean;
title: string;
+ // Added effortTime to the section data to store the estimated time for the section
+ effortTime?: number;
+ showOutlineEstimatedTime?: boolean;
}
-const SectionTitle: React.FC = ({ complete, hideFromTOC, title }) => {
+// Component to render the section title in the course outline, including the completion status, title, and estimated time (if available and enabled)
+const SectionTitle: React.FC = ({
+ complete,
+ hideFromTOC,
+ title,
+ effortTime = 0,
+ showOutlineEstimatedTime = true,
+}) => {
const intl = useIntl();
+ const minuteCount = effortTime > 0 ? Math.ceil(effortTime / 60) : 0;
+ const minutesLabel = showOutlineEstimatedTime
+ ? (minuteCount > 0
+ ? intl.formatMessage(messages.estimatedTimeMinutesAbbreviated, { minuteCount })
+ : 'x min')
+ : null;
return (
{title}
+ {minutesLabel && (
+
+ {minutesLabel}
+
+ )}
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
diff --git a/src/course-home/outline-tab/section-outline/SequenceLink.tsx b/src/course-home/outline-tab/section-outline/SequenceLink.tsx
index a0eda629df..f61e7b7da1 100644
--- a/src/course-home/outline-tab/section-outline/SequenceLink.tsx
+++ b/src/course-home/outline-tab/section-outline/SequenceLink.tsx
@@ -8,6 +8,8 @@ import SequenceTitle from './SequenceTitle';
interface Props {
id: string;
first: boolean;
+ // Added showOutlineEstimatedTime prop to control whether to show the estimated time for sequences in the course outline
+ showOutlineEstimatedTime?: boolean;
sequence: {
complete: boolean;
description: string;
@@ -21,6 +23,9 @@ interface Props {
const SequenceLink: React.FC = ({
id,
first,
+ // Default showOutlineEstimatedTime to true to maintain existing behavior of showing estimated time in the course outline,
+ // but allow it to be turned off if needed (e.g. for a more simplified outline view).
+ showOutlineEstimatedTime = true,
sequence,
}) => {
const {
@@ -32,6 +37,8 @@ const SequenceLink: React.FC = ({
hideFromTOC,
} = sequence;
+ // showOutlineEstimatedTime is passed down to the SequenceTitle component to control whether to show the estimated
+ // time for sequences in the course
return (
@@ -41,6 +48,7 @@ const SequenceLink: React.FC = ({
showLink,
title,
sequence,
+ showOutlineEstimatedTime,
id,
}}
/>
diff --git a/src/course-home/outline-tab/section-outline/SequenceTitle.tsx b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
index ec035dfa28..a945833915 100644
--- a/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
+++ b/src/course-home/outline-tab/section-outline/SequenceTitle.tsx
@@ -11,14 +11,22 @@ import { useContextId } from '../../../data/hooks';
interface Props {
complete: boolean;
showLink: boolean;
+ //Added showOutlineEstimatedTime and sequence data to the SequenceTitle component to control whether to show the
+ // estimated time for sequences in the course outline and to pass the estimated time data down to the component
+ showOutlineEstimatedTime?: boolean;
title: string;
- sequence: object;
+ sequence: {
+ effortActivities?: number;
+ effortTime?: number;
+ };
id: string;
}
const SequenceTitle: React.FC = ({
complete,
showLink,
+ // Default showOutlineEstimatedTime to true (can be turned off later)
+ showOutlineEstimatedTime = true,
title,
sequence,
id,
@@ -27,6 +35,13 @@ const SequenceTitle: React.FC = ({
const courseId = useContextId();
const coursewareUrl = {title};
const displayTitle = showLink ? coursewareUrl : title;
+ const minuteCount = sequence?.effortTime ? Math.ceil(sequence.effortTime / 60) : 0;
+ // Format of the estimated time for seuqences in the course outline
+ const minutesLabel = showOutlineEstimatedTime
+ ? (minuteCount > 0
+ ? intl.formatMessage(messages.estimatedTimeMinutesAbbreviated, { minuteCount })
+ : 'x min')
+ : null;
return (
{title}
+ {/* Display the estimated time for the unit next to the title if showOutlineEstimatedTime is true and estimated time > 0 */}
+ {showOutlineEstimatedTime && minuteCount > 0 && (
+
+ {intl.formatMessage(messages.estimatedTimeMinutesAbbreviated, { minuteCount })}
+
+ )}
, {intl.formatMessage(complete ? messages.completedUnit : messages.incompleteUnit)}
@@ -56,6 +69,10 @@ SidebarUnit.propTypes = {
isFirst: PropTypes.bool.isRequired,
unit: PropTypes.shape({
complete: PropTypes.bool,
+ // Allow passing effort time directly on the unit for more accurate time
+ // estimates, but fall back to using estimatedTimeMinutes if effortTime is not provided
+ estimatedTimeMinutes: PropTypes.number,
+ effortTime: PropTypes.number,
icon: PropTypes.string,
id: PropTypes.string,
title: PropTypes.string,
@@ -66,6 +83,13 @@ SidebarUnit.propTypes = {
courseId: PropTypes.string.isRequired,
sequenceId: PropTypes.string.isRequired,
activeUnitId: PropTypes.string.isRequired,
+ // Pass showOutlineEstimatedTime down to the SidebarUnit component
+ showOutlineEstimatedTime: PropTypes.bool,
+};
+
+// Show the estimated time for units in the sidebar by default
+SidebarUnit.defaultProps = {
+ showOutlineEstimatedTime: true,
};
export default SidebarUnit;
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx b/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
index c685fe59d2..c1341d8ae8 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
+++ b/src/courseware/course/sidebar/sidebars/course-outline/hooks.jsx
@@ -5,7 +5,7 @@ import { sendTrackEvent, sendTrackingLogEvent } from '@edx/frontend-platform/ana
import { useModel } from '@src/generic/model-store';
import { LOADED } from '@src/constants';
-import { checkBlockCompletion, getCourseOutlineStructure } from '@src/courseware/data/thunks';
+import { checkBlockCompletion, getCourseOutlineStructure, hydrateOutlineEffortData } from '@src/courseware/data/thunks';
import OldSidebarContext from '@src/courseware/course/sidebar/SidebarContext';
import NewSidebarContext from '@src/courseware/course/new-sidebar/SidebarContext';
import {
@@ -27,7 +27,15 @@ export const useCourseOutlineSidebar = () => {
const courseOutlineStatus = useSelector(getCourseOutlineStatus);
const sequenceStatus = useSelector(getSequenceStatus);
const activeSequenceId = useSelector(getSequenceId);
- const { sections = {}, sequences = {}, units = {} } = useSelector(getCourseOutline);
+ // Selectors to get the course outline data from the store
+ const {
+ courses = {},
+ sections = {},
+ sequences = {},
+ units = {},
+ } = useSelector(getCourseOutline);
+ const modelSequences = useSelector(state => state.models?.sequences || {});
+ const modelUnits = useSelector(state => state.models?.units || {});
const { courseId } = useParams();
const course = useModel('coursewareMeta', courseId);
@@ -44,12 +52,19 @@ export const useCourseOutlineSidebar = () => {
const isOpenSidebar = !initialSidebar && isEnabledSidebar && !isCollapsedOutlineSidebar;
const [isOpen, setIsOpen] = useState(true);
+ // Local state to track whether effort data has been hydrated to prevent unnecessary hydration calls
+ const [isEffortHydrated, setIsEffortHydrated] = useState(false);
const {
entranceExamEnabled,
entranceExamPassed,
} = course.entranceExamData || {};
const isActiveEntranceExam = entranceExamEnabled && !entranceExamPassed;
+ const rootCourseId = Object.keys(courses)[0];
+ // Determine whether to show estimated time in the sidebar based on the course setting, defaulting to true if the setting is not defined
+ const showOutlineEstimatedTime = rootCourseId
+ ? courses[rootCourseId]?.showEstimatedTime !== false
+ : true;
const handleToggleCollapse = () => {
if (currentSidebar === ID) {
@@ -104,6 +119,29 @@ export const useCourseOutlineSidebar = () => {
}
}, [courseId, isEnabledSidebar, courseOutlineShouldUpdate]);
+ // Reset effort hydration state when courseId changes to ensure effort data is hydrated for the new course
+ useEffect(() => {
+ setIsEffortHydrated(false);
+ }, [courseId]);
+
+ // Hydrate effort data for the course outline when the outline is loaded and effort data has not yet been hydrated
+ useEffect(() => {
+ if (!isEnabledSidebar || courseOutlineStatus !== LOADED || isEffortHydrated) {
+ return;
+ }
+
+ // Get all sequence IDs from the course outline to hydrate effort data for each sequence.
+ const allSequenceIds = Object.keys(sequences);
+ if (allSequenceIds.length === 0) {
+ setIsEffortHydrated(true);
+ return;
+ }
+
+ // Dispatch the hydrateOutlineEffortData thunk to calculate and store effort data for each sequence in the course outline
+ dispatch(hydrateOutlineEffortData(allSequenceIds));
+ setIsEffortHydrated(true);
+ }, [courseOutlineStatus, dispatch, isEffortHydrated, isEnabledSidebar, sequences]);
+
return {
courseId,
unitId,
@@ -119,6 +157,10 @@ export const useCourseOutlineSidebar = () => {
sections,
sequences,
units,
+ // Pass model data to be accessed in the SidebarSection, SidebarSequence, and SidebarUnit components for effort time calculations
+ showOutlineEstimatedTime,
+ modelSequences,
+ modelUnits,
handleUnitClick,
sequenceStatus,
};
diff --git a/src/courseware/course/sidebar/sidebars/course-outline/messages.ts b/src/courseware/course/sidebar/sidebars/course-outline/messages.ts
index a16aa0eea3..fb1655c1f8 100644
--- a/src/courseware/course/sidebar/sidebars/course-outline/messages.ts
+++ b/src/courseware/course/sidebar/sidebars/course-outline/messages.ts
@@ -26,6 +26,12 @@ const messages = defineMessages({
defaultMessage: 'Incomplete unit',
description: 'Text used to describe the gray checkmark icon in front of a unit title',
},
+ // Added message for the estimated time label for sections and sequences in the course outline
+ estimatedTimeMinutesAbbreviated: {
+ id: 'courseOutline.estimatedTimeMinutesAbbreviated',
+ defaultMessage: '{minuteCount, plural, one {# min} other {# min}}',
+ description: 'Compact effort estimate for section or subsection rows in the course outline sidebar',
+ },
});
export default messages;
diff --git a/src/courseware/data/thunks.js b/src/courseware/data/thunks.js
index 3f84fbf221..0ef4cc9bed 100644
--- a/src/courseware/data/thunks.js
+++ b/src/courseware/data/thunks.js
@@ -286,3 +286,49 @@ export function getCourseOutlineStructure(courseId) {
}
};
}
+
+/**
+ * This thunk is used to hydrate the effort data for the course outline.
+ * It is called when the course outline is loaded and when a sequence is loaded in case the
+ * effort data was not included in the initial course outline response.
+ * The effort data is used to display the estimated time for each sequence in the course outline.
+ */
+export function hydrateOutlineEffortData(sequenceIds, isPreview = false) {
+ return async (dispatch) => {
+ if (!Array.isArray(sequenceIds) || sequenceIds.length === 0) {
+ return;
+ }
+
+ const preview = isPreview ? '1' : '0';
+
+ await Promise.allSettled(
+ sequenceIds.map(async (sequenceId) => {
+ const { sequence, units } = await getSequenceMetadata(sequenceId, { preview });
+ dispatch(updateModels({
+ modelType: 'units',
+ models: units,
+ }));
+
+ const unitsEffortSeconds = units.reduce(
+ (total, unit) => total + (
+ typeof unit.effortTime === 'number'
+ ? unit.effortTime
+ : (typeof unit.estimatedTimeMinutes === 'number' ? Math.ceil(unit.estimatedTimeMinutes * 60) : 0)
+ ),
+ 0,
+ );
+
+ dispatch(updateModel({
+ modelType: 'sequences',
+ model: {
+ id: sequence.id,
+ effortActivities: sequence.effortActivities,
+ effortTime: typeof sequence.effortTime === 'number'
+ ? sequence.effortTime
+ : (unitsEffortSeconds > 0 ? unitsEffortSeconds : undefined),
+ },
+ }));
+ }),
+ );
+ };
+}
diff --git a/src/courseware/data/utils.js b/src/courseware/data/utils.js
index eaf65c46ed..f7676e3e8f 100644
--- a/src/courseware/data/utils.js
+++ b/src/courseware/data/utils.js
@@ -23,7 +23,10 @@ export function normalizeLearningSequencesData(learningSequencesData) {
return; // Don't let the learner see unreleased sequences
}
+ // Determine which units to show estimated time for in the course outline
models.sequences[seqId] = {
+ effortActivities: sequence.effort_activities,
+ effortTime: sequence.effort_time,
id: seqId,
title: sequence.title,
};
@@ -44,6 +47,9 @@ export function normalizeLearningSequencesData(learningSequencesData) {
models.sections[section.id] = {
id: section.id,
title: section.title,
+ // Keep the effort data for sections
+ effortActivities: section.effort_activities,
+ effortTime: section.effort_time,
sequenceIds: availableSequenceIds,
courseId: learningSequencesData.course_key,
};
@@ -111,6 +117,9 @@ export function normalizeSequenceMetadata(sequence) {
sequence: {
id: sequence.item_id,
blockType: sequence.tag,
+ // Keep the effort data for sequences
+ effortActivities: sequence.effort_activities,
+ effortTime: sequence.effort_time,
unitIds: sequence.items.map(unit => unit.id),
bannerText: sequence.banner_text,
format: sequence.format,
@@ -144,6 +153,10 @@ export function normalizeSequenceMetadata(sequence) {
contentType: unit.type,
graded: unit.graded,
containsContentTypeGatedContent: unit.contains_content_type_gated_content,
+ // Keep the effort data for units
+ effortTime: unit.effort_time,
+ estimatedTimeMinutes: unit.estimated_time_minutes,
+ showEstimatedTime: unit.show_estimated_time,
})),
};
}
@@ -155,16 +168,49 @@ export function normalizeSequenceMetadata(sequence) {
* @returns {Object} - An object with normalized sections, sequences, and units.
*/
export function normalizeOutlineBlocks(courseId, blocks) {
+ // Get the effort time in seconds for a given block, prioritizing effort_time but falling back to estimated_time_minutes if effort_time is not available
+ const getEffortTimeSeconds = (block) => {
+ const payloadBase = {
+ id: block?.id,
+ type: block?.type,
+ effort_time: block?.effort_time,
+ estimated_time_minutes: block?.estimated_time_minutes,
+ };
+
+ if (typeof block.effort_time === 'number') {
+ return block.effort_time;
+ }
+ if (typeof block.estimated_time_minutes === 'number') {
+ const resolvedSeconds = Math.ceil(block.estimated_time_minutes * 60);
+ return resolvedSeconds;
+ }
+
+ return undefined;
+ };
+
const models = {
+ courses: {},
sections: {},
sequences: {},
units: {},
};
Object.values(blocks).forEach(block => {
switch (block.type) {
+ // If we have a course block, we want to add it to the models with its section children
+ case 'course':
+ models.courses[block.id] = {
+ id: courseId,
+ sectionIds: block.children || [],
+ showEstimatedTime: block.show_estimated_time,
+ };
+ break;
+
case 'chapter':
models.sections[block.id] = {
complete: block.complete,
+ // Keep the effort data for chapters
+ effortActivities: block.effort_activities,
+ effortTime: getEffortTimeSeconds(block),
id: block.id,
title: block.display_name,
sequenceIds: block.children || [],
@@ -179,6 +225,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
case 'lock':
models.sequences[block.id] = {
complete: block.complete,
+ // Keep the effort data for sequences
+ effortActivities: block.effort_activities,
+ effortTime: getEffortTimeSeconds(block),
id: block.id,
title: block.display_name,
type: block.type,
@@ -194,6 +243,9 @@ export function normalizeOutlineBlocks(courseId, blocks) {
case 'vertical':
models.units[block.id] = {
complete: block.complete,
+ // Keep the effort data for units
+ estimatedTimeMinutes: block.estimated_time_minutes,
+ effortTime: getEffortTimeSeconds(block),
icon: block.icon,
id: block.id,
title: block.display_name,
diff --git a/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx
index ff188513f8..470c6968e3 100644
--- a/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx
+++ b/src/plugin-slots/CourseHomeSectionOutlineSlot/index.tsx
@@ -6,10 +6,11 @@ interface Props {
expandAll: boolean;
sections: object;
sectionIds: string[];
+ showOutlineEstimatedTime?: boolean;
}
const CourseHomeSectionOutlineSlot: React.FC = ({
- expandAll, sections, sectionIds,
+ expandAll, sections, sectionIds, showOutlineEstimatedTime = true,
}) => (
= ({
defaultOpen={sections[sectionId].resumeBlock}
expand={expandAll}
section={sections[sectionId]}
+ // Pass showOutlineEstimatedTime down to the Section component to control whether
+ // to show the estimated time for sections and sequences in the course outline
+ showOutlineEstimatedTime={showOutlineEstimatedTime}
/>
))}
diff --git a/src/plugin-slots/UnitTitleSlot/index.jsx b/src/plugin-slots/UnitTitleSlot/index.jsx
index f753efc921..365d86a8b6 100644
--- a/src/plugin-slots/UnitTitleSlot/index.jsx
+++ b/src/plugin-slots/UnitTitleSlot/index.jsx
@@ -13,6 +13,8 @@ const UnitTitleSlot = ({
}) => {
const { formatMessage } = useIntl();
const isProcessing = unit.bookmarkedUpdateState === 'loading';
+ // Calculate the estimated hours and minutes for the unit based on the estimated time
+ const estimatedMinutes = (unit.estimatedTimeMinutes || 0) % 60;
return (
{unit.title}
+ {/* Display the estimated time for the unit below the title if showEstimatedTime is true and estimatedTimeMinutes > 0 */}
+ {unit.showEstimatedTime && unit.estimatedTimeMinutes > 0 && (
+
@@ -48,6 +67,9 @@ UnitTitleSlot.propTypes = {
bookmarked: PropTypes.bool.isRequired,
title: PropTypes.string.isRequired,
bookmarkedUpdateState: PropTypes.string.isRequired,
+ // Added estimatedTimeMinutes and showEstimatedTime to the unit prop types for the estimated time display in the UnitTitleSlot
+ estimatedTimeMinutes: PropTypes.number,
+ showEstimatedTime: PropTypes.bool,
}).isRequired,
isEnabledOutlineSidebar: PropTypes.bool.isRequired,
renderUnitNavigation: PropTypes.func.isRequired,