PageRenderTime 169ms CodeModel.GetById 44ms RepoModel.GetById 1ms app.codeStats 0ms

/javascript/exercises-package/stacks.js

https://gitlab.com/gregtyka/KhanLatest
JavaScript | 1046 lines | 590 code | 234 blank | 222 comment | 56 complexity | a7f40b8337ade0ebeb9eff4455d6ec02 MD5 | raw file
  1. /**
  2. * Model of any (current or in-stack) card
  3. */
  4. Exercises.Card = Backbone.Model.extend({
  5. leaves: function(card) {
  6. return _.map(_.range(4), function(index) {
  7. return {
  8. index: index,
  9. state: (this.get("leavesEarned") > index ? "earned" :
  10. this.get("leavesAvailable") > index ? "available" :
  11. "unavailable")
  12. };
  13. }, this);
  14. },
  15. /**
  16. * Decreases leaves available -- if leaves available is already at this
  17. * level or lower, noop
  18. */
  19. decreaseLeavesAvailable: function(leavesAvailable) {
  20. var currentLeaves = this.get("leavesAvailable");
  21. if (currentLeaves) {
  22. leavesAvailable = Math.min(currentLeaves, leavesAvailable);
  23. }
  24. return this.set({ leavesAvailable: leavesAvailable });
  25. },
  26. /**
  27. * @return {boolean} True if this is a card representing the end of a stack.
  28. */
  29. isLastCard: function() {
  30. return _.contains(["endofstack", "endofreview"], this.get("cardType"));
  31. },
  32. /**
  33. * Get the associated latest user exercise object for this card.
  34. * @param {Card} card
  35. * @return {Object|undefined} The userExercise object if available.
  36. */
  37. getUserExercise: function(card) {
  38. return Exercises.UserExerciseCache.get(this.get("exerciseName"));
  39. }
  40. });
  41. /**
  42. * Collection model of a stack of cards
  43. */
  44. Exercises.StackCollection = Backbone.Collection.extend({
  45. model: Exercises.Card,
  46. peek: function() {
  47. return this.first();
  48. },
  49. pop: function(animationOptions) {
  50. var head = this.peek();
  51. this.remove(head, animationOptions);
  52. return head;
  53. },
  54. /**
  55. * Shrink this stack by removing N cards up to but not including
  56. * the first card in the stack and the last (end of stack) card.
  57. */
  58. shrinkBy: function(n) {
  59. // Never shrink to less than two cards (first card, end of stack card).
  60. var targetLength = Math.max(2, this.length - n);
  61. while (this.length > targetLength) {
  62. // Remove the second-to-last card until we're done.
  63. this.remove(this.models[this.length - 2]);
  64. }
  65. },
  66. /**
  67. * Return the longest streak of cards in this stack
  68. * that satisfies the truth test fxn.
  69. * If fxnSkip is supplied, the card won't count towards
  70. * or break a streak.
  71. */
  72. longestStreak: function(fxn, fxnSkip) {
  73. var current = 0,
  74. longest = 0;
  75. fxnSkip = fxnSkip || function() { return false; };
  76. this.each(function(card) {
  77. if (!fxnSkip(card)) {
  78. if (fxn(card)) {
  79. current += 1;
  80. } else {
  81. current = 0;
  82. }
  83. longest = Math.max(current, longest);
  84. }
  85. });
  86. return longest;
  87. },
  88. /**
  89. * Return a dictionary of interesting, positive stats about this stack.
  90. */
  91. stats: function() {
  92. var totalLeaves = this.reduce(function(sum, card) {
  93. // Don't count the fourth leaf for now. We're showing it in a different
  94. // way at the end of the stack. TODO (jasonrr/kamens) remove 4th leaf
  95. // altogether if we keep this implementation
  96. return Math.min(3, card.get("leavesEarned")) + sum;
  97. }, 0);
  98. var longestStreak = this.longestStreak(
  99. function(card) {
  100. return card.get("leavesEarned") >= 3;
  101. },
  102. function(card) {
  103. // Skip any cards w/ 0 leaves available --
  104. // those don't count.
  105. return card.get("leavesAvailable") === 0;
  106. }
  107. );
  108. var speedyCards = this.filter(function(card) {
  109. return card.get("leavesEarned") >= 4;
  110. }).length;
  111. return {
  112. "longestStreak": longestStreak,
  113. "speedyCards": speedyCards,
  114. "totalLeaves": totalLeaves
  115. };
  116. }
  117. });
  118. /**
  119. * StackCollection that is automatically cached in localStorage when modified
  120. * and loads itself from cache on initialization.
  121. */
  122. Exercises.CachedStackCollection = Exercises.StackCollection.extend({
  123. sessionId: null,
  124. uid: null,
  125. initialize: function(models, options) {
  126. this.sessionId = options ? options.sessionId : null;
  127. this.uid = options && options.uid;
  128. // Try to load models from cache
  129. if (!models) {
  130. this.loadFromCache();
  131. }
  132. this
  133. .bind("add", this.cache, this)
  134. .bind("remove", this.cache, this);
  135. return Exercises.StackCollection.prototype.initialize.call(this, models, options);
  136. },
  137. cacheKey: function() {
  138. if (!this.sessionId) {
  139. throw "Missing session id for cache key";
  140. }
  141. return [
  142. "cachedstack",
  143. this.sessionId
  144. ].join(":");
  145. },
  146. loadFromCache: function() {
  147. if (!this.sessionId) {
  148. // Don't cache session-less pages (such as when viewing historical
  149. // problems)
  150. return;
  151. }
  152. var modelAttrs = LocalStore.get(this.cacheKey());
  153. if (modelAttrs) {
  154. if (modelAttrs.uid) {
  155. this.uid = modelAttrs.uid;
  156. }
  157. _.each(modelAttrs.cards || modelAttrs, function(attrs) {
  158. this.add(new Exercises.Card(attrs));
  159. }, this);
  160. }
  161. },
  162. cache: function() {
  163. if (!this.sessionId) {
  164. // Don't cache session-less pages (such as when viewing historical
  165. // problems)
  166. return;
  167. }
  168. LocalStore.set(this.cacheKey(), {uid: this.uid, cards: this.models});
  169. },
  170. /**
  171. * Delete this stack from localStorage
  172. */
  173. clearCache: function() {
  174. if (!this.sessionId) {
  175. // Don't cache session-less pages (such as when viewing historical
  176. // problems)
  177. return;
  178. }
  179. LocalStore.del(this.cacheKey());
  180. },
  181. getUid: function() {
  182. // TODO(david): Ideally, this collection should be wrapped in a model.
  183. return this.uid;
  184. }
  185. });
  186. /**
  187. * View of a stack of cards
  188. */
  189. Exercises.StackView = Backbone.View.extend({
  190. template: Templates.get("exercises.stack"),
  191. initialize: function(options) {
  192. // deferAnimation is a wrapper function used to insert
  193. // any animations returned by fxn onto animationOption's
  194. // list of deferreds. This lets you chain complex
  195. // animations (see Exercises.nextCard).
  196. var deferAnimation = function(fxn) {
  197. return function(model, collection, options) {
  198. var result = fxn.call(this, model, collection, options);
  199. if (options && options.deferreds) {
  200. options.deferreds.push(result);
  201. }
  202. return result;
  203. };
  204. };
  205. this.collection
  206. .bind("add", deferAnimation(function(card) {
  207. return this.animatePush(card);
  208. }), this)
  209. .bind("remove", deferAnimation(function() {
  210. return this.animatePop();
  211. }), this);
  212. return Backbone.View.prototype.initialize.call(this, options);
  213. },
  214. render: function() {
  215. var collectionContext = _.map(this.collection.models, function(card, index) {
  216. return this.viewContext(card, index);
  217. }, this);
  218. this.$el.html(this.template({cards: collectionContext}));
  219. return this;
  220. },
  221. viewContext: function(card, index) {
  222. return _.extend(card.toJSON(), {
  223. index: index,
  224. frontVisible: this.options.frontVisible,
  225. cid: card.cid,
  226. leaves: card.leaves()
  227. });
  228. },
  229. /**
  230. * Animate popping card off of stack
  231. */
  232. animatePop: function() {
  233. return this.$el
  234. .find(".card-container")
  235. .first()
  236. .slideUp(360, function() { $(this).remove(); });
  237. },
  238. /**
  239. * Animate pushing card onto head of stack
  240. */
  241. animatePush: function(card) {
  242. var context = this.viewContext(card, this.collection.length);
  243. var jel = this.$el
  244. .find(".stack")
  245. .prepend(
  246. $(Templates.get("exercises.card")(context))
  247. .css("display", "none")
  248. )
  249. .find(".card-container")
  250. .first()
  251. // delay is used to slow down anybody waiting on this
  252. // animation. See comment below.
  253. .delay(250);
  254. // Don't immediately slideDown as part of the first animation that
  255. // happens after card insertion. This causes a rare and hard-to-track
  256. // browser crash in Chrome.
  257. //
  258. // TODO(kamens): remove this. All of this particular animation code
  259. // should be going away with the power mode team's move to their new
  260. // card UI, at which point we won't have to stress about this any more.
  261. setTimeout(function() {
  262. jel.slideDown(200);
  263. }, 50);
  264. return jel;
  265. }
  266. });
  267. /**
  268. * View of the single, currently-visible card
  269. */
  270. Exercises.CurrentCardView = Backbone.View.extend({
  271. template: Templates.get("exercises.current-card"),
  272. model: null,
  273. events: {
  274. "click .to-dashboard": "toDashboard",
  275. "click .more-stacks": "toMoreStacks",
  276. "click #show-topic-details": "showTopicDetails"
  277. },
  278. initialize: function(options) {
  279. this.attachEvents();
  280. return Backbone.View.prototype.initialize.call(this, options);
  281. },
  282. onModelChange: function(info, options) {
  283. if (options.updateLeaves) {
  284. this.updateLeaves();
  285. }
  286. },
  287. attachEvents: function() {
  288. this.model.bind("change", this.onModelChange, this);
  289. },
  290. detachEvents: function() {
  291. this.model.unbind("change", this.onModelChange);
  292. },
  293. /**
  294. * Renders the current card appropriately by card type.
  295. */
  296. render: function() {
  297. switch (this.model.get("cardType")) {
  298. case "problem":
  299. this.renderProblemCard();
  300. break;
  301. case "endofstack":
  302. this.renderEndOfStackCard();
  303. break;
  304. case "endofreview":
  305. this.renderEndOfReviewCard();
  306. break;
  307. case "happypicture":
  308. this.renderHappyPictureCard();
  309. break;
  310. default:
  311. throw "Trying to render unknown card type";
  312. }
  313. return this;
  314. },
  315. viewContext: function() {
  316. return _.extend(this.model.toJSON(), {
  317. leaves: this.model.leaves()
  318. });
  319. },
  320. /**
  321. * Renders the base card's structure, including leaves
  322. */
  323. renderCardContainer: function() {
  324. this.$el.html(this.template(this.viewContext()));
  325. },
  326. /**
  327. * Renders the card's type-specific contents into contents container
  328. */
  329. renderCardContents: function(templateName, optionalContext) {
  330. var context = _.extend({}, this.viewContext(), optionalContext);
  331. this.$el
  332. .find(".current-card-contents")
  333. .html(
  334. $(Templates.get(templateName)(context))
  335. );
  336. this.delegateEvents();
  337. },
  338. /**
  339. * Waits for API requests to finish, then runs target fxn
  340. */
  341. runAfterAPIRequests: function(fxn) {
  342. function tryRun() {
  343. if (Exercises.pendingAPIRequests > 0) {
  344. // Wait for any outbound API requests to finish.
  345. setTimeout(tryRun, 500);
  346. } else {
  347. // All API calls done, run target fxn
  348. fxn();
  349. }
  350. }
  351. tryRun();
  352. },
  353. renderCalculationInProgressCard: function() {
  354. if ($(".calculating-end-of-stack").is(":visible")) {
  355. // If the calculation in progress card is already visible,
  356. // bail.
  357. return;
  358. }
  359. this.renderCardContainer();
  360. this.renderCardContents("exercises.calculating-card");
  361. // Animate the first 8 cards into place -- others just go away
  362. setTimeout(function() {
  363. $(".complete-stack .card-container").each(function(ix, el) {
  364. if (ix < 8) {
  365. $(el).addClass("into-pocket").addClass("into-pocket-" + ix);
  366. } else {
  367. $(el).css("display", "none");
  368. }
  369. });
  370. }, 500);
  371. // Fade in/out our various pieces of "calculating progress" text
  372. var fadeInNextText = function(jel, egg) {
  373. // allows the loop to recycle when the nextMessage === []
  374. var messages = $(".calc-text-spin span");
  375. if (!jel || !jel.length) {
  376. jel = messages;
  377. }
  378. // display either jel or the egg if it was passed in
  379. var thisMessage = (egg == null) ? jel.first() : $(egg);
  380. var nextMessage = jel.next("span:not(.egg)");
  381. // send egg as second parameter if a tiny die lands just so
  382. var r = Math.random();
  383. var nextEgg = _.find(jel.filter(".egg"), function(elt) {
  384. var p = $(elt).data("prob");
  385. return (r >= p[0]) && (r < p[1]);
  386. });
  387. // fade out thisMessage and display egg || nextMessage
  388. thisMessage.fadeIn(600, function() {
  389. thisMessage.delay(1000).fadeOut(600, function() {
  390. fadeInNextText(nextMessage, nextEgg);
  391. });
  392. });
  393. };
  394. // recalculate cumulative probabilities for each egg
  395. var eggs = $(".calc-text-spin span.egg");
  396. for (var i = 0, head = 0; i < eggs.length; i += 1) {
  397. tail = head + $(eggs[i]).data("prob");
  398. $(eggs[i]).data("prob", [head, tail]);
  399. head = tail;
  400. }
  401. fadeInNextText();
  402. },
  403. /**
  404. * Renders a "calculations in progress" card, waits for API requests
  405. * to finish, and then renders the requested card template.
  406. */
  407. renderCardAfterAPIRequests: function(templateName, optionalContextFxn, optionalCallbackFxn) {
  408. // Start off by showing the "calculations in progress" card...
  409. this.renderCalculationInProgressCard();
  410. // ...and wait a bit for dramatic effect before trying to show the
  411. // requested card.
  412. setTimeout(function() {
  413. Exercises.currentCardView.runAfterAPIRequests(function() {
  414. optionalContextFxn = optionalContextFxn || function() {};
  415. Exercises.currentCardView.renderCardContents(templateName, optionalContextFxn());
  416. if (optionalCallbackFxn) {
  417. optionalCallbackFxn();
  418. }
  419. });
  420. }, 2200);
  421. },
  422. /**
  423. * Renders a new card showing an exercise problem via khan-exercises
  424. */
  425. renderProblemCard: function() {
  426. // khan-exercises currently both generates content and hooks up
  427. // events to the exercise interface. This means, for now, we don't want
  428. // to regenerate a brand new card when transitioning between exercise
  429. // problems.
  430. // TODO: in the future, if khan-exercises's problem generation is
  431. // separated from its UI events a little more, we can just rerender
  432. // the whole card for every problem.
  433. if (!$("#problemarea").length) {
  434. this.renderCardContainer();
  435. this.renderCardContents("exercises.problem-template");
  436. // Tell khan-exercises to setup its DOM and event listeners
  437. $(Exercises).trigger("problemTemplateRendered");
  438. //TODO (jasonrr): remove this when we remove the what happened UI
  439. $(".streak-transition").hoverIntent(
  440. function() {
  441. $(this).addClass("hover");
  442. },
  443. function() {
  444. $(this).removeClass("hover");
  445. }
  446. );
  447. }
  448. this.renderExerciseInProblemCard();
  449. // Update leaves since we may have not generated a brand new card
  450. this.updateLeaves();
  451. },
  452. renderExerciseInProblemCard: function() {
  453. var nextUserExercise = Exercises.nextUserExercise();
  454. if (nextUserExercise) {
  455. // khan-exercises is listening and will fill the card w/ new problem contents
  456. $(Exercises).trigger("readyForNextProblem", {userExercise: nextUserExercise});
  457. }
  458. },
  459. /**
  460. * Renders a new card showing end-of-stack statistics
  461. */
  462. renderEndOfStackCard: function() {
  463. this.renderCalculationInProgressCard();
  464. // Example "endOfStack" listener: exercises-intro.js
  465. $(Exercises).trigger("endOfStack");
  466. // First wait for all API requests to finish
  467. this.runAfterAPIRequests($.proxy(function() {
  468. var topicUserExercises = [];
  469. if (!Exercises.practiceMode && !Exercises.reviewMode) {
  470. Exercises.apiRequest({
  471. url: "/api/v1/user/topic/" + encodeURIComponent(Exercises.topic.get("id")) + "/exercises",
  472. type: "GET",
  473. success: function(data) {
  474. _.each(data, function(userExercise) {
  475. topicUserExercises[topicUserExercises.length] = userExercise;
  476. });
  477. }
  478. });
  479. }
  480. this.renderCardAfterAPIRequests(
  481. "exercises.end-of-stack-card",
  482. function() {
  483. // Collect various progress stats about both the current stack
  484. // and the current topic -- will be rendered by end of
  485. // stack card.
  486. var unstartedExercises = _.filter(topicUserExercises, function(userExercise) {
  487. return !userExercise.exerciseStates.proficient && userExercise.totalDone === 0;
  488. }),
  489. proficientExercises = _.filter(topicUserExercises, function(userExercise) {
  490. return userExercise.exerciseStates.proficient;
  491. }),
  492. startedExercises = _.filter(topicUserExercises, function(userExercise) {
  493. return !userExercise.exerciseStates.proficient && userExercise.totalDone > 0;
  494. }),
  495. progressStats = Exercises.sessionStats.progressStats();
  496. // Proficient exercises in which proficiency was just
  497. // earned in this current stack need to be marked as such.
  498. //
  499. // TODO: if we stick with this everywhere, we probably want
  500. // to change the actual review model algorithm to stop
  501. // setting recently-earned exercises into review state so
  502. // quickly.
  503. _.each(proficientExercises, function(userExercise) {
  504. userExercise.exerciseStates.justEarnedProficiency = _.any(progressStats.progress, function(stat) {
  505. return stat.exerciseStates.justEarnedProficiency && stat.name == userExercise.exercise;
  506. });
  507. });
  508. return _.extend(
  509. {
  510. "practiceMode": Exercises.practiceMode,
  511. "proficient": proficientExercises.length,
  512. "total": topicUserExercises.length,
  513. startedExercises: startedExercises,
  514. unstartedExercises: unstartedExercises,
  515. proficientExercises: proficientExercises
  516. },
  517. progressStats,
  518. Exercises.completeStack.stats()
  519. );
  520. },
  521. function() {
  522. Exercises.completeStackView.$el.hide();
  523. Exercises.currentCardView.$el
  524. .find(".stack-stats p, .small-exercise-icon, .review-explain")
  525. .each(Exercises.currentCardView.attachTooltip)
  526. .end()
  527. .find(".default-action")
  528. .focus();
  529. }
  530. );
  531. }, this));
  532. },
  533. /**
  534. * Renders a new card showing end-of-review statistics
  535. */
  536. renderEndOfReviewCard: function() {
  537. this.renderCalculationInProgressCard();
  538. // First wait for all API requests to finish
  539. this.runAfterAPIRequests(function() {
  540. var reviewsLeft = 0;
  541. // Then send another API request to see how many reviews are left --
  542. // and we'll change the end of review card's UI accordingly.
  543. Exercises.apiRequest({
  544. url: "/api/v1/user/exercises/reviews/count",
  545. type: "GET",
  546. success: function(data) { reviewsLeft = data; }
  547. });
  548. // And finally wait for the previous API call to finish before
  549. // rendering end of review card.
  550. Exercises.currentCardView.renderCardAfterAPIRequests(
  551. "exercises.end-of-review-card",
  552. function() {
  553. // Pass reviews left info into end of review card
  554. return _.extend({}, Exercises.completeStack.stats(), {reviewsLeft: reviewsLeft});
  555. },
  556. function() {
  557. Exercises.completeStackView.$el.hide();
  558. Exercises.currentCardView.$el
  559. .find(".default-action")
  560. .focus();
  561. }
  562. );
  563. });
  564. },
  565. /**
  566. * Renders a new card showing a leeeeeetle surprise
  567. */
  568. renderHappyPictureCard: function() {
  569. this.renderCardContainer();
  570. this.renderCardContents("exercises.happy-picture-card");
  571. this.$el
  572. .find("#next-question-button")
  573. .click(function() {
  574. Exercises.nextCard();
  575. })
  576. .focus();
  577. },
  578. attachTooltip: function() {
  579. $(this).qtip({
  580. content: {
  581. text: $(this).data("desc")
  582. },
  583. style: {
  584. classes: "ui-tooltip-light leaf-tooltip"
  585. },
  586. position: {
  587. my: "bottom center",
  588. at: "top center"
  589. },
  590. events: {
  591. show: function(e, api) {
  592. var target = $(api.elements.target);
  593. if (target.is(".leaf")) {
  594. // If we're hovering a leaf and the full leaf icon
  595. // is currently being animated, don't show the tooltip.
  596. if (parseInt(target.find(".full-leaf").css("opacity"), 10) != 1) {
  597. e.preventDefault();
  598. }
  599. }
  600. }
  601. },
  602. show: {
  603. delay: 200,
  604. effect: {
  605. length: 0
  606. }
  607. },
  608. hide: {
  609. delay: 0
  610. }
  611. });
  612. },
  613. /**
  614. * Show full details about the current topic
  615. * (starts out hidden to highlight stack-only details.
  616. */
  617. showTopicDetails: function() {
  618. $(".current-topic").slideDown();
  619. $("#show-topic-details").hide();
  620. },
  621. /**
  622. * Navigate to exercise dashboard
  623. */
  624. toDashboard: function() {
  625. window.location = "/exercisedashboard";
  626. },
  627. /**
  628. * Navigate to more stacks of the current type.
  629. * TODO: in the future, this can be done quick'n'javascript-y.
  630. */
  631. toMoreStacks: function() {
  632. window.location.assign(window.location.href);
  633. },
  634. /**
  635. * Update the currently available or earned leaves in current card's view
  636. */
  637. updateLeaves: function() {
  638. this.$el
  639. .find(".leaves-container")
  640. .html(
  641. $(Templates.get("exercises.card-leaves")(this.viewContext()))
  642. )
  643. .find(".leaf")
  644. .each(this.attachTooltip);
  645. if (this.model.get("done")) {
  646. $(".leaves-container").show();
  647. //TODO: This probably doesn't belong here
  648. $(".current-card").addClass("done");
  649. setTimeout(function() {
  650. $(".leaves-container .earned .full-leaf").addClass("animated");
  651. }, 1);
  652. } else {
  653. $(".current-card").removeClass("done");
  654. }
  655. },
  656. /**
  657. * Animate current card to right-hand completed stack
  658. */
  659. animateToRight: function() {
  660. this.$el.addClass("shrinkRight");
  661. // These animation fxns explicitly return null as they are used in deferreds
  662. // and may one day have deferrable animations (CSS3 animations aren't
  663. // deferred-friendly).
  664. return null;
  665. },
  666. /**
  667. * Animate card from left-hand completed stack to current card
  668. */
  669. animateFromLeft: function() {
  670. this.$el
  671. .removeClass("notransition")
  672. .removeClass("shrinkLeft");
  673. // These animation fxns explicitly return null as they are used in deferreds
  674. // and may one day have deferrable animations (CSS3 animations aren't
  675. // deferred-friendly).
  676. return null;
  677. },
  678. /**
  679. * Move (unanimated) current card from right-hand stack to left-hand stack between
  680. * toRight/fromLeft animations
  681. */
  682. moveLeft: function() {
  683. this.$el
  684. .addClass("notransition")
  685. .removeClass("shrinkRight")
  686. .addClass("shrinkLeft");
  687. // These animation fxns explicitly return null as they are used in deferreds
  688. // and may one day have deferrable animations (CSS3 animations aren't
  689. // deferred-friendly).
  690. return null;
  691. }
  692. });
  693. /**
  694. * SessionStats stores and caches a list of interesting statistics
  695. * about each individual stack session.
  696. */
  697. Exercises.SessionStats = Backbone.Model.extend({
  698. cacheEnabled: false,
  699. sessionId: null,
  700. initialize: function(attributes, options) {
  701. this.cacheEnabled = true;
  702. this.sessionId = options ? options.sessionId : null;
  703. // Try to load stats from cache
  704. this.loadFromCache();
  705. // Update exercise stats any time new exercise data is cached locally
  706. $(Exercises).bind("newUserExerciseData", $.proxy(function(ev, data) {
  707. this.updateProgressStats(data.exerciseName);
  708. }, this));
  709. return Backbone.Model.prototype.initialize.call(this, attributes, options);
  710. },
  711. cacheKey: function() {
  712. if (!this.sessionId) {
  713. throw "Missing session id for cache key";
  714. }
  715. return [
  716. "cachedsessionstats",
  717. this.sessionId
  718. ].join(":");
  719. },
  720. loadFromCache: function() {
  721. if (!this.sessionId) {
  722. // Don't cache session-less pages (such as when viewing historical
  723. // problems)
  724. return;
  725. }
  726. var attrs = LocalStore.get(this.cacheKey());
  727. if (attrs) {
  728. this.set(attrs);
  729. }
  730. },
  731. cache: function() {
  732. if (!this.sessionId) {
  733. // Don't cache session-less pages (such as when viewing historical
  734. // problems)
  735. return;
  736. }
  737. if (!this.cacheEnabled) {
  738. return;
  739. }
  740. LocalStore.set(this.cacheKey(), this.attributes);
  741. },
  742. clearCache: function() {
  743. if (!this.sessionId) {
  744. // Don't cache session-less pages (such as when viewing historical
  745. // problems)
  746. return;
  747. }
  748. LocalStore.del(this.cacheKey());
  749. },
  750. /**
  751. * Clears cache and disables sessionStats from being accumulated
  752. * if any more events are fired.
  753. */
  754. clearAndDisableCache: function() {
  755. this.cacheEnabled = false;
  756. this.clearCache();
  757. },
  758. /**
  759. * Update the start/end/change progress for this specific exercise so we
  760. * can summarize the user's session progress at the end of a stack.
  761. */
  762. updateProgressStats: function(exerciseName) {
  763. var userExercise = Exercises.UserExerciseCache.get(exerciseName);
  764. if (userExercise) {
  765. /**
  766. * For now, we're just keeping track of the change in progress per
  767. * exercise
  768. */
  769. var progressStats = this.get("progress") || {},
  770. stat = progressStats[exerciseName] || {
  771. name: userExercise.exercise,
  772. displayName: userExercise.exerciseModel.displayName,
  773. startProficient: userExercise.exerciseStates.proficient,
  774. startTotalDone: userExercise.totalDone,
  775. start: userExercise.progress
  776. };
  777. // Add all current proficiency/review/struggling states
  778. stat.exerciseStates = userExercise.exerciseStates;
  779. // Add an extra state to be used when proficiency was just earned
  780. // during the current stack.
  781. stat.exerciseStates.justEarnedProficiency = stat.exerciseStates.proficient && !stat.startProficient;
  782. stat.endTotalDone = userExercise.totalDone;
  783. stat.end = userExercise.progress;
  784. // Keep start set at the minimum of starting and current progress.
  785. // We do this b/c we never want to animate backwards progress --
  786. // if the user lost ground, just show their ending position.
  787. stat.start = Math.min(stat.start, stat.end);
  788. // Set and cache the latest
  789. progressStats[exerciseName] = stat;
  790. this.set({"progress": progressStats});
  791. this.cache();
  792. }
  793. },
  794. /**
  795. * Return list of stat objects for only those exercises which had at least
  796. * one problem done during this session, with latest userExercise state
  797. * from server attached.
  798. */
  799. progressStats: function() {
  800. var stats = _.filter(
  801. _.values(this.get("progress") || {}),
  802. function(stat) {
  803. return stat.endTotalDone && stat.endTotalDone > stat.startTotalDone;
  804. }
  805. );
  806. // Attach relevant userExercise object to each stat
  807. _.each(stats, function(stat) {
  808. stat.userExercise = Exercises.UserExerciseCache.get(stat.name);
  809. });
  810. return { progress: stats };
  811. }
  812. });