Source: observer.js

/*
 * Copyright (c) 2016, Jarmo Juujärvi, Sami Kallio, Kai Korhonen, Juha Moisio, Ilari Paananen
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 *     1. Redistributions of source code must retain the above copyright
 *       notice, this list of conditions and the following disclaimer.
 *
 *     2. Redistributions in binary form must reproduce the above copyright
 *       notice, this list of conditions and the following disclaimer in the
 *       documentation and/or other materials provided with the distribution.
 *
 *     3. Neither the name of the copyright holder nor the names of its
 *       contributors may be used to endorse or promote products derived
 *       from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

/**
 * @fileOverview JavaScript logic for observation view.
 * @module observer
 * @author Ilari Paananen <ilari.k.paananen at student.jyu.fi>
 */

//
// TODO:
// - Remove/comment out console.log calls in release?
//

// NOTE: Functions in observer/index.xhtml.
var CategoryType = getCategoryTypes();
var msg = getMessages();


/**
 * The observation clock that can be paused and resumed.
 * The individual categories get their time from the observation clock.
 * @constructor
 */
function Clock() {
    this.total_time = 0;
    this.resume_time = 0;
    this.running = false;
    
    /**
     * Resumes the observation clock.
     * @param {number} now The time in milliseconds when the observation clock was resumed.
     */
    this.resume = function(now) {
        if (!this.running) {
            this.resume_time = now;
            this.running = true;
        } else {
            console.log("Clock.resume(): Clock is already running!");
        }
    };
    
    /**
     * Pauses the observation clock.
     * @param {number} now The time in milliseconds when the observation clock was paused.
     */
    this.pause = function(now) {
        if (this.running) {
            var delta_time = now - this.resume_time;
            this.total_time += delta_time;
            this.running = false;
        } else {
            console.log("Clock.pause(): Clock is already paused!");
        }
    };
    
    /**
     * Returns the total time the observation clock has been running in milliseconds.
     * @param {number} now The time in milliseconds when the elapsed time was wanted.
     */
    this.getElapsedTime = function(now) {
        if (this.running) {
            return this.total_time + (now - this.resume_time);
        } else {
            return this.total_time;
        }
    };
    
    /**
     * Returns true if the observation clock is paused, otherwise false.
     */
    this.isPaused = function() {
        return !this.running;
    };
}


/**
 * Converts milliseconds to a string representing time.
 * The time format is hh:mm:ss if the given time is at
 * least one hour and mm:ss otherwise.
 * @param {number} ms The time in milliseconds.
 */
function timeToString(ms) {
    var t = Math.floor(ms / 1000);
    var s = t % 60;
    var m = Math.floor(t / 60) % 60;
    var h = Math.floor(t / 60 / 60) % 60;
    var str = (m < 10 ? "0" + m : m) + ":" + (s < 10 ? "0" + s : s);
    if (h > 0) {
        str = (h < 10 ? "0" + h : h) + ":" + str;
    }
    return str;
}


/**
 * Returns the given count as a string with abbreviation, e.g. "13 ct.".
 * @param {number} count The count to make the string from.
 */
function countToString(count) {
    return count + " " + msg.countAbbreviation;
}


/**
 * The class acts as a category button. It creates the HTML
 * elements it needs and responds to the click events it gets.
 * @constructor
 * @param {String} name The name to be displayed on the button.
 * @param {number} type The type of the category (TIMED or COUNTED).
 * @param {number} index The index of the category button.
 * @returns {CategoryItem}
 */
function CategoryItem(name, type, id, index) {
    this.li = $(document.createElement("li"));
    this.li.addClass("category-item");
    this.li.attr("id", "category-item_" + index);
    this.value_div = $(document.createElement("div"));
    this.value_div.addClass("category-value");
    this.name_div = $(document.createElement("div"));
    this.name_div.addClass("category-name");
    this.name_div.append(document.createTextNode(name));
    this.li.append(this.value_div);
    this.li.append(this.name_div);
    
    this.type = type;
    this.id = id;
    
    // Used if type is COUNTED.
    this.count = 0;
    
    // Used if type is TIMED.
    this.time = 0;
    this.start_time = 0;
    this.down = false;
    
    
    if (this.type === CategoryType.TIMED)
        initTimedCategory(this);
    else
        initCountedCategory(this);
    
    
    /**
     * The private method replaces the contents of the HTML element
     * that displays the value of the category button.
     * @param {CategoryItem} this_ The category button.
     * @param {String} text The text to replace the contents of the element with.
     */
    function updateValueDiv(this_, text) {
        this_.value_div.empty();
        this_.value_div.append(document.createTextNode(text));
    }
    
    /**
     * The private method initializes the category button to behave
     * as a time interval category.
     * @param {CategoryItem} this_ The category button.
     */
    function initTimedCategory(this_) {
        updateValueDiv(this_, timeToString(0));
        
	/*
         * Click handler for timed category item.
         */
        this_.click = function(master_time) {
            var record;

            if (this.down) {
                this.li.removeClass("down");
                if (master_time > this.start_time) {
                    this.time += master_time - this.start_time;
                    record = {id: this.id, startTime: this.start_time, endTime: master_time};
                }
                this.down = false;
            } else {
                this.li.addClass("down");
                this.start_time = master_time;
                this.down = true;
            }

            return record;
        };
    	
	/*
         * Updates category item's timer div if the category type is timed.
         */
        this_.updateTimer = function(master_time) {
            var time = this.time;
            if (this.down) {
                time += master_time - this.start_time;
            }
            updateValueDiv(this, timeToString(time));
        };
    }
    
    /**
     * The private method initializes the category button to behave
     * as a category that counts the click events it gets.
     * @param {CategoryItem} this_ The category button.
     */
    function initCountedCategory(this_) {
        updateValueDiv(this_, countToString(0));

        /*
         * Click handler for counted category item.
         */
        this_.click = function(master_time, paused) {
            if (paused) return;
            
            this.count += 1;
            updateValueDiv(this, countToString(this.count));
            
            this.li.addClass("down");
            var item = this.li;
            setTimeout(function() { item.removeClass("down"); }, 50);
            
            return {id: this.id, startTime: master_time, endTime: master_time};
        };

        /*
         * Does nothing because the category type is counted.
         */
        this_.updateTimer = function() { };
    }
}


/**
 * The class does the actual observation. It keeps the records
 * made during the observation and sends them to the backend after
 * the observation is stopped.
 * @constructor
 * @param category_sets The array of the category sets to be used in the observation.
 * @returns {Observer} Constructed observer.
 */
function Observer(category_sets) {
    this.master_clock = new Clock();
    this.categories = [];
    this.records = [];
    this.started = false;
    this.waiting = false;
    
    initialize(this);
    
    /**
     * The private method initializes the observer. It creates
     * the category buttons and adds them to the HTML element tree.
     * @param {Observer} this_ The observer to be initialized.
     */
    function initialize(this_) {
        $("#continue").hide();
        $("#pause").hide();
        $("#stop").hide();
        $("#total-time").append(document.createTextNode(timeToString(0)));
        
        var category_list = $("#category-list");
        
        var index = 0;
        
        for (var i = 0; i < category_sets.length; i++) {
            
            var set = category_sets[i];
            
            if (set.categories.length > 0) {
                var category_set = $(document.createElement("ul"));
                category_set.attr("id", set.name);
                category_set.addClass("category-set");
                
                for (var j = 0; j < set.categories.length; j++) {
                    var cat = set.categories[j];
                    var category = new CategoryItem(cat.name, cat.type, cat.id, index);
                    this_.categories.push(category);
                    category_set.append(category.li);
                    
                    index += 1;
                }
                
                category_list.append(category_set);
            }
        }
        
        $(".category-item").addClass("disabled");
    }
    
    /**
     * The private method adds the record to the records list if it's not undefined.
     * The method is used by categoryClick() and stopClick().
     * @param record The record or undefined if there is nothing to be added.
     */
    function addRecord(this_, record) {
        if (record !== undefined) {
            this_.records.push(record);
        }
    }
    
    /**
     * The event handler starts the observation. It sends an AJAX notification
     * to the backend when the observation is started.
     */
    this.startClick = function() {
        if (this.waiting) return;
        this.waiting = true;

        var this_ = this;

        $.ajax({
            url: "../../webapi/records/startobservation",
            type: "POST",
            dataType: "text",
            contentType: "text/plain",
            cache: false,
            data: "start observation",
            success: function(data) {
                this_.master_clock.resume(Date.now());
                this_.started = true;
                this_.waiting = false;
                var start_button = $("#start");
                start_button.off("click");
                start_button.hide();
                $("#pause").show();
                $("#stop-disabled").hide();
                $("#stop").show();
                $(".category-item").removeClass("disabled");
            },
            error: function(xhr, status, error) {
                showError(msg.obs_errorCouldntSendStart + " " + error);
                this_.waiting = false;
            }
        });
    };
    
    /**
     * The event handler continues the observation.
     */
    this.continueClick = function () {
        if (this.master_clock.isPaused()) {
            this.master_clock.resume(Date.now());
            $("#continue").hide();
            $("#pause").show();
        }
    };
    
    /**
     * The event handler pauses the observation.
     */
    this.pauseClick = function() {
        if (!this.master_clock.isPaused()) {
            this.master_clock.pause(Date.now());
            $("#pause").hide();
            $("#continue").show();
        }
    };
    
    /**
     * The event handler stops the observation.
     * It disables the continue, pause and category buttons.
     * If some categories were still on, it stops them
     * and creates records accordingly. It sends the recorded
     * information to the backend with AJAX and
     * redirects the user to the summary page (on success).
     */
    this.stopClick = function() {
        if (!this.started || this.waiting) return;
        this.waiting = true;
        
        var now = Date.now();
        
        if (!this.master_clock.isPaused()) {
            this.master_clock.pause(now);
        }
        
        var continue_button = $("#continue");
        var pause_button = $("#pause");
        continue_button.off("click");
        continue_button.addClass("disabled");
        pause_button.off("click");
        pause_button.addClass("disabled");
        
        var time = this.master_clock.getElapsedTime(now);
        
        for (var i = 0; i < this.categories.length; i++) {
            var category = this.categories[i];
            category.li.off("click");
            category.li.addClass("disabled");
            if (category.down) {
                addRecord(this, category.click(time));
            }
        }
        
        var this_ = this;
        
        $.ajax({
            url: "../../webapi/records/addobservationdata",
            type: "POST",
            dataType: "text",
            contentType: "application/json",
            cache: false,
            data: JSON.stringify({
                duration: time,
                timeZoneOffsetInMs: getTimeZoneOffset(),
                daylightSavingInMs: getDaylightSaving(),
                data: this.records
            }),
            success: function(data) {
                this_.waiting = false;
                // TODO: Redirect properly.
                window.location = "../summary/";
            },
            error: function(xhr, status, error) {
                showError(msg.obs_errorCouldntSendData + ": " + error);
                this_.waiting = false;
            }
        });
    };
    
    /**
     * Delegates the click of a category button to the correct category.
     * It adds the (possible) record returned by the category to the
     * list of all the records made during the observation.
     * @param {number} index The index of the category button that was clicked.
     */
    this.categoryClick = function(index) {
        var category = this.categories[index];
        var time = this.master_clock.getElapsedTime(Date.now());
        addRecord(this, category.click(time, this.master_clock.isPaused()));
    };
    
    /**
     * Updates the observation clock and all the categories based on it.
     */
    this.tick = function() {
        var time = this.master_clock.getElapsedTime(Date.now());
        
        var time_str = timeToString(time);
        var total_time = $("#total-time");
        total_time.empty();
        total_time.append(document.createTextNode(time_str));
        
        for (var i = 0; i < this.categories.length; i++) {
            this.categories[i].updateTimer(time);
        }
    };
}


/**
 * Sends an AJAX keep-alive signal to the backend.
 */
function keepAlive() {
    $.ajax({
        url: "../../webapi/records/keepalive",
        type: "POST",
        dataType: "text",
        contentType: "text/plain",
        cache: false,
        data: "keep-alive",
        success: function(data) {
            //console.log("Success: " + data);
        },
        error: function(xhr, status, error) {
            showError(msg.obs_errorKeepAliveFailed + ": " + error);
        }
    });
}


/**
 * Shows an error message in a PrimeFaces growl.
 * @param {String} error_msg The error message to be shown.
 */
function showError(error_msg) {
    var growl = PF("growlWdgt");
    growl.removeAll();
    growl.renderMessage({
        summary: msg.dialogErrorTitle,
        detail: error_msg,
        severity: "error"
    });
}


/**
 * The function will call observer.stop().
 * It is needed if the stopping of the observation has to be confirmed.
 */
var stopObservation = function () {};


/**
 * This function is ran when the document is ready.
 * Creates observer, binds event handlers, and sets two intervals:
 * one that updates the observer and one that sends keep alive to backend.
 */
$(document).ready(function() {
    var category_sets = getCategorySets(); // NOTE: Function in observer/index.xhtml.
    var observer = new Observer(category_sets);
    
    $("#start").click(function() {
        observer.startClick();
        $(".category-item").click(function() {
            var id = $(this).attr("id");
            var index = parseInt(id.split("_")[1]);
            observer.categoryClick(index);
        });
    });
    $("#continue").click(function() { observer.continueClick(); });
    $("#pause").click(function() { observer.pauseClick(); });
    stopObservation = function() { observer.stopClick(); };

    setInterval(function() { observer.tick(); }, 200);
    setInterval(keepAlive, 5*60000); // Send keep-alive every 5 minutes.
});

/**
 * Gets the offset of the time zone in milliseconds (in JAVA format).
 */
function getTimeZoneOffset(){
    return -1 * 60 * 1000 * new Date().getTimezoneOffset();
}

/**
 * Gets the daylight saving time offset in milliseconds.
 */
function getDaylightSaving() {
    var now = new Date();
    var jan = new Date(now.getFullYear(), 0, 1);
    return (jan.getTimezoneOffset() - now.getTimezoneOffset()) * 60 * 1000;
}