/** * 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 }; } });