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 && ( +
+ {totalEstimatedMinutes >= 60 + ? intl.formatMessage(messages.estimatedTimeToCompleteWithHours, { + hourCount: totalEstimatedHours, + minuteCount: remainingEstimatedMinutes, + }) + : intl.formatMessage(messages.estimatedTimeToComplete, { + minuteCount: totalEstimatedMinutes, + })} +
+ )}
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 (
@@ -36,6 +52,11 @@ const SectionTitle: React.FC = ({ complete, hideFromTOC, title }) => {
{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 (
    @@ -54,7 +69,12 @@ const SequenceTitle: React.FC = ({ , {intl.formatMessage(complete ? messages.completedAssignment : messages.incompleteAssignment)} - + {minutesLabel && ( + + {minutesLabel} + + )} +
    ); diff --git a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx index a2ebfaf0af..4449936b81 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/CourseOutlineTray.jsx @@ -33,6 +33,8 @@ const CourseOutlineTray = () => { activeSequenceId, sections, sequences, + // Pass showOutlineEstimatedTime down through the sidebar context so it can be accessed in the SidebarSection, SidebarSequence, and SidebarUnit components + showOutlineEstimatedTime, } = useCourseOutlineSidebar(); const { @@ -116,6 +118,8 @@ const CourseOutlineTray = () => { sequence={sequences[sequenceId]} defaultOpen={sequenceId === activeSequenceId} activeUnitId={unitId} + // Pass showOutlineEstimatedTime down to the SidebarSequence component + showOutlineEstimatedTime={showOutlineEstimatedTime} /> )) : sectionsIds.map((sectionId) => ( @@ -124,6 +128,8 @@ const CourseOutlineTray = () => { courseId={courseId} section={sections[sectionId]} handleSelectSection={handleSelectSection} + // Pass showOutlineEstimatedTime down to the SidebarSection component + showOutlineEstimatedTime={showOutlineEstimatedTime} /> ))} diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx index 2fb02ab805..dac03c5407 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSection.jsx @@ -7,8 +7,9 @@ import { ChevronRight as ChevronRightIcon } from '@openedx/paragon/icons'; import courseOutlineMessages from '@src/course-home/outline-tab/messages'; import CompletionIcon from './CompletionIcon'; import { useCourseOutlineSidebar } from '../hooks'; +import messages from '../messages'; -const SidebarSection = ({ section, handleSelectSection }) => { +const SidebarSection = ({ section, handleSelectSection, showOutlineEstimatedTime }) => { const intl = useIntl(); const { id, @@ -16,11 +17,48 @@ const SidebarSection = ({ section, handleSelectSection }) => { title, sequenceIds, completionStat, + effortTime, } = section; - const { activeSequenceId } = useCourseOutlineSidebar(); + const { + activeSequenceId, + sequences, + units, + modelSequences, + modelUnits, + } = useCourseOutlineSidebar(); const isActiveSection = sequenceIds.includes(activeSequenceId); + // Calculate section effort time by first checking if the section has an explicit effort time. + // If not, sum the effort times of the section's sequences + const getSequenceEffortSeconds = (sequenceId) => { + const sequenceEffort = sequences[sequenceId]?.effortTime ?? modelSequences[sequenceId]?.effortTime; + if (typeof sequenceEffort === 'number') { + return sequenceEffort; + } + + const sequenceUnitIds = sequences[sequenceId]?.unitIds || []; + return sequenceUnitIds.reduce( + (total, unitId) => total + ( + units[unitId]?.effortTime + ?? modelUnits[unitId]?.effortTime + ?? (typeof modelUnits[unitId]?.estimatedTimeMinutes === 'number' + ? Math.ceil(modelUnits[unitId].estimatedTimeMinutes * 60) + : 0) + ), + 0, + ); + }; + + const sectionEffortTime = typeof effortTime === 'number' + ? effortTime + : sequenceIds.reduce( + (total, sequenceId) => total + getSequenceEffortSeconds(sequenceId), + 0, + ); + // Convert effort time to minutes, rounding up to ensujre a default time of at least 1 minute + const minuteCount = sectionEffortTime > 0 ? Math.ceil(sectionEffortTime / 60) : 0; + const sectionTitle = ( <>
    @@ -28,6 +66,11 @@ const SidebarSection = ({ section, handleSelectSection }) => {
    {title} + {showOutlineEstimatedTime && minuteCount > 0 && ( + + {intl.formatMessage(messages.estimatedTimeMinutesAbbreviated, { minuteCount })} + + )} , {intl.formatMessage(complete ? courseOutlineMessages.completedSection @@ -60,12 +103,19 @@ SidebarSection.propTypes = { id: PropTypes.string, title: PropTypes.string, sequenceIds: PropTypes.arrayOf(PropTypes.string), + effortTime: PropTypes.number, completionStat: PropTypes.shape({ completed: PropTypes.number, total: PropTypes.number, }), }).isRequired, handleSelectSection: PropTypes.func.isRequired, + showOutlineEstimatedTime: PropTypes.bool, +}; + +// Show the estimated time for sections in the sidebar by default +SidebarSection.defaultProps = { + showOutlineEstimatedTime: true, }; export default SidebarSection; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx index 791b1581ce..91e98dc127 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarSequence.jsx @@ -6,6 +6,7 @@ import { Collapsible } from '@openedx/paragon'; import courseOutlineMessages from '@src/course-home/outline-tab/messages'; import { useCourseOutlineSidebar } from '../hooks'; +import messages from '../messages'; import CompletionIcon from './CompletionIcon'; import SidebarUnit from './SidebarUnit'; import { UNIT_ICON_TYPES } from './UnitIcon'; @@ -15,6 +16,7 @@ const SidebarSequence = ({ defaultOpen, sequence, activeUnitId, + showOutlineEstimatedTime, }) => { const intl = useIntl(); const { @@ -25,11 +27,33 @@ const SidebarSequence = ({ unitIds, type, completionStat, + effortTime, } = sequence; const [open, setOpen] = useState(defaultOpen); - const { activeSequenceId, units } = useCourseOutlineSidebar(); + const { + activeSequenceId, + units, + modelSequences, + modelUnits, + } = useCourseOutlineSidebar(); const isActiveSequence = id === activeSequenceId; + // Calculate section effort time by first checking if the section has an explicit effort time. + const sequenceEffortTime = typeof effortTime === 'number' + ? effortTime + : (typeof modelSequences[id]?.effortTime === 'number' + ? modelSequences[id].effortTime + : unitIds.reduce( + (total, unitId) => total + ( + units[unitId]?.effortTime + ?? modelUnits[unitId]?.effortTime + ?? (typeof modelUnits[unitId]?.estimatedTimeMinutes === 'number' + ? Math.ceil(modelUnits[unitId].estimatedTimeMinutes * 60) + : 0) + ), + 0, + )); + const minuteCount = sequenceEffortTime > 0 ? Math.ceil(sequenceEffortTime / 60) : 0; const sectionTitle = ( <> @@ -37,7 +61,14 @@ const SidebarSequence = ({
    - {title} + + {title} + {showOutlineEstimatedTime && minuteCount > 0 && ( + + {intl.formatMessage(messages.estimatedTimeMinutesAbbreviated, { minuteCount })} + + )} + {specialExamInfo && {specialExamInfo}} , {intl.formatMessage(complete @@ -58,19 +89,37 @@ const SidebarSequence = ({ onToggle={() => setOpen(!open)} >
      - {unitIds.map((unitId, index) => ( - - ))} + {/* Map over the sequence's units, pulling relevant data from both the outline and model to pass down to the SidebarUnit component */} + {unitIds.map((unitId, index) => { + const outlineUnit = units[unitId] || {}; + const modelUnit = modelUnits[unitId] || {}; + const resolvedEffortTime = typeof outlineUnit.effortTime === 'number' + ? outlineUnit.effortTime + : modelUnit.effortTime; + const resolvedEstimatedTimeMinutes = typeof outlineUnit.estimatedTimeMinutes === 'number' + ? outlineUnit.estimatedTimeMinutes + : modelUnit.estimatedTimeMinutes; + + return ( + + ); + })}
  • @@ -87,12 +136,19 @@ SidebarSequence.propTypes = { type: PropTypes.string, specialExamInfo: PropTypes.string, unitIds: PropTypes.arrayOf(PropTypes.string), + effortTime: PropTypes.number, completionStat: PropTypes.shape({ completed: PropTypes.number, total: PropTypes.number, }), }).isRequired, activeUnitId: PropTypes.string.isRequired, + showOutlineEstimatedTime: PropTypes.bool, +}; + +// Show the estimated time for sequences in the sidebar by default +SidebarSequence.defaultProps = { + showOutlineEstimatedTime: true, }; export default SidebarSequence; diff --git a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx index 70a8dcb43d..1a57a5b482 100644 --- a/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx +++ b/src/courseware/course/sidebar/sidebars/course-outline/components/SidebarUnit.jsx @@ -15,15 +15,22 @@ const SidebarUnit = ({ isActive, isLocked, activeUnitId, + showOutlineEstimatedTime, }) => { const intl = useIntl(); const { complete, title, icon = UNIT_ICON_TYPES.other, + effortTime, + estimatedTimeMinutes, } = unit; const iconType = isLocked ? UNIT_ICON_TYPES.lock : icon; + // Calculate unit effort time to display in the sidebar + const minuteCount = typeof effortTime === 'number' + ? Math.ceil(effortTime / 60) + : (typeof estimatedTimeMinutes === 'number' ? Math.ceil(estimatedTimeMinutes) : 0); return (
  • @@ -41,6 +48,12 @@ const SidebarUnit = ({
    {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 && ( +

    + {unit.estimatedTimeMinutes >= 60 ? ( + <> + Estimated Time to Complete:{' '} + + {estimatedHours} hour{estimatedHours !== 1 ? 's' : ''} and {estimatedMinutes} minute{estimatedMinutes !== 1 ? 's' : ''} + + + ) : ( + <> + Estimated Time to Complete: {unit.estimatedTimeMinutes} minute{unit.estimatedTimeMinutes !== 1 ? 's' : ''} + + )} +

    + )}
    {isEnabledOutlineSidebar && renderUnitNavigation(true)}
    @@ -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,