app/assets/javascripts/hitme.js

Summary

Maintainability
D
2 days
Test Coverage
// returns a unique ID to be used to identify an element within the
// webpage. Depends on the current state of the page, i.e. if we already
// are in ask-skipped-questions-again mode.
function getUniqId(q, a) {
  var qid = (typeof q === 'object' ? q.id : q);
  if(qid === undefined || qid === null) {
    throwWithAlert('invalid question id', "question=" + q + "  qid=" + qid);
  }
  var id = "blockQuest" + qid;

  if(!window.currentHitme.nagAboutSkippedQuestions) id += 'repeat';

  if(a) {
    id += "_answer" + (typeof a === 'object' ? a.id : a);
  }
  return id;
}

function isAnswerSelectionCorrect(answers) {
  var correct = true;
  $.each(answers, function(ind, answ) {
    answ = $(answ);
    // answer correct, but not checked
    if(answ.data('correct') === 1 && !answ.hasClass("active")) correct = false;
    // answer wrong, but checked
    if(answ.data('correct') === 0 && answ.hasClass("active")) correct = false;
    if(!correct) return false; // i.e. break
  });
  return correct;
}

function checkForQuestionPreview() {
  var singleQuestion = getHash("question");
  if(singleQuestion === undefined || !singleQuestion) return;

  var h = new H.Hitme();
  window.currentHitme = h;
  h.setupSingleQuestionMode();
}

function validateNumberOnly(self) {
  self = $(self);
  var val = parseInt(self.val().replace(/[^0-9]/g, "") || 10);
  var max = parseInt(self.attr('max') || 999999);
  var min = parseInt(self.attr('min') ||-999999);
  self.val(Math.max(min, Math.min(max, val)));
}

function parseMatrix(orig) {
  var s = $.trim(orig);
  var rows = s.split(/[\r\n]+/);
  rows = $.map(rows, function(r) {
    return $.trim(r).split(/\s+/).join(" ");
  });
  return rows.join("  ");
}

function showNextHint(elm) {
  var hidden = $(elm).siblings(":hidden");
  hidden.first().animate(CONST.showAnimation);
  if(hidden.length === 1) $(elm).animate(CONST.hideAnimation);
}

function renderStarred(question) {
  c = "";
  c += '<div class="star">';

  if(window.loggedIn) {
    c += question.starred ? '&#9733; ' : '';
    c += '<a onclick="handleStarredClick(this)" '
    c += 'data-id="'+question.id+'" ';
    c += 'data-starred="'+(question.starred ? 1 : 0)+'">Frage ';
    c += question.starred ? 'gemerkt' : 'merken';
    c += '</a>';
    c += '&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;';
  }

  var xx = Routes.perma_question_path(question.id);
  c += '⚓  <a href="'+xx+'" target="_blank">Link zur Frage</a>';

  c += '</div>';

  return c;
}

function handleStarredClick(link) {
  var l = $(link);
  var s = l.data('starred');
  var id = l.data('id');
  var r = s ? Routes.unstar_question_path(id) : Routes.star_question_path(id);

  $.ajax({
    url: r,
  }).done(function(data) {
    var fakeQ = {id: id, starred: data};
    l.parent().replaceWith(renderStarred(fakeQ));
  }).fail(function() {
    l.text('irgendwas ist kaputt…').addClass('disable');
  });
}

function getQuestionById(id) {
  var quest = null;
  $.each(window.currentHitme.questions, function(ind, q) {
    if(q.id === id) {
      quest = q;
      return false;
    }
  });
  return quest;
}




function ensureValidDifficultySelection() {
  var any = false;
  $('input[type=checkbox][name^=difficulty]').each(function() {
    if($(this).is('checked')) {
      any = true;
      return false;
    }
  });
  if(!any) $('input[type=checkbox][name^=difficulty]:first').prop('checked', true);
}


function disableOptions()  {
  $('#options input').attr("disabled", "disabled");
  $('#options').animate(CONST.hideAnimation);
}

function enableOptions() {
  $('#options input').removeAttr("disabled");
}


function hideCategories() {
  $('h3 + .toggle:visible').each(function(i, cat) {
    $(cat).prev().click();
  });
  var s = $('#categories a.button, #start-button');
  disableLinks(s);
}

function getDifficulties() {
  var diff = [];
  $('input[type=checkbox][name^=difficulty]').each(function() {
    if(this.checked) {
      diff.push($(this).val());
    }
  });
  return diff.join("_");
}

function showAllCategories() {
  $('#categories .toggle').animate(CONST.showAnimation);
  var s = $('a.disable, #start-button');
  enableLinks(s);
}

function animateVisibilityHiddenShow(elms) {
  elms.css('visibility','visible').hide().fadeIn('slow');
}

function animateDisplayNoneShow(elms) {
  elms.css('display','inline');
}

function disableLinks(selector) {
  $(selector).each(function(ind, s) {
    s = $(s);
    s.addClass('disable')
      .data('oldonclick', s.attr('onclick'))
      .attr('onclick', '');
  });
}

function enableLinks(selector) {
  $(selector).each(function(ind, s) {
    s = $(s);
    s.removeClass('disable').attr('onclick', s.data('oldonclick'));
  });
}

function getURLForRootQuestions(categoryIds) {
  var diff = this.getDifficulties();
  var count = $('#quantity').val();
  var studyPath = $('#study_path').val();

  var h = {categories: categoryIds.join("_"), count: count, difficulty: diff, study_path: studyPath}

  return Routes.main_question_path(h);
}

function getRootQuestions(categoryIds, context, successCallback) {
  // example: http://0.0.0.0:3000/main/questions?categories=6_8&count=10&difficulty=10_20_30&study_path=3
  $.ajax({
    url: getURLForRootQuestions(categoryIds),
  }).done(function(data) {
    successCallback(context, data);
  }).fail(function() {
    alert("Die Anfrage konnte leider nicht bearbeitet werden. Möglicherweise hat der Server ein Problem.");
  });
}

function getSingleQuestion(questionId, context, successCallback) {
  $.ajax({
    url: Routes.main_single_question_path({id: questionId}),
  }).done(function(data) {
    successCallback(context, data);
  }).fail(function() {
    alert("Die Anfrage konnte leider nicht bearbeitet werden. Möglicherweise hat der Server ein Problem.");
  });
}

function getMultipleQuestion(questionIds, context, successCallback) {
  $.ajax({
    url: Routes.main_multiple_question_path({id: questionIds}),
  }).done(function(data) {
    successCallback(context, data);
  }).fail(function() {
    alert("Die Anfrage konnte leider nicht bearbeitet werden. Möglicherweise hat der Server ein Problem.");
  });
}

function answersGivenCount() {
  var a = window.H.answersGiven;
  return a.fail.length + a.correct.length + a.skip.length;
}

// sees if there’s a subquestion for the current answer. If there is,
// and the user has activated subquestions it will be inserted next.
// Returns true if a subquestion has been inserted.
function maybeInsertSubquestion(aid) {
  if(!$('#subquestions').is(':checked')) {
    // user doesn’t want subquestions, skip
    return false;
  }

  var q = window.currentQuestion;
  var s;
  $.each(q.answers, function(ind, answ) {
    if(answ.id === parseInt(aid)) {
      s = answ.subquestion;
      return false;
    }
  });
  // this answer doesn’t have a subquestion
  if(!s) return false;
  var c = window.currentHitme;
  var p = c.questPositionPointer;
  c.questions.splice(p+1, 0, s);
  return true;
}

H = {};
window.H = H;

window.currentHitme = null;

Array.prototype.diff = function(a) {
    return this.filter(function(i) {return !(a.indexOf(i) > -1);});
};


window.CONST = {
  hideAnimation: {opacity: 'hide', height: 'hide', marginTop: 'hide',
    marginBottom: 'hide', paddingTop: 'hide', paddingBottom: 'hide'},
  showAnimation: {opacity: 'show', height: 'show', marginTop: 'show',
    marginBottom: 'show', paddingTop: 'show', paddingBottom: 'show'},
  stayAtBottom: { duration: 'slow',
    step: function(now, fx) {
      var absTop = $(this).offset().top;
      var relTop = absTop - $(window).scrollTop();
      // do not scroll past the top of the element.
      // 50 pixels leave some space for the border
      if(relTop < 50) return;

      $('html, body').scrollTop(99999999);
    }
  }
}


// constructor
H.Hitme = function() {
  // no selection? GONNA SELECT 'EM ALL!
  if($('.inline-chooser .active').length === 0)
    $('.inline-chooser .toggleable').addClass('active');

  this.cats = $('.inline-chooser .active').map(function(i, m) { return $(m).data("id"); }).get();

  this._this = this;
  disableOptions();
  ensureValidDifficultySelection();
  hideCategories();
};

// members
H.Hitme.prototype = {
  setupCategoryQuestionMode: function() {
    getRootQuestions(this.cats, this, this.rootQuestionsAvailable);
    this.questPositionPointer = -1;
    this.nagAboutSkippedQuestions = true;
    this.skippedQuestionsData = [];
    this.answersGiven = {correct: [], fail: [], skip: []};
  },

  setupSingleQuestionMode: function() {
    var question = getHash("question");
    var ids = question.split(",");
    var length = ids.length;

    $("#quantity").val(length);
    if (length == 1) {
      getSingleQuestion(question, this, this.rootQuestionsAvailable);
    } else {
      getMultipleQuestion(question, this, this.rootQuestionsAvailable);
    }

    this.questPositionPointer = -1;
    this.nagAboutSkippedQuestions = false;
    this.skippedQuestionsData = [];
    this.answersGiven = {correct: [], fail: [], skip: []};
  },

  giveMore: function() {
    $('.hideMeOnMore').remove();
    H.hitme(this.cat);
  },

  rootQuestionsAvailable: function(_this, data) {
    _this.questions = data;
    _this.showNext();
  },

  _renderAnswersForQuestion: function(quest) {
    var s = "";
    $.each(shuffle(quest.answers), function(ind, a) {
      s += '<div>'
      s += '<a class="button toggleable" id="'+getUniqId(quest, a)+'"';
      s += ' onclick="$(this).toggleClass(\'active\');"';
      s += ' data-correct="'+a.correct+'" data-qid="'+quest.id+'"';
      s += ' data-aid="'+a.id+'"><div class="tex">'+a.html+'</div></a>';
      s += '<span>'+answer_correctness[a.correct]+'</span>';
      s += '<span>(das hattest Du angekreuzt)</span>';
      s += '</div>';
    });
    return s;
  },

  _handleQuestionSubmit: function() {
    // gather details
    //~ var question = getQuestionById($(this).data('qid'));
    var quest = window.currentQuestion;
    var answerChooser = $(this).parent().siblings(".answer-chooser, .answer-chooser-matrix").first();
    var action = $(this).data('action');
    var boxSelector = '#' + getUniqId(window.currentQuestion);
    var s = window.stat_reporting;

    // disable ui
    $(this).parent().children("a").addClass("disable");
    answerChooser.find("a").addClass("disable").removeAttr("onclick");
    answerChooser.find("textarea").attr('disabled', 'disabled');

    // handle action
    if(action === 'skip') {
      window.currentHitme.skippedQuestionsData.push(quest);
      window.currentHitme.answersGiven.skip.push(boxSelector);
      $(this).parent().append("<span>Du hast diese Frage übersprungen</span>");
      s.skipped();

    } else if(action === 'save') {
      $(boxSelector).addClass('reveal');
      var correct;

      if(quest.matrix) {
        var m = parseMatrix(answerChooser.find("textarea").val());
        var solution = window.currentQuestion.matrix_solution;
        correct = solution.toLowerCase() === m.toLowerCase();
        s.set_answers([m]);

      } else {
        var answ = answerChooser.find("a");
        correct = isAnswerSelectionCorrect(answ);


        // insert subquestions for selected answers if any
        var selAnsw = answerChooser.find("a.active");
        $.each(selAnsw, function(ind, answ) {
          maybeInsertSubquestion($(answ).data('aid'));
        });

        // show which questions were selected by the user
        $.each(selAnsw, function(ind, answ) {
          animateVisibilityHiddenShow($(answ).siblings().last());
        });

        var selAnswIds = $.map(selAnsw, function(answ, ind) {
          return $(answ).data('aid');
        });

        s.set_answers(selAnswIds);
      }


      if(correct) {
        //~ console.log("q" + quest.id + " answered correctly");
        window.currentHitme.answersGiven.correct.push(boxSelector);
        s.success();
      } else {
        //~ console.log("q" + quest.id + " answered incorrectly");
        window.currentHitme.answersGiven.fail.push(boxSelector);
        s.failed();
      }


    } else {
      throwWithAlert('Unsupported action. This is a coding error.');
    }

    window.currentHitme.showNext();
  },

  _showNextQuestion: function() {
    this.questPositionPointer++;
    var q = window.currentQuestion = this.questions[this.questPositionPointer];
    var code = '<div style="display:none" id="'+getUniqId(q.id)+'" class="box hideMeOnMore">'; // outer box

    code += '<h3 class="count">Frage</h3>';

    if (q.video) { // inner box
      code += '<div style="float:left;width: 70%;" >';
    } else {
      code += '<div>';
    }
    
    code += '<div class="tex">'+q.html+'</div>'
    code += '<br/>';

    if(q.hints && q.hints.length >= 1) {
      code += '<div>'
      $.each(q.hints, function(ind, hint) {
        code += '<div style="display: none;margin: 5px 0">'+hint+'</div>';
      });
      code += '<a onclick="showNextHint(this);">Hinweis anzeigen</a>';
      code += '</div>';
    }

    code += renderStarred(q);


    var cls;

    code += '<div class="answer-chooser'+(q.matrix?"-matrix":"")+'">'
    if(q.matrix) {
      var a = q.answers[0];
      code += 'Trage unten die Lösung ein. Matrizen schreibst Du einfach mittels Leerzeichen und Zeilenumbrüchen. Die Anzahl der Leerzeichen ist dabei egal.<br/><br/>';
      code += '<div style="float:left;width: 45%; overflow-y: show; overflow-x: hidden;">';
      code += '<label for="'+getUniqId(q, a)+'">Deine Lösung</label><br class="clear"/>';
      code += '<textarea id="'+getUniqId(q, a)+'" class="matrixmode"></textarea>';
      code += '<div class="tex previewer" id="'+getUniqId(q, a)+'previewer"></div>';
      code += '<br/>';
      code += '</div>';
      code += '<div style="float:right;width: 45%" class="initiallyHidden">';
      code += '<strong>Unsere Lösung</strong><br class="clear"/>';
      code += '<div class="tex">'+a.html+'</div></div>';
      code += '<br class="clear"/>';
    } else {
      code += this._renderAnswersForQuestion(q);
    }

    code += '</div>'; // answer-chooser

    code += '<br/><div class="answer-submit button-group">';
    code += '<a class="button big" data-qid="'+q.id+'" title="Günther Jauch: Sind Sie sich wirklich sicher?" data-action="save">Antwort übernehmen</a>';
    code += '<a class="button big" data-qid="'+q.id+'" data-action="skip">Frage überspringen</a>';
    code += '</div>';

    code += '</div>'; // inner box

    if (q.video) {
      code += '<div style="float:right;width: 30%" class="displayHidden">' + q.video + '</div><br class="clear"/>';
    }

    code += '</div>'; // outer box

    $(code).appendTo('body');
    $('.answer-submit:last').one('click', 'a', this._handleQuestionSubmit);
    // render math first, then expand
    var render = function() {
      if(window.animationDisabled) {
        var elm = $("#"+getUniqId(q.id));
        elm.show();
        $("html, body").scrollTop(elm.offset().top);
      } else {
        $("#"+getUniqId(q.id)).animate(CONST.showAnimation, CONST.stayAtBottom);
      }
    }
    MathJax.Hub.Queue(["Typeset",MathJax.Hub, render]);

    // add preview for matrix questions
    if(q.matrix) {
      window.matrixModePreview = null;
      var textarea = $('#'+getUniqId(q, a));
      var previewer = $('#'+getUniqId(q, a)+'previewer');
      textarea.keyup(function() {
        if(window.matrixModePreview) clearTimeout(window.matrixModePreview);
        window.matrixModePreview = setTimeout(function() {
          var v = parseMatrix(textarea.val());
          if(v === "") return previewer.html("");
          v = shortMatrixStrToTeX(v);
          previewer.html(v);
          MathJax.Hub.Queue(["Typeset",MathJax.Hub]);
        }, 100);
      });
    }

    if($('#comiccheckbox').is(':checked')) {
      setTimeout("XkcdLoader.preload()", 100);
    }

    // start recording
    window.stat_reporting = new StatReporting().record(q.id);
  },

  _reshowSkippedQuestions: function() {
    var w = window.currentHitme;
    w.answersGiven.skip = [];
    w.questions = $.extend(true, [], w.skippedQuestionsData); // deep copy
    w.questPositionPointer = -1;
    w.showNext();
  },

  _showSkippedQuestionsNagDialog: function() {
    // show only once
    if(!this.nagAboutSkippedQuestions) return false;
    this.nagAboutSkippedQuestions = false;

    if(this.answersGiven.skip.length === 0) return false;

    var code = '<div style="display:none;" class="box hideMeOnMore reshowskipped">'
      + '<h3>Übersprungene Fragen</h3>'
      + '<p>Du hast ' + this.answersGiven.skip.length + ' Frage(n) übersprungen. Sollen sie nochmal angezeigt werden, oder möchtest Du abschließen?</p>'
      + '<div class="button-group">'
      + '<a onclick="window.currentHitme._showFinishDialog(); disableLinks(\'.reshowskipped a\');" class="button big">Block abschließen</a>'
      + '<a onclick="window.currentHitme._reshowSkippedQuestions(); disableLinks(\'.reshowskipped a\');" class="button big">nochmal vorlegen</a>'
      + '</div>';

    $(code).appendTo('body').animate(CONST.showAnimation, CONST.stayAtBottom);
    return true;
  },

  _showFinishDialog: function() {
    if(this._showSkippedQuestionsNagDialog()) return;

    var sum = this.answersGiven.correct.length + this.answersGiven.fail.length;

    var code = '<div style="display:none;" class="box hideMeOnMore">'
      + '<h3>Fertig!</h3>'
      + '<p>Du hast den aktuellen Block abgeschlossen. Insgesamt hast Du '+sum+' Fragen beantwortet und davon '+this.answersGiven.correct.length+' richtig. Scrolle nach oben um jeweils die Antworten für die Fragen zu sehen.</p>'
      + '<div class="button-group">'
      + '<a onclick="window.currentHitme.giveMore();" class="button big">Gib mir nochmal '+$('#quantity').val()+'!</a>'
      + '<a href="'+Routes.main_hitme_path()+'" class="button big">Einstellungen ändern</a>'
      + '</div>';

    if($('#comiccheckbox').is(':checked')) {
      code = code + XkcdLoader.retrieveWithText();
    }

    $(code).appendTo('body').animate(CONST.showAnimation, CONST.stayAtBottom);

    animateVisibilityHiddenShow($('.reveal .answer-chooser > div > span:nth-child(2), .reveal .initiallyHidden .displayHidden'));
    animateDisplayNoneShow($('.displayHidden'));

    var allCorr = this.answersGiven.correct.diff(this.answersGiven.fail).join(',');
    var anyFail = this.answersGiven.fail.join(',');
    $(allCorr).addClass('correct');
    $(anyFail).addClass('wrong');
  },

  showNext: function() {
    if(this.questions.length === 0) {
      alert("Keine Fragen für die aktuellen Einstellungen gefunden.\n\nVersuche es mit anderen Einstellungen nochmal.");
      enableOptions();
      showAllCategories();
    // no more available questions?
    } else if(this.questions.length-1 === this.questPositionPointer) {
      this._showFinishDialog();
    // reached user defined question limit
    } else if(answersGivenCount >= parseInt($('#quantity').val())) {
      this._showFinishDialog();
    } else {
      this._showNextQuestion();
    }
  },

  skipCurrentQuestion: function(self) {
    $(self).animate(CONST.hideAnimation);
    this.showNext();
  }
}


// convenience generator
H.hitme = function() {
  var h = new H.Hitme();
  window.currentHitme = h;
  h.setupCategoryQuestionMode();
  return h;
}


$(document).ready(function(){
  if(isMobileBrowser) {
    $("#quantity").attr("min", "1").attr("value", "2");
  }
});