/javascript/exercises-package/stacks.js
JavaScript | 1046 lines | 590 code | 234 blank | 222 comment | 56 complexity | a7f40b8337ade0ebeb9eff4455d6ec02 MD5 | raw file
- /**
- * Model of any (current or in-stack) card
- */
- Exercises.Card = Backbone.Model.extend({
- leaves: function(card) {
- return _.map(_.range(4), function(index) {
- return {
- index: index,
- state: (this.get("leavesEarned") > index ? "earned" :
- this.get("leavesAvailable") > index ? "available" :
- "unavailable")
- };
- }, this);
- },
- /**
- * Decreases leaves available -- if leaves available is already at this
- * level or lower, noop
- */
- decreaseLeavesAvailable: function(leavesAvailable) {
- var currentLeaves = this.get("leavesAvailable");
- if (currentLeaves) {
- leavesAvailable = Math.min(currentLeaves, leavesAvailable);
- }
- return this.set({ leavesAvailable: leavesAvailable });
- },
- /**
- * @return {boolean} True if this is a card representing the end of a stack.
- */
- isLastCard: function() {
- return _.contains(["endofstack", "endofreview"], this.get("cardType"));
- },
- /**
- * Get the associated latest user exercise object for this card.
- * @param {Card} card
- * @return {Object|undefined} The userExercise object if available.
- */
- getUserExercise: function(card) {
- return Exercises.UserExerciseCache.get(this.get("exerciseName"));
- }
- });
- /**
- * Collection model of a stack of cards
- */
- Exercises.StackCollection = Backbone.Collection.extend({
- model: Exercises.Card,
- peek: function() {
- return this.first();
- },
- pop: function(animationOptions) {
- var head = this.peek();
- this.remove(head, animationOptions);
- return head;
- },
- /**
- * Shrink this stack by removing N cards up to but not including
- * the first card in the stack and the last (end of stack) card.
- */
- shrinkBy: function(n) {
- // Never shrink to less than two cards (first card, end of stack card).
- var targetLength = Math.max(2, this.length - n);
- while (this.length > targetLength) {
- // Remove the second-to-last card until we're done.
- this.remove(this.models[this.length - 2]);
- }
- },
- /**
- * Return the longest streak of cards in this stack
- * that satisfies the truth test fxn.
- * If fxnSkip is supplied, the card won't count towards
- * or break a streak.
- */
- longestStreak: function(fxn, fxnSkip) {
- var current = 0,
- longest = 0;
- fxnSkip = fxnSkip || function() { return false; };
- this.each(function(card) {
- if (!fxnSkip(card)) {
- if (fxn(card)) {
- current += 1;
- } else {
- current = 0;
- }
- longest = Math.max(current, longest);
- }
- });
- return longest;
- },
- /**
- * Return a dictionary of interesting, positive stats about this stack.
- */
- stats: function() {
- var totalLeaves = this.reduce(function(sum, card) {
- // Don't count the fourth leaf for now. We're showing it in a different
- // way at the end of the stack. TODO (jasonrr/kamens) remove 4th leaf
- // altogether if we keep this implementation
- return Math.min(3, card.get("leavesEarned")) + sum;
- }, 0);
- var longestStreak = this.longestStreak(
- function(card) {
- return card.get("leavesEarned") >= 3;
- },
- function(card) {
- // Skip any cards w/ 0 leaves available --
- // those don't count.
- return card.get("leavesAvailable") === 0;
- }
- );
- var speedyCards = this.filter(function(card) {
- return card.get("leavesEarned") >= 4;
- }).length;
- return {
- "longestStreak": longestStreak,
- "speedyCards": speedyCards,
- "totalLeaves": totalLeaves
- };
- }
- });
- /**
- * StackCollection that is automatically cached in localStorage when modified
- * and loads itself from cache on initialization.
- */
- Exercises.CachedStackCollection = Exercises.StackCollection.extend({
- sessionId: null,
- uid: null,
- initialize: function(models, options) {
- this.sessionId = options ? options.sessionId : null;
- this.uid = options && options.uid;
- // Try to load models from cache
- if (!models) {
- this.loadFromCache();
- }
- this
- .bind("add", this.cache, this)
- .bind("remove", this.cache, this);
- return Exercises.StackCollection.prototype.initialize.call(this, models, options);
- },
- cacheKey: function() {
- if (!this.sessionId) {
- throw "Missing session id for cache key";
- }
- return [
- "cachedstack",
- this.sessionId
- ].join(":");
- },
- loadFromCache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- var modelAttrs = LocalStore.get(this.cacheKey());
- if (modelAttrs) {
- if (modelAttrs.uid) {
- this.uid = modelAttrs.uid;
- }
- _.each(modelAttrs.cards || modelAttrs, function(attrs) {
- this.add(new Exercises.Card(attrs));
- }, this);
- }
- },
- cache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- LocalStore.set(this.cacheKey(), {uid: this.uid, cards: this.models});
- },
- /**
- * Delete this stack from localStorage
- */
- clearCache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- LocalStore.del(this.cacheKey());
- },
- getUid: function() {
- // TODO(david): Ideally, this collection should be wrapped in a model.
- return this.uid;
- }
- });
- /**
- * View of a stack of cards
- */
- Exercises.StackView = Backbone.View.extend({
- template: Templates.get("exercises.stack"),
- initialize: function(options) {
- // deferAnimation is a wrapper function used to insert
- // any animations returned by fxn onto animationOption's
- // list of deferreds. This lets you chain complex
- // animations (see Exercises.nextCard).
- var deferAnimation = function(fxn) {
- return function(model, collection, options) {
- var result = fxn.call(this, model, collection, options);
- if (options && options.deferreds) {
- options.deferreds.push(result);
- }
- return result;
- };
- };
- this.collection
- .bind("add", deferAnimation(function(card) {
- return this.animatePush(card);
- }), this)
- .bind("remove", deferAnimation(function() {
- return this.animatePop();
- }), this);
- return Backbone.View.prototype.initialize.call(this, options);
- },
- render: function() {
- var collectionContext = _.map(this.collection.models, function(card, index) {
- return this.viewContext(card, index);
- }, this);
- this.$el.html(this.template({cards: collectionContext}));
- return this;
- },
- viewContext: function(card, index) {
- return _.extend(card.toJSON(), {
- index: index,
- frontVisible: this.options.frontVisible,
- cid: card.cid,
- leaves: card.leaves()
- });
- },
- /**
- * Animate popping card off of stack
- */
- animatePop: function() {
- return this.$el
- .find(".card-container")
- .first()
- .slideUp(360, function() { $(this).remove(); });
- },
- /**
- * Animate pushing card onto head of stack
- */
- animatePush: function(card) {
- var context = this.viewContext(card, this.collection.length);
- var jel = this.$el
- .find(".stack")
- .prepend(
- $(Templates.get("exercises.card")(context))
- .css("display", "none")
- )
- .find(".card-container")
- .first()
- // delay is used to slow down anybody waiting on this
- // animation. See comment below.
- .delay(250);
- // Don't immediately slideDown as part of the first animation that
- // happens after card insertion. This causes a rare and hard-to-track
- // browser crash in Chrome.
- //
- // TODO(kamens): remove this. All of this particular animation code
- // should be going away with the power mode team's move to their new
- // card UI, at which point we won't have to stress about this any more.
- setTimeout(function() {
- jel.slideDown(200);
- }, 50);
- return jel;
- }
- });
- /**
- * View of the single, currently-visible card
- */
- Exercises.CurrentCardView = Backbone.View.extend({
- template: Templates.get("exercises.current-card"),
- model: null,
- events: {
- "click .to-dashboard": "toDashboard",
- "click .more-stacks": "toMoreStacks",
- "click #show-topic-details": "showTopicDetails"
- },
- initialize: function(options) {
- this.attachEvents();
- return Backbone.View.prototype.initialize.call(this, options);
- },
- onModelChange: function(info, options) {
- if (options.updateLeaves) {
- this.updateLeaves();
- }
- },
- attachEvents: function() {
- this.model.bind("change", this.onModelChange, this);
- },
- detachEvents: function() {
- this.model.unbind("change", this.onModelChange);
- },
- /**
- * Renders the current card appropriately by card type.
- */
- render: function() {
- switch (this.model.get("cardType")) {
- case "problem":
- this.renderProblemCard();
- break;
- case "endofstack":
- this.renderEndOfStackCard();
- break;
- case "endofreview":
- this.renderEndOfReviewCard();
- break;
- case "happypicture":
- this.renderHappyPictureCard();
- break;
- default:
- throw "Trying to render unknown card type";
- }
- return this;
- },
- viewContext: function() {
- return _.extend(this.model.toJSON(), {
- leaves: this.model.leaves()
- });
- },
- /**
- * Renders the base card's structure, including leaves
- */
- renderCardContainer: function() {
- this.$el.html(this.template(this.viewContext()));
- },
- /**
- * Renders the card's type-specific contents into contents container
- */
- renderCardContents: function(templateName, optionalContext) {
- var context = _.extend({}, this.viewContext(), optionalContext);
- this.$el
- .find(".current-card-contents")
- .html(
- $(Templates.get(templateName)(context))
- );
- this.delegateEvents();
- },
- /**
- * Waits for API requests to finish, then runs target fxn
- */
- runAfterAPIRequests: function(fxn) {
- function tryRun() {
- if (Exercises.pendingAPIRequests > 0) {
- // Wait for any outbound API requests to finish.
- setTimeout(tryRun, 500);
- } else {
- // All API calls done, run target fxn
- fxn();
- }
- }
- tryRun();
- },
- renderCalculationInProgressCard: function() {
- if ($(".calculating-end-of-stack").is(":visible")) {
- // If the calculation in progress card is already visible,
- // bail.
- return;
- }
- this.renderCardContainer();
- this.renderCardContents("exercises.calculating-card");
- // Animate the first 8 cards into place -- others just go away
- setTimeout(function() {
- $(".complete-stack .card-container").each(function(ix, el) {
- if (ix < 8) {
- $(el).addClass("into-pocket").addClass("into-pocket-" + ix);
- } else {
- $(el).css("display", "none");
- }
- });
- }, 500);
- // Fade in/out our various pieces of "calculating progress" text
- var fadeInNextText = function(jel, egg) {
- // allows the loop to recycle when the nextMessage === []
- var messages = $(".calc-text-spin span");
- if (!jel || !jel.length) {
- jel = messages;
- }
- // display either jel or the egg if it was passed in
- var thisMessage = (egg == null) ? jel.first() : $(egg);
- var nextMessage = jel.next("span:not(.egg)");
- // send egg as second parameter if a tiny die lands just so
- var r = Math.random();
- var nextEgg = _.find(jel.filter(".egg"), function(elt) {
- var p = $(elt).data("prob");
- return (r >= p[0]) && (r < p[1]);
- });
- // fade out thisMessage and display egg || nextMessage
- thisMessage.fadeIn(600, function() {
- thisMessage.delay(1000).fadeOut(600, function() {
- fadeInNextText(nextMessage, nextEgg);
- });
- });
- };
- // recalculate cumulative probabilities for each egg
- var eggs = $(".calc-text-spin span.egg");
- for (var i = 0, head = 0; i < eggs.length; i += 1) {
- tail = head + $(eggs[i]).data("prob");
- $(eggs[i]).data("prob", [head, tail]);
- head = tail;
- }
- fadeInNextText();
- },
- /**
- * Renders a "calculations in progress" card, waits for API requests
- * to finish, and then renders the requested card template.
- */
- renderCardAfterAPIRequests: function(templateName, optionalContextFxn, optionalCallbackFxn) {
- // Start off by showing the "calculations in progress" card...
- this.renderCalculationInProgressCard();
- // ...and wait a bit for dramatic effect before trying to show the
- // requested card.
- setTimeout(function() {
- Exercises.currentCardView.runAfterAPIRequests(function() {
- optionalContextFxn = optionalContextFxn || function() {};
- Exercises.currentCardView.renderCardContents(templateName, optionalContextFxn());
- if (optionalCallbackFxn) {
- optionalCallbackFxn();
- }
- });
- }, 2200);
- },
- /**
- * Renders a new card showing an exercise problem via khan-exercises
- */
- renderProblemCard: function() {
- // khan-exercises currently both generates content and hooks up
- // events to the exercise interface. This means, for now, we don't want
- // to regenerate a brand new card when transitioning between exercise
- // problems.
- // TODO: in the future, if khan-exercises's problem generation is
- // separated from its UI events a little more, we can just rerender
- // the whole card for every problem.
- if (!$("#problemarea").length) {
- this.renderCardContainer();
- this.renderCardContents("exercises.problem-template");
- // Tell khan-exercises to setup its DOM and event listeners
- $(Exercises).trigger("problemTemplateRendered");
- //TODO (jasonrr): remove this when we remove the what happened UI
- $(".streak-transition").hoverIntent(
- function() {
- $(this).addClass("hover");
- },
- function() {
- $(this).removeClass("hover");
- }
- );
- }
- this.renderExerciseInProblemCard();
- // Update leaves since we may have not generated a brand new card
- this.updateLeaves();
- },
- renderExerciseInProblemCard: function() {
- var nextUserExercise = Exercises.nextUserExercise();
- if (nextUserExercise) {
- // khan-exercises is listening and will fill the card w/ new problem contents
- $(Exercises).trigger("readyForNextProblem", {userExercise: nextUserExercise});
- }
- },
- /**
- * Renders a new card showing end-of-stack statistics
- */
- renderEndOfStackCard: function() {
- this.renderCalculationInProgressCard();
- // Example "endOfStack" listener: exercises-intro.js
- $(Exercises).trigger("endOfStack");
- // First wait for all API requests to finish
- this.runAfterAPIRequests($.proxy(function() {
- var topicUserExercises = [];
- if (!Exercises.practiceMode && !Exercises.reviewMode) {
- Exercises.apiRequest({
- url: "/api/v1/user/topic/" + encodeURIComponent(Exercises.topic.get("id")) + "/exercises",
- type: "GET",
- success: function(data) {
- _.each(data, function(userExercise) {
- topicUserExercises[topicUserExercises.length] = userExercise;
- });
- }
- });
- }
- this.renderCardAfterAPIRequests(
- "exercises.end-of-stack-card",
- function() {
- // Collect various progress stats about both the current stack
- // and the current topic -- will be rendered by end of
- // stack card.
- var unstartedExercises = _.filter(topicUserExercises, function(userExercise) {
- return !userExercise.exerciseStates.proficient && userExercise.totalDone === 0;
- }),
- proficientExercises = _.filter(topicUserExercises, function(userExercise) {
- return userExercise.exerciseStates.proficient;
- }),
- startedExercises = _.filter(topicUserExercises, function(userExercise) {
- return !userExercise.exerciseStates.proficient && userExercise.totalDone > 0;
- }),
- progressStats = Exercises.sessionStats.progressStats();
- // Proficient exercises in which proficiency was just
- // earned in this current stack need to be marked as such.
- //
- // TODO: if we stick with this everywhere, we probably want
- // to change the actual review model algorithm to stop
- // setting recently-earned exercises into review state so
- // quickly.
- _.each(proficientExercises, function(userExercise) {
- userExercise.exerciseStates.justEarnedProficiency = _.any(progressStats.progress, function(stat) {
- return stat.exerciseStates.justEarnedProficiency && stat.name == userExercise.exercise;
- });
- });
- return _.extend(
- {
- "practiceMode": Exercises.practiceMode,
- "proficient": proficientExercises.length,
- "total": topicUserExercises.length,
- startedExercises: startedExercises,
- unstartedExercises: unstartedExercises,
- proficientExercises: proficientExercises
- },
- progressStats,
- Exercises.completeStack.stats()
- );
- },
- function() {
- Exercises.completeStackView.$el.hide();
- Exercises.currentCardView.$el
- .find(".stack-stats p, .small-exercise-icon, .review-explain")
- .each(Exercises.currentCardView.attachTooltip)
- .end()
- .find(".default-action")
- .focus();
- }
- );
- }, this));
- },
- /**
- * Renders a new card showing end-of-review statistics
- */
- renderEndOfReviewCard: function() {
- this.renderCalculationInProgressCard();
- // First wait for all API requests to finish
- this.runAfterAPIRequests(function() {
- var reviewsLeft = 0;
- // Then send another API request to see how many reviews are left --
- // and we'll change the end of review card's UI accordingly.
- Exercises.apiRequest({
- url: "/api/v1/user/exercises/reviews/count",
- type: "GET",
- success: function(data) { reviewsLeft = data; }
- });
- // And finally wait for the previous API call to finish before
- // rendering end of review card.
- Exercises.currentCardView.renderCardAfterAPIRequests(
- "exercises.end-of-review-card",
- function() {
- // Pass reviews left info into end of review card
- return _.extend({}, Exercises.completeStack.stats(), {reviewsLeft: reviewsLeft});
- },
- function() {
- Exercises.completeStackView.$el.hide();
- Exercises.currentCardView.$el
- .find(".default-action")
- .focus();
- }
- );
- });
- },
- /**
- * Renders a new card showing a leeeeeetle surprise
- */
- renderHappyPictureCard: function() {
- this.renderCardContainer();
- this.renderCardContents("exercises.happy-picture-card");
- this.$el
- .find("#next-question-button")
- .click(function() {
- Exercises.nextCard();
- })
- .focus();
- },
- attachTooltip: function() {
- $(this).qtip({
- content: {
- text: $(this).data("desc")
- },
- style: {
- classes: "ui-tooltip-light leaf-tooltip"
- },
- position: {
- my: "bottom center",
- at: "top center"
- },
- events: {
- show: function(e, api) {
- var target = $(api.elements.target);
- if (target.is(".leaf")) {
- // If we're hovering a leaf and the full leaf icon
- // is currently being animated, don't show the tooltip.
- if (parseInt(target.find(".full-leaf").css("opacity"), 10) != 1) {
- e.preventDefault();
- }
- }
- }
- },
- show: {
- delay: 200,
- effect: {
- length: 0
- }
- },
- hide: {
- delay: 0
- }
- });
- },
- /**
- * Show full details about the current topic
- * (starts out hidden to highlight stack-only details.
- */
- showTopicDetails: function() {
- $(".current-topic").slideDown();
- $("#show-topic-details").hide();
- },
- /**
- * Navigate to exercise dashboard
- */
- toDashboard: function() {
- window.location = "/exercisedashboard";
- },
- /**
- * Navigate to more stacks of the current type.
- * TODO: in the future, this can be done quick'n'javascript-y.
- */
- toMoreStacks: function() {
- window.location.assign(window.location.href);
- },
- /**
- * Update the currently available or earned leaves in current card's view
- */
- updateLeaves: function() {
- this.$el
- .find(".leaves-container")
- .html(
- $(Templates.get("exercises.card-leaves")(this.viewContext()))
- )
- .find(".leaf")
- .each(this.attachTooltip);
- if (this.model.get("done")) {
- $(".leaves-container").show();
- //TODO: This probably doesn't belong here
- $(".current-card").addClass("done");
- setTimeout(function() {
- $(".leaves-container .earned .full-leaf").addClass("animated");
- }, 1);
- } else {
- $(".current-card").removeClass("done");
- }
- },
- /**
- * Animate current card to right-hand completed stack
- */
- animateToRight: function() {
- this.$el.addClass("shrinkRight");
- // These animation fxns explicitly return null as they are used in deferreds
- // and may one day have deferrable animations (CSS3 animations aren't
- // deferred-friendly).
- return null;
- },
- /**
- * Animate card from left-hand completed stack to current card
- */
- animateFromLeft: function() {
- this.$el
- .removeClass("notransition")
- .removeClass("shrinkLeft");
- // These animation fxns explicitly return null as they are used in deferreds
- // and may one day have deferrable animations (CSS3 animations aren't
- // deferred-friendly).
- return null;
- },
- /**
- * Move (unanimated) current card from right-hand stack to left-hand stack between
- * toRight/fromLeft animations
- */
- moveLeft: function() {
- this.$el
- .addClass("notransition")
- .removeClass("shrinkRight")
- .addClass("shrinkLeft");
- // These animation fxns explicitly return null as they are used in deferreds
- // and may one day have deferrable animations (CSS3 animations aren't
- // deferred-friendly).
- return null;
- }
- });
- /**
- * SessionStats stores and caches a list of interesting statistics
- * about each individual stack session.
- */
- Exercises.SessionStats = Backbone.Model.extend({
- cacheEnabled: false,
- sessionId: null,
- initialize: function(attributes, options) {
- this.cacheEnabled = true;
- this.sessionId = options ? options.sessionId : null;
- // Try to load stats from cache
- this.loadFromCache();
- // Update exercise stats any time new exercise data is cached locally
- $(Exercises).bind("newUserExerciseData", $.proxy(function(ev, data) {
- this.updateProgressStats(data.exerciseName);
- }, this));
- return Backbone.Model.prototype.initialize.call(this, attributes, options);
- },
- cacheKey: function() {
- if (!this.sessionId) {
- throw "Missing session id for cache key";
- }
- return [
- "cachedsessionstats",
- this.sessionId
- ].join(":");
- },
- loadFromCache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- var attrs = LocalStore.get(this.cacheKey());
- if (attrs) {
- this.set(attrs);
- }
- },
- cache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- if (!this.cacheEnabled) {
- return;
- }
- LocalStore.set(this.cacheKey(), this.attributes);
- },
- clearCache: function() {
- if (!this.sessionId) {
- // Don't cache session-less pages (such as when viewing historical
- // problems)
- return;
- }
- LocalStore.del(this.cacheKey());
- },
- /**
- * Clears cache and disables sessionStats from being accumulated
- * if any more events are fired.
- */
- clearAndDisableCache: function() {
- this.cacheEnabled = false;
- this.clearCache();
- },
- /**
- * Update the start/end/change progress for this specific exercise so we
- * can summarize the user's session progress at the end of a stack.
- */
- updateProgressStats: function(exerciseName) {
- var userExercise = Exercises.UserExerciseCache.get(exerciseName);
- if (userExercise) {
- /**
- * For now, we're just keeping track of the change in progress per
- * exercise
- */
- var progressStats = this.get("progress") || {},
- stat = progressStats[exerciseName] || {
- name: userExercise.exercise,
- displayName: userExercise.exerciseModel.displayName,
- startProficient: userExercise.exerciseStates.proficient,
- startTotalDone: userExercise.totalDone,
- start: userExercise.progress
- };
- // Add all current proficiency/review/struggling states
- stat.exerciseStates = userExercise.exerciseStates;
- // Add an extra state to be used when proficiency was just earned
- // during the current stack.
- stat.exerciseStates.justEarnedProficiency = stat.exerciseStates.proficient && !stat.startProficient;
- stat.endTotalDone = userExercise.totalDone;
- stat.end = userExercise.progress;
- // Keep start set at the minimum of starting and current progress.
- // We do this b/c we never want to animate backwards progress --
- // if the user lost ground, just show their ending position.
- stat.start = Math.min(stat.start, stat.end);
- // Set and cache the latest
- progressStats[exerciseName] = stat;
- this.set({"progress": progressStats});
- this.cache();
- }
- },
- /**
- * Return list of stat objects for only those exercises which had at least
- * one problem done during this session, with latest userExercise state
- * from server attached.
- */
- progressStats: function() {
- var stats = _.filter(
- _.values(this.get("progress") || {}),
- function(stat) {
- return stat.endTotalDone && stat.endTotalDone > stat.startTotalDone;
- }
- );
- // Attach relevant userExercise object to each stat
- _.each(stats, function(stat) {
- stat.userExercise = Exercises.UserExerciseCache.get(stat.name);
- });
- return { progress: stats };
- }
- });