Track viewing of individual blocks.

* Implement a ViewedEvent handling system which calls handlers when a
  block has been viewed for 5 seconds (configurable).
* Hook up Verticals to register their children blocks with this event,
  and submit completions once seen.

OSPR-2093
OC-3358
This commit is contained in:
J. Cliff Dyer
2017-12-07 14:26:35 -05:00
committed by Alex Dusenbery
parent 17ddd07838
commit 4946b6b296
14 changed files with 368 additions and 36 deletions

View File

@@ -1095,6 +1095,8 @@ COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get(
'COMPLETION_VIDEO_COMPLETE_PERCENTAGE',
COMPLETION_VIDEO_COMPLETE_PERCENTAGE,
)
# The time a block needs to be viewed to be considered complete, in milliseconds.
COMPLETION_BY_VIEWING_DELAY_MS = ENV_TOKENS.get('COMPLETION_BY_VIEWING_DELAY_MS', COMPLETION_BY_VIEWING_DELAY_MS)
############### Settings for django-fernet-fields ##################
FERNET_KEYS = AUTH_TOKENS.get('FERNET_KEYS', FERNET_KEYS)

View File

@@ -3447,6 +3447,7 @@ EDX_PLATFORM_REVISION = 'unknown'
# Once a user has watched this percentage of a video, mark it as complete:
# (0.0 = 0%, 1.0 = 100%)
COMPLETION_VIDEO_COMPLETE_PERCENTAGE = 0.95
COMPLETION_BY_VIEWING_DELAY_MS = 5000
############### Settings for Django Rate limit #####################
RATELIMIT_ENABLE = True

View File

@@ -0,0 +1,7 @@
module.exports = {
extends: 'eslint-config-edx',
root: true,
settings: {
'import/resolver': 'webpack',
},
};

View File

@@ -0,0 +1,182 @@
/** Ensure that a function is only run once every `wait` milliseconds */
function throttle(fn, wait) {
let time = 0;
function delay() {
// Do not call the function until at least `wait` seconds after the
// last time the function was called.
const now = Date.now();
if (time + wait < now) {
time = now;
fn();
}
}
return delay;
}
export class ElementViewing {
/**
* A wrapper for an HTMLElement that tracks whether the element has been
* viewed or not.
*/
constructor(el, viewedAfterMs, callback) {
this.el = el;
this.viewedAfterMs = viewedAfterMs;
this.callback = callback;
this.topSeen = false;
this.bottomSeen = false;
this.seenForMs = 0;
this.becameVisibleAt = undefined;
this.hasBeenViewed = false;
}
getBoundingRect() {
return this.el.getBoundingClientRect();
}
/** This element has become visible on screen.
*
* (may be called even when already on screen though)
*/
handleVisible() {
if (!this.becameVisibleAt) {
this.becameVisibleAt = Date.now();
// We're now visible; after viewedAfterMs, if the top and bottom have been
// seen, this block will count as viewed.
setTimeout(
() => {
this.checkIfViewed();
},
this.viewedAfterMs - this.seenForMs,
);
}
}
handleNotVisible() {
if (this.becameVisibleAt) {
this.seenForMs = Date.now() - this.becameVisibleAt;
}
this.becameVisibleAt = undefined;
}
markTopSeen() {
// If this element has been seen for enough time, but the top wasn't visible, it may now be
// considered viewed.
this.topSeen = true;
this.checkIfViewed();
}
markBottomSeen() {
this.bottomSeen = true;
this.checkIfViewed();
}
getTotalTimeSeen() {
if (this.becameVisibleAt) {
return this.seenForMs + (Date.now() - this.becameVisibleAt);
}
return this.seenForMs;
}
areViewedCriteriaMet() {
return this.topSeen && this.bottomSeen && (this.getTotalTimeSeen() >= this.viewedAfterMs);
}
checkIfViewed() {
// User can provide a "now" value for testing purposes.
if (this.hasBeenViewed) {
return;
}
if (this.areViewedCriteriaMet()) {
this.hasBeenViewed = true;
// Report to the tracker that we have been viewed
this.callback(this.el, { elementHasBeenViewed: this.hasBeenViewed });
}
}
}
export class ViewedEventTracker {
/**
* When the top or bottom of an element is first viewed, and the entire
* element is viewed for a specified amount of time, the callback is called,
* passing the element that was viewed, and an event object having the
* following field:
*
* * hasBeenViewed (bool): true if all the conditions for being
* considered "viewed" have been met.
*/
constructor(elements, viewedAfterMs) {
this.viewedAfterMs = viewedAfterMs;
this.elementViewings = new Set();
this.handlers = [];
this.interval = undefined;
elements.forEach((el) => {
this.elementViewings.add(
new ElementViewing(
el,
viewedAfterMs,
(element, event) => this.callHandlers(element, event),
),
);
});
this.registerDomHandlers();
}
/** Register a new handler to be called when an element has been viewed. */
addHandler(handler) {
this.handlers.push(handler);
}
/** Mark which elements are currently visible.
*
* Also marks when an elements top or bottom has been seen.
* */
updateVisible() {
this.elementViewings.forEach((elv) => {
if (elv.hasBeenViewed) {
return;
}
const now = Date.now(); // Use the same "now" for all calculations
const rect = elv.getBoundingRect();
let visible = false;
if (rect.top > 0 && rect.top < window.innerHeight) {
elv.markTopSeen(now);
visible = true;
}
if (rect.bottom > 0 && rect.bottom < window.innerHeight) {
elv.markBottomSeen(now);
visible = true;
}
if (rect.top < 0 && rect.bottom > window.innerHeight) {
visible = true;
}
if (visible) {
elv.handleVisible(now);
} else {
elv.handleNotVisible(now);
}
});
}
registerDomHandlers() {
window.onscroll = throttle(() => this.updateVisible(), 100);
window.onresize = throttle(() => this.updateVisible(), 100);
this.updateVisible();
}
/** Call the handlers for all newly-viewed elements and pause tracking
* for recently disappeared elements.
*/
callHandlers(el, event) {
this.handlers.forEach((handler) => {
handler(el, event);
});
}
}

View File

@@ -0,0 +1,95 @@
import { ElementViewing, ViewedEventTracker } from '../ViewedEvent';
describe('ViewedTracker', () => {
let existingHTML;
beforeEach(() => {
existingHTML = document.body.innerHTML;
});
afterEach(() => {
document.body.innerHTML = existingHTML;
});
it('calls the handlers when an element is viewed', () => {
document.body.innerHTML = '<div id="d1"></div><div id="d2"></div><div id="d3"></div>';
const tracker = new ViewedEventTracker(Array.from(document.getElementsByTagName('div')), 1000);
const handlerSpy = jasmine.createSpy('handlerSpy');
tracker.addHandler(handlerSpy);
const elvIter = tracker.elementViewings.values();
// Pick two elements, and mock them so that one has met the criteria to be viewed,
// and the other hasn't.
const viewed = elvIter.next().value;
spyOn(viewed, 'areViewedCriteriaMet').and.returnValue(true);
viewed.checkIfViewed();
expect(handlerSpy).toHaveBeenCalledWith(viewed.el, {
elementHasBeenViewed: true,
});
const unviewed = elvIter.next().value;
spyOn(unviewed, 'areViewedCriteriaMet').and.returnValue(false);
unviewed.checkIfViewed();
expect(handlerSpy).not.toHaveBeenCalledWith(unviewed.el, jasmine.anything());
});
});
describe('ElementViewing', () => {
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('calls checkIfViewed when enough time has elapsed', () => {
const viewing = new ElementViewing({}, 500, () => {});
spyOn(viewing, 'checkIfViewed').and.callThrough();
viewing.seenForMs = 250;
viewing.handleVisible();
jasmine.clock().tick(249);
expect(viewing.checkIfViewed).not.toHaveBeenCalled();
jasmine.clock().tick(1);
expect(viewing.checkIfViewed).toHaveBeenCalled();
});
it('has been viewed after the specified number of milliseconds', () => {
const viewing = new ElementViewing({}, 500, () => {});
viewing.seenForMs = 250;
spyOn(Date, 'now').and.returnValue(750);
viewing.handleVisible();
viewing.markTopSeen();
viewing.markBottomSeen();
Date.now.and.returnValue(999);
viewing.checkIfViewed();
expect(viewing.hasBeenViewed).toBeFalsy();
Date.now.and.returnValue(1000);
jasmine.clock().tick(250);
expect(viewing.hasBeenViewed).toBeTruthy();
});
it('has not been viewed if the bottom has not been seen', () => {
const viewing = new ElementViewing(undefined, 500, () => {});
viewing.markTopSeen();
viewing.seenForMs = 500;
expect(viewing.areViewedCriteriaMet()).toBeFalsy();
viewing.checkIfViewed();
expect(viewing.hasBeenViewed).toBeFalsy();
});
it('has not been viewed if the top has not been seen', () => {
const viewing = new ElementViewing(undefined, 500, () => {});
viewing.markBottomSeen();
viewing.seenForMs = 500;
expect(viewing.areViewedCriteriaMet()).toBeFalsy();
viewing.checkIfViewed();
expect(viewing.hasBeenViewed).toBeFalsy();
});
it('does not update time seen if lastSeen is undefined', () => {
const viewing = new ElementViewing(undefined, 500, () => {});
viewing.becameVisibleAt = undefined;
expect(viewing.becameVisibleAt).toBeUndefined();
viewing.handleVisible();
expect(viewing.becameVisibleAt).not.toBeUndefined();
});
});

View File

@@ -34,12 +34,14 @@ var options = {
{pattern: 'learner_profile/**/!(*spec).js'},
{pattern: 'lms/js/**/!(*spec).js'},
{pattern: 'support/js/**/!(*spec).js'},
{pattern: 'teams/js/**/!(*spec).js'}
{pattern: 'teams/js/**/!(*spec).js'},
{pattern: 'completion/js/**/!(*spec).js'}
],
specFiles: [
// Define the Webpack-built spec files first
{pattern: 'course_experience/js/**/*_spec.js', webpack: true},
{pattern: 'completion/js/**/*_spec.js', webpack: true},
// Add all remaining spec files to be used without Webpack
{pattern: '../**/*spec.js'}

View File

@@ -690,6 +690,7 @@
});
testFiles = [
'completion/js/spec/ViewedEvent_spec.js',
'course_bookmarks/js/spec/bookmark_button_view_spec.js',
'course_bookmarks/js/spec/bookmarks_list_view_spec.js',
'course_bookmarks/js/spec/course_bookmarks_factory_spec.js',

View File

@@ -8,7 +8,11 @@
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
% endif
<div class="vert-mod">
<div class="vert-mod" \
% if completion_delay_ms is not None:
data-completion-delay-ms="${completion_delay_ms}" \
% endif
>
% for idx, item in enumerate(items):
<div class="vert vert-${idx}" data-id="${item['id']}" \
% if item['id'] in watched_completable_blocks: