Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-craft/frontend-app-learning-paths",
"version": "0.1.5",
"version": "0.1.6",
"description": "Frontend application template",
"repository": {
"type": "git",
Expand Down
11 changes: 10 additions & 1 deletion src/learningpath/CourseCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export const CourseCard = ({
endDate,
status,
percent,
hasOptionalCompletion,
hasUnearnedOptionalCompletion,
checkingEnrollment,
} = course;

Expand Down Expand Up @@ -135,9 +137,14 @@ export const CourseCard = ({
label={`${progressBarPercent}%`}
variant="primary"
/>
<div className="x-small text-right pt-1">content completed</div>
<div className="x-small text-right pt-1">
{hasOptionalCompletion ? 'Required content completed' : 'content completed'}
</div>
</>
)}
{status.toLowerCase() === 'completed' && hasUnearnedOptionalCompletion && (
<div className="small">You&apos;ve completed all required content. Remaining content is optional.</div>
)}
</Card.Section>
<Card.Footer orientation="horizontal" className="pt-3 pb-3 justify-content-between">
<Col className="d-flex p-0 flex-column-reverse flex-md-row align-items-start w-100 w-md-auto">
Expand Down Expand Up @@ -195,6 +202,8 @@ CourseCard.propTypes = {
endDate: PropTypes.string,
status: PropTypes.string.isRequired,
percent: PropTypes.number.isRequired,
hasOptionalCompletion: PropTypes.bool,
hasUnearnedOptionalCompletion: PropTypes.bool,
checkingEnrollment: PropTypes.bool,
}).isRequired,
relatedLearningPaths: PropTypes.arrayOf(PropTypes.shape({
Expand Down
11 changes: 10 additions & 1 deletion src/learningpath/LearningPathCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ const LearningPathCard = ({ learningPath, showFilters = false }) => {
minDate,
maxDate,
percent,
hasOptionalCompletion,
hasUnearnedOptionalCompletion,
org,
} = learningPath;

Expand Down Expand Up @@ -123,9 +125,14 @@ const LearningPathCard = ({ learningPath, showFilters = false }) => {
label={`${progressBarPercent}%`}
variant="primary"
/>
<div className="x-small text-right pt-1">content completed</div>
<div className="x-small text-right pt-1">
{hasOptionalCompletion ? 'Required content completed' : 'content completed'}
</div>
</>
)}
{status.toLowerCase() === 'completed' && hasUnearnedOptionalCompletion && (
<div className="small">You&apos;ve completed all required content. Remaining content is optional.</div>
)}
</Card.Section>
<Card.Footer orientation="horizontal" className="pt-3 pb-3 justify-content-between">
<Card.Section className="d-flex p-0 flex-column-reverse flex-md-row align-items-start w-100 w-md-auto">
Expand Down Expand Up @@ -159,6 +166,8 @@ LearningPathCard.propTypes = {
minDate: PropTypes.instanceOf(Date),
maxDate: PropTypes.instanceOf(Date),
percent: PropTypes.number,
hasOptionalCompletion: PropTypes.bool,
hasUnearnedOptionalCompletion: PropTypes.bool,
org: PropTypes.string,
}).isRequired,
showFilters: PropTypes.bool,
Expand Down
17 changes: 2 additions & 15 deletions src/learningpath/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,26 +69,12 @@ export async function fetchCourseDetails(courseId) {
});
}

export async function fetchCourseCompletion(courseId) {
try {
const { username } = getAuthenticatedUser();
const client = getAuthenticatedHttpClient();
const response = await client.get(
`${getConfig().LMS_BASE_URL}/completion-aggregator/v1/course/${encodeURIComponent(courseId)}/?username=${username}`,
);
return response.data.results?.[0]?.completion?.percent ?? 0.0;
} catch (error) {
// Handle API errors - they indicate the user is not enrolled or did not complete any XBlocks.
return 0.0;
}
}

export async function fetchAllCourseCompletions() {
const { username } = getAuthenticatedUser();
const client = getAuthenticatedHttpClient();

let allResults = [];
let nextUrl = `${getConfig().LMS_BASE_URL}/completion-aggregator/v1/course/?username=${username}&page_size=10000`;
let nextUrl = `${getConfig().LMS_BASE_URL}/completion-aggregator/v1/course/?username=${username}&page_size=10000&include_optional=true`;

while (nextUrl) {
// eslint-disable-next-line no-await-in-loop
Expand All @@ -103,6 +89,7 @@ export async function fetchAllCourseCompletions() {
return camelCaseObject(allResults.map(item => ({
course_key: item.course_key,
completion: item.completion,
optional_completion: item.optional_completion,
})));
}

Expand Down
14 changes: 12 additions & 2 deletions src/learningpath/data/dataUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ export const calculateCompletionStatus = (completion) => {
* @returns {Object} Course object with completion status
*/
export const addCompletionStatus = (course, completionsMap, courseId) => {
const completion = completionsMap[courseId]?.percent || 0;
const completionData = completionsMap[courseId];
const completion = completionData?.completion?.percent || 0;
const { status, percent } = calculateCompletionStatus(completion);
const optionalPossible = completionData?.optionalCompletion?.possible ?? 0;
const optionalEarned = completionData?.optionalCompletion?.earned ?? 0;
const hasOptionalCompletion = optionalPossible > 0;
const hasUnearnedOptionalCompletion = optionalPossible > optionalEarned;
return {
...course,
status,
percent,
hasOptionalCompletion,
hasUnearnedOptionalCompletion,
};
};

Expand All @@ -39,7 +46,10 @@ export const addCompletionStatus = (course, completionsMap, courseId) => {
export const createCompletionsMap = (completions) => {
const completionsMap = {};
completions?.forEach?.(item => {
completionsMap[item.courseKey] = item.completion;
completionsMap[item.courseKey] = {
completion: item.completion,
optionalCompletion: item.optionalCompletion,
};
});
return completionsMap;
};
Expand Down
29 changes: 14 additions & 15 deletions src/learningpath/data/queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ export const QUERY_KEYS = {
LEARNER_DASHBOARD: ['learnerDashboard'],
COURSE_DETAILS: (courseId) => ['course', courseId],
COURSE_COMPLETIONS: ['courseCompletions'],
COURSE_COMPLETION: (courseId) => ['courseCompletion', courseId],
COURSE_ENROLLMENT_STATUS: (courseId) => ['courseEnrollmentStatus', courseId],
ORGANIZATIONS: ['organizations'],
CREDENTIAL_CONFIGURATION: (learningContextKey) => ['credentialConfiguration', learningContextKey],
Expand Down Expand Up @@ -70,9 +69,19 @@ export const useLearningPaths = () => {
};
}

let hasOptionalCompletion = false;
let hasUnearnedOptionalCompletion = false;
const totalCompletion = lp.steps.reduce((sum, step) => {
const completion = completionsMap[step.courseKey];
return sum + (completion?.percent ?? 0);
const completionData = completionsMap[step.courseKey];
const optionalPossible = completionData?.optionalCompletion?.possible ?? 0;
const optionalEarned = completionData?.optionalCompletion?.earned ?? 0;
if (optionalPossible > 0) {
hasOptionalCompletion = true;
if (optionalPossible > optionalEarned) {
hasUnearnedOptionalCompletion = true;
}
}
return sum + (completionData?.completion?.percent ?? 0);
Comment on lines +72 to +84
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useLearningPaths uses reduce to compute totalCompletion but also mutates hasOptionalCompletion/hasUnearnedOptionalCompletion as side effects inside the reducer. This makes the reducer non-pure and harder to reason about (and easier to break if the logic changes). Consider replacing this with a single for...of loop (or a reduce that returns an object containing { totalCompletion, hasOptionalCompletion, hasUnearnedOptionalCompletion }) so the aggregation is explicit and side-effect free.

Copilot uses AI. Check for mistakes.
}, 0);

const percent = totalCompletion / totalCourses;
Expand Down Expand Up @@ -104,6 +113,8 @@ export const useLearningPaths = () => {
minDate,
maxDate,
percent,
hasOptionalCompletion,
hasUnearnedOptionalCompletion,
type: 'learning_path',
org: lp.key.match(/path-v1:([^+]+)/)[1],
enrollmentDate: lp.enrollmentDate ? new Date(lp.enrollmentDate) : null,
Expand Down Expand Up @@ -215,12 +226,6 @@ export const useLearnerDashboard = () => {
});
};

export const useCourseCompletions = () => useQuery({
queryKey: QUERY_KEYS.COURSE_COMPLETIONS,
queryFn: api.fetchAllCourseCompletions,
staleTime: STALE_TIMES.COMPLETIONS,
});

export const useCoursesByIds = (courseIds) => {
const queryClient = useQueryClient();

Expand Down Expand Up @@ -314,12 +319,6 @@ export const usePrefetchCourseDetail = (courseId) => {
staleTime: STALE_TIMES.COURSE_DETAIL,
});

queryClient.fetchQuery({
queryKey: QUERY_KEYS.COURSE_COMPLETION(courseId),
queryFn: () => api.fetchCourseCompletion(courseId),
staleTime: STALE_TIMES.COMPLETIONS,
});

queryClient.prefetchQuery({
queryKey: QUERY_KEYS.CREDENTIAL_CONFIGURATION(courseId),
queryFn: () => api.fetchCredentialConfiguration(courseId),
Expand Down
Loading