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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 107 additions & 2 deletions src/course-home/data/api.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: {},
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down
37 changes: 37 additions & 0 deletions src/course-home/outline-tab/OutlineTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const OutlineTab = () => {
courseBlocks: {
courses,
sections,
sequences,
},
courseGoals: {
selectedGoal,
Expand Down Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -121,6 +145,19 @@ const OutlineTab = () => {
<div data-learner-type={learnerType} className="row w-100 mx-0 my-3 justify-content-between">
<div className="col-12 col-sm-auto p-0">
<div role="heading" aria-level="1" className="h2">{title}</div>
{/* Display the estimated time for the course next to the title if showOutlineEstimatedTime is true and estimated time > 0 */}
{showOutlineEstimatedTime && courseMinuteCount > 0 && (
<div className="small text-primary mt-1">
{totalEstimatedMinutes >= 60
? intl.formatMessage(messages.estimatedTimeToCompleteWithHours, {
hourCount: totalEstimatedHours,
minuteCount: remainingEstimatedMinutes,
})
: intl.formatMessage(messages.estimatedTimeToComplete, {
minuteCount: totalEstimatedMinutes,
})}
</div>
)}
</div>
</div>
<div className="row course-outline-tab">
Expand Down
16 changes: 16 additions & 0 deletions src/course-home/outline-tab/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
27 changes: 26 additions & 1 deletion src/course-home/outline-tab/section-outline/Section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,12 +28,16 @@ interface Props {
const Section: React.FC<Props> = ({
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,
Expand All @@ -41,6 +49,12 @@ const Section: React.FC<Props> = ({
} = 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);
Expand All @@ -56,7 +70,15 @@ const Section: React.FC<Props> = ({
<Collapsible
className="mb-2"
styling="card-lg"
title={<SectionTitle {...{ complete, hideFromTOC, title }} />}
// Pass the calculated sectionEffortTime to the SectionTitle component to display the estimated time for
// the section in the course outline.
title={<SectionTitle {...{
complete,
hideFromTOC,
title,
effortTime: sectionEffortTime,
showOutlineEstimatedTime,
}} />}
open={open}
onToggle={() => { setOpen(!open); }}
iconWhenClosed={(
Expand All @@ -82,6 +104,9 @@ const Section: React.FC<Props> = ({
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}
/>
))}
Expand Down
23 changes: 22 additions & 1 deletion src/course-home/outline-tab/section-outline/SectionTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Props> = ({ 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<Props> = ({
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 (
<div className="d-flex row w-100 m-0">
<div className="col-auto p-0">
Expand All @@ -36,6 +52,11 @@ const SectionTitle: React.FC<Props> = ({ complete, hideFromTOC, title }) => {
</div>
<div className="col-7 ml-3 p-0 font-weight-bold text-dark-500">
<span className="align-middle col-6">{title}</span>
{minutesLabel && (
<span className="small text-gray-500 ml-2 align-middle font-weight-normal">
{minutesLabel}
</span>
)}
<span className="sr-only">
, {intl.formatMessage(complete ? messages.completedSection : messages.incompleteSection)}
</span>
Expand Down
8 changes: 8 additions & 0 deletions src/course-home/outline-tab/section-outline/SequenceLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +23,9 @@ interface Props {
const SequenceLink: React.FC<Props> = ({
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 {
Expand All @@ -32,6 +37,8 @@ const SequenceLink: React.FC<Props> = ({
hideFromTOC,
} = sequence;

// showOutlineEstimatedTime is passed down to the SequenceTitle component to control whether to show the estimated
// time for sequences in the course
return (
<li>
<div className={classNames('', { 'mt-2 pt-2 border-top border-light': !first })}>
Expand All @@ -41,6 +48,7 @@ const SequenceLink: React.FC<Props> = ({
showLink,
title,
sequence,
showOutlineEstimatedTime,
id,
}}
/>
Expand Down
Loading