PageRenderTime 48ms CodeModel.GetById 19ms RepoModel.GetById 0ms app.codeStats 0ms

/javascript/shared-package/goals.js

https://bitbucket.org/taewony/stable
JavaScript | 719 lines | 582 code | 89 blank | 48 comment | 68 complexity | 5598d1f7c8965d3c04ae9736256796f8 MD5 | raw file
Possible License(s): BSD-3-Clause, LGPL-3.0, CC-BY-3.0, GPL-3.0
  1. var Goal = Backbone.Model.extend({
  2. defaults: {
  3. active: false,
  4. complete: false,
  5. progress: 0,
  6. title: "Unnamed goal",
  7. objectives: []
  8. },
  9. urlRoot: "/api/v1/user/goals",
  10. initialize: function() {
  11. // defaults for new models (e.g. not from server)
  12. // default created and updated values
  13. if (!this.has("created")) {
  14. var now = new Date().toISOString();
  15. this.set({created: now, updated: now});
  16. }
  17. // default progress value for all objectives
  18. _.each(this.get("objectives"), function(o) {
  19. if (!o.progress) {
  20. o.progress = 0;
  21. }
  22. });
  23. // a bunch of stuff needed to display goals in views. might need to be
  24. // refactored.
  25. this.calcDependents();
  26. this.bind("change", this.fireCustom, this);
  27. },
  28. calcDependents: function() {
  29. var progress = this.calcTotalProgress(this.get("objectives"));
  30. var objectiveWidth = 100 / this.get("objectives").length;
  31. _.each(this.get("objectives"), function(obj) {
  32. Goal.calcObjectiveDependents(obj, objectiveWidth);
  33. });
  34. this.set({
  35. progress: progress,
  36. progressStr: Goal.floatToPercentageStr(progress),
  37. complete: progress >= 1,
  38. // used to display 3/5 in goal summary area
  39. objectiveProgress: _.filter(this.get("objectives"), function(obj) {
  40. return obj.progress >= 1;
  41. }).length,
  42. // used to maintain sorted order in a GoalCollection
  43. updatedTime: parseISO8601(this.get("updated")).getTime()
  44. }, {silent: true});
  45. },
  46. calcTotalProgress: function(objectives) {
  47. objectives = objectives || this.get("objectives");
  48. var progress = 0;
  49. if (objectives.length) {
  50. progress = _.reduce(objectives, function(p, ob) { return p + ob.progress; }, 0);
  51. if (objectives.length > 0) {
  52. progress = progress / objectives.length;
  53. } else {
  54. progress = 0;
  55. }
  56. }
  57. return progress;
  58. },
  59. fireCustom: function() {
  60. this.calcDependents();
  61. if (this.hasChanged("progress")) {
  62. // we want to fire these events after all other listeners to 'change'
  63. // have had a chance to run
  64. var toFire = [];
  65. // check for goal completion
  66. if (this.get("progress") >= 1) {
  67. toFire.push(["goalcompleted", this]);
  68. }
  69. else {
  70. // now look for updated objectives
  71. oldObjectives = this.previous("objectives");
  72. _.each(this.get("objectives"), function(newObj, i) {
  73. var oldObj = oldObjectives[i];
  74. if (newObj.progress > oldObj.progress) {
  75. toFire.push(["progressed", this, newObj]);
  76. if (newObj.progress >= 1) {
  77. toFire.push(["completed", this, newObj]);
  78. }
  79. }
  80. }, this);
  81. }
  82. if (_.any(toFire)) {
  83. // register a callback to execute at the end of the rest of the
  84. // change callbacks
  85. this.collection.bind("change", function callback() {
  86. // this callback should only run once, so immediately unbind
  87. this.unbind("change", callback);
  88. // trigger all change notifications
  89. _.each(toFire, function(triggerArgs) {
  90. this.trigger.apply(this, triggerArgs);
  91. }, this);
  92. }, this.collection);
  93. }
  94. }
  95. }
  96. }, {
  97. calcObjectiveDependents: function(objective, objectiveWidth) {
  98. objective.complete = objective.progress >= 1;
  99. objective.progressStr = Goal.floatToPercentageStr(objective.progress);
  100. objective.iconFillHeight = Goal.calcIconFillHeight(objective);
  101. objective.objectiveWidth = objectiveWidth;
  102. objective.isVideo = (objective.type == "GoalObjectiveWatchVideo");
  103. objective.isAnyVideo = (objective.type == "GoalObjectiveAnyVideo");
  104. objective.isExercise = (objective.type == "GoalObjectiveExerciseProficiency");
  105. objective.isAnyExercise = (objective.type == "GoalObjectiveAnyExerciseProficiency");
  106. },
  107. calcIconFillHeight: function(objective) {
  108. var height = objective.type.toLowerCase().indexOf("exercise") >= 1 ? 13 : 12;
  109. var offset = objective.type.toLowerCase().indexOf("exercise") >= 1 ? 4 : 6;
  110. return Math.ceil(objective.progress * height) + offset;
  111. },
  112. floatToPercentageStr: function(progress) {
  113. return (progress * 100).toFixed(0);
  114. },
  115. objectiveUrlForType: {
  116. GoalObjectiveWatchVideo: function(objective) {
  117. return "/video/" + objective.internal_id;
  118. },
  119. GoalObjectiveAnyVideo: function(objective) {
  120. return "/";
  121. },
  122. GoalObjectiveExerciseProficiency: function(objective) {
  123. return "/exercise/" + objective.internal_id;
  124. },
  125. GoalObjectiveAnyExerciseProficiency: function(objective) {
  126. return "/exercisedashboard";
  127. }
  128. },
  129. objectiveUrl: function(objective) {
  130. return Goal.objectiveUrlForType[objective.type](objective);
  131. }
  132. });
  133. var GoalCollection = Backbone.Collection.extend({
  134. model: Goal,
  135. initialize: function() {
  136. this.updateActive();
  137. // ensure updateActive is called whenever the collection changes
  138. this.bind("add", this.updateActive, this);
  139. this.bind("remove", this.updateActive, this);
  140. this.bind("reset", this.updateActive, this);
  141. },
  142. url: "/api/v1/user/goals",
  143. comparator: function(goal) {
  144. // display most recently updated goal at the top of the list.
  145. // http://stackoverflow.com/questions/5636812/sorting-strings-in-reverse-order-with-backbone-js/5639070#5639070
  146. return -goal.get("updatedTime");
  147. },
  148. active: function(goal) {
  149. var current = this.find(function(g) {return g.get("active");}) || null;
  150. if (goal && goal !== current) {
  151. // set active
  152. if (current !== null) {
  153. current.set({active: false});
  154. }
  155. goal.set({active: true});
  156. current = goal;
  157. }
  158. return current;
  159. },
  160. updateActive: function() {
  161. this.active(this.findActiveGoal());
  162. },
  163. incrementalUpdate: function(updatedGoals) {
  164. _.each(updatedGoals, function(newGoal) {
  165. oldGoal = this.get(newGoal.id) || null;
  166. if (oldGoal !== null) {
  167. oldGoal.set(newGoal);
  168. }
  169. else {
  170. // todo: remove this, do something better
  171. KAConsole.log("Error: brand new goal appeared from somewhere", newGoal);
  172. }
  173. }, this);
  174. },
  175. findGoalWithObjective: function(internalId, specificType, generalType) {
  176. return this.find(function(goal) {
  177. // find a goal with an objective for this exact entity
  178. return _.find(goal.get("objectives"), function(ob) {
  179. return ob.type == specificType && internalId == ob.internal_id;
  180. });
  181. }) || this.find(function(goal) {
  182. // otherwise find a goal with any entity proficiency
  183. return _.find(goal.get("objectives"), function(ob) {
  184. return ob.type == generalType;
  185. });
  186. }) || null;
  187. },
  188. // find the most appriate goal to display for a given URL
  189. findActiveGoal: function() {
  190. var matchingGoal = null;
  191. if (window.location.pathname.indexOf("/exercise") === 0 && window.userExerciseName) {
  192. matchingGoal = this.findGoalWithObjective(userExerciseName,
  193. "GoalObjectiveExerciseProficiency",
  194. "GoalObjectiveAnyExerciseProficiency");
  195. } else if (window.location.pathname.indexOf("/video") === 0 &&
  196. typeof Video.readableId !== "undefined") {
  197. matchingGoal = this.findGoalWithObjective(Video.readableId,
  198. "GoalObjectiveWatchVideo", "GoalObjectiveAnyVideo");
  199. }
  200. // if we're not on a matching exercise or video page, just show the
  201. // most recently upated one
  202. if (matchingGoal === null) {
  203. matchingGoal = this.at(0); // comparator is most recently updated
  204. }
  205. return matchingGoal;
  206. },
  207. processGoalContext: function() {
  208. return {
  209. hasExercise: this.any(function(goal) {
  210. return _.any(goal.get("objectives"), function(obj) {
  211. return obj.type === "GoalObjectiveAnyExerciseProficiency";
  212. });
  213. }),
  214. hasVideo: this.any(function(goal) {
  215. return _.any(goal.get("objectives"), function(obj) {
  216. return obj.type === "GoalObjectiveAnyVideo";
  217. });
  218. })
  219. };
  220. }
  221. });
  222. var GoalBookView = Backbone.View.extend({
  223. template: Templates.get("shared.goalbook"),
  224. isVisible: false,
  225. needsRerender: true,
  226. initialize: function() {
  227. $(this.el)
  228. .delegate(".close-button", "click", $.proxy(this.hide, this))
  229. // listen to archive button on goals
  230. .delegate(".goal.recently-completed", "mouseenter mouseleave", function(e) {
  231. var el = $(e.currentTarget);
  232. if (e.type == "mouseenter") {
  233. el.find(".goal-description .summary-light").hide();
  234. el.find(".goal-description .goal-controls").show();
  235. } else {
  236. el.find(".goal-description .goal-controls").hide();
  237. el.find(".goal-description .summary-light").show();
  238. }
  239. })
  240. .delegate(".archive", "click", $.proxy(function(e) {
  241. var jel = $(e.target).closest(".goal");
  242. var goal = this.model.get(jel.data("id"));
  243. this.animateGoalToHistory(jel).then($.proxy(function() {
  244. this.model.remove(goal);
  245. }, this));
  246. }, this))
  247. .delegate(".new-goal", "click", $.proxy(function(e) {
  248. e.preventDefault();
  249. this.hide();
  250. newGoalDialog.show();
  251. }, this))
  252. .delegate(".goal-history", "click",
  253. $.proxy(this.goalHistoryButtonClicked, this));
  254. this.model.bind("change", this.render, this);
  255. this.model.bind("reset", this.render, this);
  256. this.model.bind("remove", this.render, this);
  257. this.model.bind("add", this.added, this);
  258. this.model.bind("goalcompleted", this.show, this);
  259. },
  260. show: function() {
  261. this.isVisible = true;
  262. // render if necessary
  263. if (this.needsRerender) {
  264. this.render();
  265. }
  266. var that = this;
  267. // animate on the way down
  268. return $(this.el).slideDown("fast", function() {
  269. // listen for escape key
  270. $(document).bind("keyup.goalbook", function(e) {
  271. if (e.which == 27) {
  272. that.hide();
  273. }
  274. });
  275. // close the goalbook if user clicks elsewhere on page
  276. $("body").bind("click.goalbook", function(e) {
  277. if ($(e.target).closest("#goals-nav-container").length === 0) {
  278. that.hide();
  279. }
  280. });
  281. });
  282. },
  283. hide: function() {
  284. this.isVisible = false;
  285. $(document).unbind("keyup.goalbook");
  286. $("body").unbind("click.goalbook");
  287. // if there are completed goals, move them to history before closing
  288. var completed = this.model.filter(function(goal) { return goal.get("complete"); });
  289. var completedEls = this.$(".recently-completed");
  290. if (completedEls.length > 0) {
  291. this.animateThenHide(completedEls);
  292. } else {
  293. return $(this.el).slideUp("fast");
  294. }
  295. },
  296. goalHistoryButtonClicked: function(e) {
  297. if (document.location.pathname === "/profile") {
  298. var jelGoalLink = $(".goals-accordion-content .graph-link");
  299. if (jelGoalLink.length) {
  300. e.preventDefault();
  301. jelGoalLink.click();
  302. this.hide();
  303. }
  304. }
  305. },
  306. added: function(goal, options) {
  307. this.needsRerender = true;
  308. this.show();
  309. // add a highlight to the new goal
  310. $(".goal[data-id=" + goal.get("id") + "]").effect("highlight", {}, 2500);
  311. },
  312. animateThenHide: function(els) {
  313. var goals = _.map(els, function(el) {
  314. return this.model.get($(el).data("id"));
  315. }, this);
  316. // wait for the animation to complete and then close the goalbook
  317. this.animateGoalToHistory(els).then($.proxy(function() {
  318. $(this.el).slideUp("fast").promise().then($.proxy(function() {
  319. this.model.remove(goals);
  320. }, this));
  321. }, this));
  322. },
  323. render: function() {
  324. var jel = $(this.el);
  325. // delay rendering until the view is actually visible
  326. if (!this.isVisible) {
  327. this.needsRerender = true;
  328. }
  329. else {
  330. KAConsole.log("rendering GoalBookView", this);
  331. this.needsRerender = false;
  332. var json = _.pluck(this.model.models, "attributes");
  333. jel.html(this.template({goals: json}));
  334. }
  335. return this;
  336. },
  337. animateGoalToHistory: function(els) {
  338. var btnGoalHistory = this.$("a.goal-history");
  339. var promises = $(els).map(function(i, el) {
  340. var dfd = $.Deferred();
  341. var jel = $(el);
  342. jel .children()
  343. .each(function() {
  344. $(this).css("overflow", "hidden").css("height", $(this).height());
  345. })
  346. .end()
  347. .delay(500)
  348. .animate({
  349. width: btnGoalHistory.width(),
  350. left: btnGoalHistory.position().left
  351. })
  352. .animate({
  353. top: btnGoalHistory.position().top - jel.position().top,
  354. height: "0",
  355. opacity: "toggle"
  356. },
  357. "easeInOutCubic",
  358. function() {
  359. $(this).remove();
  360. dfd.resolve();
  361. }
  362. );
  363. return dfd.promise();
  364. }).get();
  365. // once all the animations are done, make the history button glow
  366. var button = $.Deferred();
  367. $.when.apply(null, promises).then(function() {
  368. btnGoalHistory
  369. .animate({backgroundColor: "orange"})
  370. .animate({backgroundColor: "#ddd"}, button.resolve);
  371. });
  372. // return a promise that the history button is done animating
  373. return button.promise();
  374. }
  375. });
  376. var GoalSummaryView = Backbone.View.extend({
  377. template: Templates.get("shared.goal-summary-area"),
  378. initialize: function(args) {
  379. $(this.el).delegate("#goals-drawer", "click",
  380. $.proxy(args.goalBookView.show, args.goalBookView));
  381. this.model.bind("change", this.render, this);
  382. this.model.bind("reset", this.render, this);
  383. this.model.bind("remove", this.render, this);
  384. this.model.bind("add", this.render, this);
  385. this.model.bind("completed", this.justFinishedObjective, this);
  386. },
  387. render: function() {
  388. KAConsole.log("rendering GoalSummaryView", this);
  389. var active = this.model.active() || null;
  390. if (active !== null) {
  391. $(this.el).html(this.template(active.attributes));
  392. }
  393. else {
  394. // todo: put create a goal button here?
  395. $(this.el).empty();
  396. }
  397. return this;
  398. },
  399. justFinishedObjective: function(newGoal, newObj) {
  400. this.render();
  401. this.$("#goals-drawer").effect("highlight", {}, 2500);
  402. }
  403. });
  404. function finishLoadingMapsPackage() {
  405. KAConsole.log("Loaded Google Maps.");
  406. dynamicLoadPackage_maps(function(status, progress) {
  407. if (status == "complete") {
  408. KAConsole.log("Loaded maps package.");
  409. } else if (status == "failed") {
  410. KAConsole.log("Failed to load maps package.");
  411. setTimeout(finishLoadingMapsPackage, 5000); // Try again in 5 seconds
  412. } else if (status == "progress") {
  413. KAConsole.log("Maps package " + (progress * 100).toFixed(0) + "% loaded.");
  414. if (newCustomGoalDialog) {
  415. newCustomGoalDialog.$(".progress-bar")
  416. .progressbar("value", progress * 100);
  417. }
  418. }
  419. });
  420. }
  421. var NewGoalView = Backbone.View.extend({
  422. template: Templates.get("shared.goal-new"),
  423. events: {
  424. "click .newgoal.custom": "createCustomGoal",
  425. "click .newgoal.five_exercises": "createExerciseProcessGoal",
  426. "click .newgoal.five_videos": "createVideoProcessGoal"
  427. },
  428. initialize: function() {
  429. this.render();
  430. },
  431. render: function() {
  432. var context = this.model.processGoalContext();
  433. $(this.el).html(this.template(context));
  434. this.hookup();
  435. return this;
  436. },
  437. hookup: function() {
  438. var that = this;
  439. this.$(".newgoal").hoverIntent(
  440. function hfa(evt) {
  441. if ($(this).hasClass("disabled")) {
  442. return;
  443. }
  444. that.$(".newgoal").not(this).not(".disabled").hoverFlow(
  445. evt.type, { opacity: 0.2},
  446. 750, "easeInOutCubic");
  447. $(".info.pos-left", this).hoverFlow(
  448. evt.type, { left: "+=30px", opacity: "show" },
  449. 350, "easeInOutCubic");
  450. $(".info.pos-right, .info.pos-top", this).hoverFlow(
  451. evt.type, { right: "+=30px", opacity: "show" },
  452. 350, "easeInOutCubic");
  453. },
  454. function hfo(evt) {
  455. if ($(this).hasClass("disabled")) {
  456. return;
  457. }
  458. that.$(".newgoal").not(this).not(".disabled").hoverFlow(
  459. evt.type, { opacity: 1}, 175, "easeInOutCubic");
  460. $(".info.pos-left", this).hoverFlow(
  461. evt.type, { left: "-=30px", opacity: "hide" },
  462. 150, "easeInOutCubic");
  463. $(".info.pos-right, .info.pos-top", this).hoverFlow(
  464. evt.type, { right: "-=30px", opacity: "hide" },
  465. 150, "easeInOutCubic");
  466. }
  467. );
  468. },
  469. createVideoProcessGoal: function(e) {
  470. e.preventDefault();
  471. if ($(e.currentTarget).hasClass("disabled")) return;
  472. var goal = new Goal({
  473. title: "Complete Five Videos",
  474. objectives: [
  475. { description: "Any video", type: "GoalObjectiveAnyVideo" },
  476. { description: "Any video", type: "GoalObjectiveAnyVideo" },
  477. { description: "Any video", type: "GoalObjectiveAnyVideo" },
  478. { description: "Any video", type: "GoalObjectiveAnyVideo" },
  479. { description: "Any video", type: "GoalObjectiveAnyVideo" }
  480. ]
  481. });
  482. this.createSimpleGoal(goal);
  483. },
  484. createExerciseProcessGoal: function(e) {
  485. e.preventDefault();
  486. if ($(e.currentTarget).hasClass("disabled")) return;
  487. var goal = new Goal({
  488. title: "Complete Five Exercises",
  489. objectives: [
  490. { description: "Any exercise", type: "GoalObjectiveAnyExerciseProficiency" },
  491. { description: "Any exercise", type: "GoalObjectiveAnyExerciseProficiency" },
  492. { description: "Any exercise", type: "GoalObjectiveAnyExerciseProficiency" },
  493. { description: "Any exercise", type: "GoalObjectiveAnyExerciseProficiency" },
  494. { description: "Any exercise", type: "GoalObjectiveAnyExerciseProficiency" }
  495. ]
  496. });
  497. this.createSimpleGoal(goal);
  498. },
  499. createSimpleGoal: function(goal) {
  500. this.model.add(goal);
  501. goal.save().fail($.proxy(function() {
  502. KAConsole.log("Error while saving new custom goal", goal);
  503. this.model.remove(goal);
  504. }, this));
  505. this.trigger("creating");
  506. },
  507. createCustomGoal: function(e) {
  508. this.trigger("creating");
  509. e.preventDefault();
  510. newCustomGoalDialog.show();
  511. }
  512. });
  513. var NewGoalDialog = Backbone.View.extend({
  514. template: Templates.get("shared.goal-new-dialog"),
  515. initialize: function() {
  516. this.render();
  517. },
  518. render: function() {
  519. var context = this.model.processGoalContext();
  520. // As we're assigning to this.el, event handlers need to be rebound
  521. // after each render.
  522. this.el = $(this.template(context)).appendTo(document.body).get(0);
  523. this.newGoalView = new NewGoalView({
  524. el: this.$(".viewcontents"),
  525. model: this.model
  526. });
  527. this.newGoalView.bind("creating", this.hide, this);
  528. return this;
  529. },
  530. show: function() {
  531. // rerender every time we show this in case some process goals should
  532. // be disabled
  533. this.newGoalView.render();
  534. return $(this.el).modal({
  535. keyboard: true,
  536. backdrop: true,
  537. show: true
  538. });
  539. },
  540. hide: function() {
  541. // hide all hover effects so they don't show up next time we show
  542. this.$(".info").hide();
  543. // now hide the dialog
  544. return $(this.el).modal("hide");
  545. }
  546. });
  547. var NewCustomGoalDialog = Backbone.View.extend({
  548. template: Templates.get("shared.goal-new-custom-dialog"),
  549. loaded: false,
  550. render: function() {
  551. // As we're assigning to this.el, event handlers need to be rebound
  552. // after each render.
  553. this.el = $(this.template()).appendTo(document.body).get(0);
  554. this.innerEl = this.$(".modal-body").get(0);
  555. // turn on fading just before we animate so that dragging is fast
  556. var $el = $(this.el);
  557. $el.bind("shown", function() { $el.removeClass("fade"); });
  558. $el.bind("hide", function() { $el.addClass("fade"); });
  559. return this;
  560. },
  561. _show: function() {
  562. return $(this.el).modal({
  563. keyboard: false,
  564. backdrop: true,
  565. show: true
  566. });
  567. },
  568. show: function() {
  569. if (!this.innerEl) {
  570. this.render();
  571. }
  572. // if we haven't yet loaded the contents of this dialog, do it
  573. if (!this.loaded) {
  574. this.loaded = true;
  575. this.load().error($.proxy(function() {
  576. this.loaded = false;
  577. }));
  578. this.$(".progress-bar").progressbar({value: 10}).slideDown("fast");
  579. }
  580. this._show();
  581. },
  582. load: function() {
  583. if (!dynamicPackageLoader.packageLoaded("maps")) {
  584. $('<script src="http://maps.google.com/maps/api/js?v=3.3&sensor=false&callback=finishLoadingMapsPackage" type="text/javascript"></script>').appendTo(document);
  585. }
  586. return $.ajax({url: "/goals/new", type: "GET", dataType: "html"})
  587. .done($.proxy(function(html) {
  588. KAConsole.log("Loaded /goals/new.");
  589. this.waitForMapsPackage(html);
  590. }, this))
  591. .error($.proxy(function() {
  592. KAConsole.log(Array.prototype.slice.call(arguments));
  593. $(this.innerEl).text("Page load failed. Please try again.");
  594. }, this));
  595. },
  596. hide: function() {
  597. $(this.el).modal("hide");
  598. },
  599. waitForMapsPackage: function(html) {
  600. if (!dynamicPackageLoader.packageLoaded("maps")) {
  601. var that = this;
  602. setTimeout(function() { that.waitForMapsPackage(html); }, 100);
  603. return;
  604. }
  605. KAConsole.log("Done loading.");
  606. $(this.innerEl).html(html);
  607. createGoalInitialize();
  608. }
  609. });
  610. $(function() {
  611. window.GoalBook = new GoalCollection(window.GoalsBootstrap || []);
  612. APIActionResults.register("updateGoals",
  613. $.proxy(GoalBook.incrementalUpdate, window.GoalBook));
  614. window.myGoalBookView = new GoalBookView({
  615. el: "#goals-nav-container",
  616. model: GoalBook
  617. });
  618. window.myGoalSummaryView = new GoalSummaryView({
  619. el: "#goals-container",
  620. model: GoalBook,
  621. goalBookView: myGoalBookView
  622. });
  623. myGoalSummaryView.render();
  624. window.newGoalDialog = new NewGoalDialog({model: GoalBook});
  625. window.newCustomGoalDialog = new NewCustomGoalDialog();
  626. });
  627. // todo: should we do this globally?
  628. Handlebars.registerPartial("goal-objectives", Templates.get("shared.goal-objectives"));
  629. Handlebars.registerPartial("goalbook-row", Templates.get("shared.goalbook-row"));
  630. Handlebars.registerPartial("goal-new", Templates.get("shared.goal-new"));