Skip to content
Open
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
2 changes: 1 addition & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ PRIVACY_POLICY_URL='http://localhost:18000/privacy'
OPTIMIZELY_FULL_STACK_SDK_KEY=''
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
# Fallback in local style files
PARAGON_THEME_URLS={}
PARAGON_THEME_URLS={}
60 changes: 54 additions & 6 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.1",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^8.2.1",
"@edx/frontend-component-footer": "git+https://github.com/opswerks-swg/OpenLMS-frontend-component-footer.git",
"@edx/frontend-component-header": "git+https://github.com/opswerks-swg/OpenLMS-frontend-component-header.git",
"@edx/frontend-lib-special-exams": "^4.0.0",
"@edx/frontend-platform": "^8.7.0",
"@edx/openedx-atlas": "^0.7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const PassingGradeTooltip = ({ passingGrade, tooltipClassName }) => {
placement="bottom"
overlay={(
<Popover id="minimum-grade-tooltip" className={`bg-primary-500 ${tooltipClassName}`} aria-hidden="true">
<Popover.Content className="text-white">
<Popover.Content>
{passingGrade}{isLocaleRtl && '\u200f'}%
</Popover.Content>
</Popover>
Expand Down
9 changes: 5 additions & 4 deletions src/course-tabs/CourseTabsNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useCoursewareSearchState } from '../course-home/courseware-search/hooks

import Tabs from '../generic/tabs/Tabs';
import messages from './messages';
import { Container } from '@openedx/paragon';

interface CourseTabsNavigationProps {
activeTabSlug?: string;
Expand All @@ -22,13 +23,13 @@ const CourseTabsNavigation = ({
activeTabSlug = undefined,
className = null,
tabs,
}:CourseTabsNavigationProps) => {
}: CourseTabsNavigationProps) => {
const intl = useIntl();
const { show } = useCoursewareSearchState();

return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
<Container fluid>
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="nav-bar">
<div className="nav-menu">
<Tabs
Expand All @@ -44,7 +45,7 @@ const CourseTabsNavigation = ({
</div>
</div>
{show && <CoursewareSearch />}
</div>
</Container>
);
};

Expand Down
11 changes: 8 additions & 3 deletions src/course-tabs/course-tabs-navigation.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@
.nav a,
.nav button {
&:hover {
background-color: var(--pgn-color-light-400);
font-weight: var(--pgn-typography-font-weight-normal);
color: var(--pgn-color-primary-600);
background-color: var(--pgn-color-primary-50);
font-weight: 700;
}
}

.nav a {
&:not(.active):hover {
background-color: var(--pgn-color-light-400);
border-bottom: none;
font-weight: var(--pgn-typography-font-weight-normal);
color: var(--pgn-color-primary-600);
background-color: var(--pgn-color-primary-50);
font-weight: 500;
}
}

Expand Down
66 changes: 66 additions & 0 deletions src/courseware/CoursewareContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ import {
getResumeBlock,
getSequenceForUnitDeprecated,
saveSequencePosition,
postTimeSpent,
addTimeSpent,
} from './data';
import { TabPage } from '../tab-page';

import Course from './course';
import { handleNextSectionCelebration } from './course/celebration';
import withParamsAndNavigation from './utils';

// How often we flush accumulated time-on-course to local storage (and attempt to sync to the
// backend) while a learner is actively viewing an ongoing course. See `startTimeTracking` below.
const TIME_TRACKING_FLUSH_INTERVAL_MS = 30 * 1000;

// Look at where this is called in componentDidUpdate for more info about its usage
export const checkResumeRedirect = memoize(
(courseStatus, courseId, sequenceId, firstSequenceId, navigate, isPreview) => {
Expand Down Expand Up @@ -172,6 +178,54 @@ class CoursewareContainer extends Component {
}
});

// Every time a learner visits this (ongoing) course, start a session timer. Elapsed time is
// periodically flushed (see flushTimeSpent) into local storage and rolled up into the "Total
// Time Spent" stat on the learner dashboard's Progress Summary widget.
startTimeTracking = () => {
this.timeTrackingCourseId = this.props.routeCourseId;
this.timeTrackingSessionStartedAt = Date.now();
this.timeTrackingFlushIntervalId = setInterval(
this.flushTimeSpent,
TIME_TRACKING_FLUSH_INTERVAL_MS,
);
document.addEventListener('visibilitychange', this.handleTimeTrackingVisibilityChange);
};

// Pause the timer while the tab is hidden (backgrounded) so we don't count time the learner
// isn't actually looking at the course, and resume it when they come back.
handleTimeTrackingVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
this.flushTimeSpent();
} else {
this.timeTrackingSessionStartedAt = Date.now();
}
};

flushTimeSpent = () => {
const { timeTrackingCourseId, timeTrackingSessionStartedAt } = this;
if (!timeTrackingCourseId || !timeTrackingSessionStartedAt) {
return;
}
const now = Date.now();
const elapsedSeconds = Math.floor((now - timeTrackingSessionStartedAt) / 1000);
this.timeTrackingSessionStartedAt = now;
if (elapsedSeconds <= 0) {
return;
}
addTimeSpent(timeTrackingCourseId, elapsedSeconds);
// Fire-and-forget: postTimeSpent swallows its own errors (see data/api.js), so no catch needed here.
postTimeSpent(timeTrackingCourseId, elapsedSeconds);
};

stopTimeTracking = () => {
this.flushTimeSpent();
if (this.timeTrackingFlushIntervalId) {
clearInterval(this.timeTrackingFlushIntervalId);
this.timeTrackingFlushIntervalId = null;
}
document.removeEventListener('visibilitychange', this.handleTimeTrackingVisibilityChange);
};

componentDidMount() {
const {
routeCourseId,
Expand All @@ -180,6 +234,7 @@ class CoursewareContainer extends Component {
// Load data whenever the course or sequence ID changes.
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);
this.startTimeTracking();
}

componentDidUpdate() {
Expand All @@ -203,6 +258,13 @@ class CoursewareContainer extends Component {
this.checkFetchCourse(routeCourseId);
this.checkFetchSequence(routeSequenceId);

// Restart time tracking if the learner has navigated to a different course without this
// component unmounting (e.g. jumping from one course straight into another).
if (routeCourseId && routeCourseId !== this.timeTrackingCourseId) {
this.stopTimeTracking();
this.startTimeTracking();
}

// Check if we should save our sequence position. Only do this when the route unit ID changes.
this.checkSaveSequencePosition(routeUnitId);

Expand Down Expand Up @@ -303,6 +365,10 @@ class CoursewareContainer extends Component {
);
}

componentWillUnmount() {
this.stopTimeTracking();
}

handleUnitNavigationClick = () => {
const {
courseId,
Expand Down
11 changes: 11 additions & 0 deletions src/courseware/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,3 +116,14 @@ export async function getCoursewareOutlineSidebarToggles(courseId) {
enable_completion_tracking: data.enable_completion_tracking || false,
};
}
export async function postTimeSpent(courseId, seconds) {
try {
const { data } = await getAuthenticatedHttpClient().patch(
`${getConfig().LMS_BASE_URL}/api/course_assignments/v1/time-spent/${courseId}/`,
{ seconds_spent: seconds },
);
return data;
} catch (error) {
return null;
}
}
5 changes: 5 additions & 0 deletions src/courseware/data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export {
getResumeBlock,
getSequenceForUnitDeprecated,
sendActivationEmail,
postTimeSpent,
} from './api';
export {
getStoredTimeSpent,
addTimeSpent,
} from './timeTracking';
export {
sequenceIdsSelector,
} from './selectors';
Expand Down
41 changes: 41 additions & 0 deletions src/courseware/data/timeTracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@

export const TIME_TRACKING_STORAGE_KEY_PREFIX = 'edx.timeTracking.course.';

export const getTimeTrackingStorageKey = (courseId) => `${TIME_TRACKING_STORAGE_KEY_PREFIX}${courseId}`;

export const getStoredTimeSpent = (courseId) => {
if (!courseId) {
return 0;
}
try {
const raw = window.localStorage.getItem(getTimeTrackingStorageKey(courseId));
const seconds = parseInt(raw, 10);
return Number.isNaN(seconds) ? 0 : seconds;
} catch (error) {
// localStorage may be unavailable (e.g. disabled, private browsing quota). Fail quietly.
return 0;
}
};

/**
* Adds `seconds` to the running total of time spent tracked for `courseId`.
* @returns {number} the new running total, in seconds.
*/
export const addTimeSpent = (courseId, seconds) => {
if (!courseId || !seconds || seconds <= 0) {
return getStoredTimeSpent(courseId);
}
const total = getStoredTimeSpent(courseId) + Math.round(seconds);
try {
window.localStorage.setItem(getTimeTrackingStorageKey(courseId), String(total));
} catch (error) {
// Ignore write failures (e.g. storage quota exceeded / disabled).
}
return total;
};

export default {
getTimeTrackingStorageKey,
getStoredTimeSpent,
addTimeSpent,
};
44 changes: 44 additions & 0 deletions src/courseware/data/timeTracking.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getStoredTimeSpent, addTimeSpent, getTimeTrackingStorageKey } from './timeTracking';

describe('timeTracking', () => {
beforeEach(() => {
window.localStorage.clear();
});

describe('getStoredTimeSpent', () => {
it('returns 0 when nothing is stored', () => {
expect(getStoredTimeSpent('course-1')).toBe(0);
});

it('returns 0 when no courseId is given', () => {
expect(getStoredTimeSpent(null)).toBe(0);
});

it('reads back a previously stored value', () => {
window.localStorage.setItem(getTimeTrackingStorageKey('course-1'), '42');
expect(getStoredTimeSpent('course-1')).toBe(42);
});
});

describe('addTimeSpent', () => {
it('accumulates seconds across multiple calls for the same course', () => {
addTimeSpent('course-1', 30);
addTimeSpent('course-1', 45);
expect(getStoredTimeSpent('course-1')).toBe(75);
});

it('tracks different courses independently', () => {
addTimeSpent('course-1', 30);
addTimeSpent('course-2', 10);
expect(getStoredTimeSpent('course-1')).toBe(30);
expect(getStoredTimeSpent('course-2')).toBe(10);
});

it('is a no-op for zero, negative, or missing values', () => {
addTimeSpent('course-1', 0);
addTimeSpent('course-1', -5);
addTimeSpent(null, 30);
expect(getStoredTimeSpent('course-1')).toBe(0);
});
});
});
Loading