MbtPlanner.js


/**
 * @module MbtPlanner
 * @author thierry.schmit@gmail.com
 * @copyright Cabinet Camus Lebkiri (2019)
 * @license MIT
 * 
 * @overview dependencies:
 *    - required
 *        - jquery
 *    - optional
 *        - moment https://github.com/moment/moment/ 
 *        
 *  [samples](http://planner.madbuildertools.com/)
 */

(function (exports) {
    "use strict";

    var _endOfTheWorld = new Date("2500-01-01");

    function isPromise(p) {
        return !!p && typeof p.then === 'function'
    }

    function ContextMenu(contId, menu, openCallback) {
        if (contId.startsWith("#"))
            contId = contId.substring(1);

        let cont = document.getElementById(contId);
        if (cont === null) {
            let body = document.getElementsByTagName("body")[0];
            cont = document.createElement("div");
            cont.id = contId
            body.appendChild(cont);
        }

        if (!$(cont).hasClass("contextMenu")) {
            $(cont).addClass("contextMenu")
        }

        $("body").on("click", function () {
            $(cont).css('display', 'none');
        });



        return function (evt) {
            var ul = $("<ul>");
            $(cont).html('');
            menu.forEach((m) => {
                if (m.visible) {
                    var li = $("<li>" + m["title"] + "</li>");
                    li.on("click", async function (evt) {                        
                        let r = m["action"](m, evt);
                        if (isPromise(r))
                            await r;
                        $(contId).css('display', 'none');
                    });
                    ul.append(li);
                }
            });
            $(cont).append(ul);
                        
            //if (openCallback) openCallback(data || evt, index);

            $(cont)
                .css('left', (evt.pageX - 2) + 'px')
                .css('top', (evt.pageY - 2) + 'px')
                .css('display', 'block');

            evt.preventDefault();
            evt.stopPropagation();
        };
    }

    /**
     * @constructor
     * @param {any} id Unique identifier of a DOM element that will be used as placeholder for the planner
     */
    var MbtPlanner = function (id) {
        if (window.jQuery === undefined) {
            throw "jQuery required for MbtPlanner";
        }

        if (!id.startsWith("#"))
            id = "#" + id;
        this.Id = id;
        this.ResetPlanner();
        /** 
         *  @property {array} Resources an array of 
         *  
         *  {
         *      Id: id,
         *      OutOfWorkDays: [], string XXXX-MM-DD
         *      ClosedIsoDays: [], int between 0 and 6. 7 is equivalent to 0 (sunday)
         *  }
         *  
         */
        this.Resources = [];
        this.ClosedIsoDays = [6, 7];
        this.OutOfWorkDays = [];
        this.ProgressStep = 25;
        this.DefaultLoads = [];
        this.FirstIsoDayOfWeek = 1;  
        this.TodayChar = "+";
        /**
         * @property {function} CustomFormatter function that returns a string from a Task object. Default:
         * 
         *     (task) => task.IRef + ": " + task.Code
         * */
        this.CustomFormatter = null;
        /**
         * @property {function} CustomResourceFooter function that returns a string from a Resource object, and load aggregated value. Default:
         * 
         *     (r, load) => r.Id.toString() + ": " + load.toFixed(2)
         * */
        this.CustomResourceFooter = null;
        //gestion d'erreur d'arrondie, on ne planifie pas cette fraction de jour:
        this.Resolution = 16;

        this.fontSize = 12;
        this.dayWidth = 15; 
        //css inline line-height for the component
        this.dayHeight = 15;

        //for the svg experimental version
        this.dayWGutter = 2;
        this.dayHGutter = 2;

        /**
         * @property {function} Save function that saves modified task completions. Default:
         * 
         *     null
         * */
        this.Save = null;
    };

    /**
     * 
     * @param {function} save a function taking the result of this.GetDeltas() as parameter. Awaited id save is async.
     * @see {@link GetDeltas}
     */
    MbtPlanner.prototype.SetSave = function (save) {
        if (this.Save == null && save != null) {
            $(this.Id).on("contextmenu", ContextMenu(this.Id + "m", [
                {
                    visible: true,
                    title: "Save",
                    action: async function () {
                        let r = this.Save(this.GetDeltas());
                        if (isPromise(r)) {
                            await r;
                        }
                    }.bind(this)
                }
            ], null))
        }
        this.Save = save;
        if (this.Save === null) {
            $(this.Id).off("contextmenu");
        }
    }

    MbtPlanner.prototype.GetEOW = () => _endOfTheWorld;

    /**
     * Reset the planner, that is set Tasks and Resources to empty arrays.*/
    MbtPlanner.prototype.ResetPlanner = function (keepResources = false) {
        $(this.Id).html("");
        this.Tasks = [];
        if (!keepResources)
            this.Resources = [];
        this.MinDueDate = _endOfTheWorld;
        this.MaxDueDate = null;
        this.MaxDate = null;
        this.MinDate = null;
    };  

    /**
     * @returns {array} Only altered tasks are returned. If the array is empty, then no task was altered.
     * 
     *     {
     *       Id:                 identifier of the task
     *       InitialCompletion:  float, the initial value of the completion
     *       CompltetionDelta:   float, the variation of the completion
     *       InitialExOrder:     int, the initial value of the rank of the task.
     *       ExOrderDelta:       int, the variation of the rank.
     *     }
     * 
     */
    MbtPlanner.prototype.GetDeltas = function () {
        var deltas = [];
        this.Tasks.forEach(function (o) {
            if (o.InitialExOrder - o.ExOrder !== 0 || o.InitialCompletion !== o.Completion) {
                deltas.push({
                    Id: o.Id,
                    InitialCompletion: o.InitialCompletion,
                    CompletionDelta: o.Completion - o.InitialCompletion,
                    InitialExOrder: o.InitialExOrder,
                    ExOrderDelta: o.ExOrder - o.InitialExOrder
                });
            }
        });

        return deltas;
    };  

    MbtPlanner.prototype.FormatTaskHeader = function (task) {
        try {
            if (this.CustomFormatter !== null) {
                return this.CustomFormatter(task);
            }
            return task.IRef + ": " + task.Code
        } catch (err) {
            return "formatting error";
        }
    };

    MbtPlanner.prototype.FormatResourceFooter = function (r, load) {
        try {
            if (this.CustomResourceFooter !== null) {
                return this.CustomResourceFooter(r, load);
            }
            return r.Id.toString() + ": " + load.toFixed(2);
        } catch (err) {
            return "formatting error";
        }
    };

    MbtPlanner.prototype.LastIsoDayOfWeek = function () {
        return (this.FirstIsoDayOfWeek - 1) || 7;
    };    

    MbtPlanner.prototype.GetIsoWeekForWeekStartingAt = function (jsDate) {
        if (this.FirstIsoDayOfWeek !== 1)
            jsDate = new Date(jsDate.valueOf() + (8 - this.FirstIsoDayOfWeek) * 864E5);
        if (window.moment !== undefined) {
            var m = moment(dateToString(jsDate));
            return m.isoWeek();
        }

        var d = new Date(Date.UTC(jsDate.getFullYear(), jsDate.getMonth(), jsDate.getDate()));
        var dayNum = d.getUTCDay() || 7;
        d.setUTCDate(d.getUTCDate() + 4 - dayNum);
        var yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
        return Math.ceil((((d - yearStart) / 864E5) + 1) / 7)
    };

    /**
     * @async
     * @see {@link ResetPlanner}
     */
    MbtPlanner.prototype.ResetPlannerAsync = function (keepResources = false) {
        return new Promise(function (resolve, reject) {
            this.ResetPlanner(keepResources);
            resolve(true);
        }.bind(this));
    };   

    /**
     * Short for aysnc load task, then plan, then draw
     * 
     *     await r.QueryPlanAndDrawAsync(
     *       {
     *         url: "/DeadLine/ForPlanner/",
     *         data: {
     *           id: $("#@plannedUserId").val().toString(),
     *           period: $("#@plannedPeriodId").val().toString()
     *         }
     *       },
     *       function (r) { return r.ExitCode !== undefined },
     *       function (r) { return r.ExitCode === 0; },
     *       function (r) { appendMessage("res", "Something is wrong: " + r.ExitCode.toString(), "warning"); },
     *       function () { appendMessage("res", "Something is wrong, but I don't know what!", "danger"); },
     *       function (p) {
     *         p.AppendOutOfWorkDay(51, "2019-01-02");
     *       }
     *     );
     * 
     * A json return **comprising a `Data` member** as an array of [appendable tasks](#AppendTasks) is expected.
     * 
     * @async
     * @param {function} queryObject a jquery object for an ajax request. Param is the return of the ajax call.
     * @param {function} successEval a function to evaluate if the ajax call was successfull. Param is the return of the ajax call.
     * @param {function} businessSuccessEval a function to evaluate if the call was successfull from the server point of view. Param is the return of the ajax call.
     * @param {function} businessError a function to handle the business error. Param is the return of the ajax call.
     * @param {function} error a function to handle the not recoverble errors. No Param.
     * @param {function} beforePlan. A function to setup the planner before the call to Plan, but after the call to AppendTasks. Param: the planner, the return of the ajax call.
     * @param {function} dataGetter. How to get the tasks from the response. Param: the return of the ajax call.
     * @param keepResources. If true, does not clear Resources during planner reset. Default = false
     */
    MbtPlanner.prototype.QueryPlanAndDrawAsync = function (
        queryObject, successEval, businessSuccessEval,
        businessError, error, beforePlan, dataGetter = null, keepResources = false
    ) {
        return Promise.all([
            this.ResetPlannerAsync(keepResources),
            $.ajax(queryObject)
        ]).then(function (values) {
            var dl = values[1];
            if (successEval(dl)) {
                if (businessSuccessEval(dl)) {
                    if (dataGetter === null)
                        dataGetter = (dl) => dl.Data
                    this.AppendTasks(dataGetter(dl), this.DefaultLoads);
                    if (beforePlan !== undefined) {
                        beforePlan(this, dl);
                    }
                    this.Plan("forward");
                    this.Draw(Date.now());
                } else {
                    businessError(dl);
                }
            } else {
                error();
            }
        }.bind(this));
    };

    /**
     * Draw the planner in the DOM. 
     * @param {(date|string)} drawMinDate the almost first date displayed. The first date displayed is always the first day of a week.
     * @param {string} mode "html" or "html". Default: "html".
     */
    MbtPlanner.prototype.Draw = function (drawMinDate, mode) {
        if (!$(this.Id).hasClass("MbtPlanner"))
            $(this.Id).addClass("MbtPlanner");
        $(this.Id).html("<div style='font-size:" + this.fontSize.toString() + "px;line-height:" + this.dayHeight.toString() + "px'><div style='display: flex;'><div class='Tasks'></div><div class='Planning'></div></div><div class='Messages'></div></div>");

        var perf_startDrawing = Date.now();
        if (this.MaxDate === null) {
            $(this.Id + " .Planning").html("<p>Nothing to display</p>");
            return;
        }

        if (mode === undefined || mode === null) {
            mode = "html;"
        }
        
        if (drawMinDate === undefined || drawMinDate === null) {
            drawMinDate = this.LastDrawMinDate;
        } else {
            this.LastDrawMinDate = drawMinDate;
        }

        var startDate =
            drawMinDate === undefined || drawMinDate === null ?
                this.MinDate > this.MinDueDate ? this.MinDueDate : this.MinDate :
                getDate(drawMinDate);

        while (getIsoDay(startDate) !== this.FirstIsoDayOfWeek) {
            startDate = new Date(startDate.valueOf() - 864E5)
        }
        var drawMaxDate = this.MaxDate;
        
        this.Resources.forEach(function (r) {
            r.OutOfWorkDays.forEach(function (d) {
                if (d > drawMaxDate)
                    drawMaxDate = d;
            });
        });
        //var weekCount = Math.ceil((this.MaxDate.valueOf() - startDate.valueOf()) / (7 * 864E5));
        var weekCount = Math.ceil((drawMaxDate.valueOf() - startDate.valueOf()) / (7 * 864E5));
        while (getIsoDay(drawMaxDate) !== this.LastIsoDayOfWeek()) {
            drawMaxDate = new Date(drawMaxDate.valueOf() + 864E5);
        }
        var dtn = getDate(Date.now());

        let t = null; //la table
        let ld = null; //variable date
        let weekOfYear = null;
        switch (mode) {
            case "svg-experimental":
                if (this.DrawSvgTasks === undefined) {
                    throw "svg not found";
                }
                this.DrawSvgTasks(weekCount, startDate);                
                break;
            default:
                t = $("<table style='border-collapse:separate;border-spacing:1px 1px'><thead><tr><th>Tasks</th><th style='width:40px'></th><th style='width:30px'></th></tr>" +
                    "</thead><tbody></tbody></table>");
                var h = t.find("thead").first();
                var b = t.find("tbody").first();
                var loadSum = 0.0;

                for (var i = 0; i < this.Tasks.length; i++) {
                    var isLastForUser = false;
                    if (i === this.Tasks.length - 1) {
                        isLastForUser = true;
                    } else {
                        if (this.Tasks[i].Resource === null) {
                            if (this.Tasks[i + 1].Resource !== null) {
                                isLastForUser = true;
                            }
                        } else {
                            if (this.Tasks[i + 1].Resource === null) {
                                isLastForUser = true;
                            } else {
                                if (this.Tasks[i].Resource.Id !== this.Tasks[i + 1].Resource.Id) {
                                    isLastForUser = true;
                                }
                            }
                        }
                    }
                    let taskHead = "<div class='PBar' style='width: " + this.Tasks[i].Completion.toString() + "%'>" + this.FormatTaskHeader(this.Tasks[i]) + "</div>";

                    loadSum += (this.Tasks[i].Load * (1 - this.Tasks[i].Completion / 100));
                    let r = $("<tr data-taskid='" + this.Tasks[i].Id.toString() + "'><td>" + taskHead + "</td>" +
                        "<td>" + (this.Tasks[i].Load * (1 - this.Tasks[i].Completion / 100)).toFixed(2) + "</td>" +
                        "<td>" + (!isLastForUser ? "<span class='ActionB Back'>↓</span>" : "") + "<span class='ActionB Complete'>&gt;</span>" + "</td></tr>");

                    r.on('click', 'td:last-child span.Back', function (e) {
                        var r = $(e.target).closest('tr');
                        var taskId = r.data("taskid");
                        var i = this.Tasks.findIndex(function (t) { return t.Id === taskId; });
                        if (i !== -1 && i !== this.Tasks.length - 1) {
                            var j = this.Tasks[i].ExOrder;
                            this.Tasks[i].ExOrder = this.Tasks[i + 1].ExOrder;
                            this.Tasks[i + 1].ExOrder = j;
                            this.Plan();
                            this.Draw();
                        }
                    }.bind(this));
                    r.on('click', 'td:last-child span.Complete', function (e) {
                        var r = $(e.target).closest('tr');
                        var taskId = r.data("taskid");
                        var i = this.Tasks.findIndex(function (t) { return t.Id === taskId; });
                        if (this.Tasks[i].InitialCompletion !== 100) {
                            if (this.Tasks[i].Completion !== 100) {
                                this.Tasks[i].Completion += this.ProgressStep;
                                if (this.Tasks[i].Completion > 100)
                                    this.Tasks[i].Completion = 100;
                            } else {
                                this.Tasks[i].Completion = this.Tasks[i].InitialCompletion;
                            }
                            this.Plan();
                            this.Draw();
                        }

                    }.bind(this));

                    b.append(r);

                    if (isLastForUser) {
                        b.append("<tr><td colspan='2' style='text-align:right;'>" +
                            this.FormatResourceFooter(this.Tasks[i].Resource, loadSum) 
                            + "</tr>");
                        loadSum = 0;
                    }
                }
                $(this.Id + " .Tasks").html(t);
                b.on("click", "td:first-child", (e) => {
                    e = e || window.event;
                    var p = $(e.target || e.srcElement).closest("tr");
                    var i = Array.prototype.indexOf.call(p[0].parentElement.children, p[0]);
                    //alert(i);
                    p.toggleClass("SelectedRow");
                    $(this.Id + " .Planning tbody").children().eq(i).toggleClass("SelectedRow");

                });

                var perf_DrawingTasksDuration = Date.now() - perf_startDrawing;                

                t = $("<table style='border-collapse:separate;border-spacing:1px 1px'><thead></thead><tbody></tbody><tfoot></tfoot></table>");
                h = t.find("thead").first();
                b = t.find("tbody").first();
                var f = t.find("tfoot").first();

                t.css("width", (this.dayWidth) * (weekCount * 7).toString() + "px");

                ld = startDate;
                let r = "<tr>";
                weekCount = 0;
                
                weekOfYear = this.GetIsoWeekForWeekStartingAt(ld);                

                while (ld <= drawMaxDate) {
                    r += "<th colspan='7'>" + weekOfYear.toString() + " : " + dateToString(ld) + "</th>";
                    ld = new Date(ld.valueOf() + 7 * 864E5);
                    weekCount++;
                    weekOfYear++;
                    if (weekOfYear > 52) {
                        weekOfYear = this.GetIsoWeekForWeekStartingAt(ld);
                    }
                }
                h.append(r + "</tr>");
                f.append(r + "</tr>");                
                var baseDate = startDate,
                    baseRow = "<tr>",
                    prevResourceId = -12587;

                let isTaskSpecific = false;
                for (i = 0; i < this.Tasks.length; i++) {
                    let curTask = this.Tasks[i];
                    if (curTask.Resource.Id !== prevResourceId) {
                        if (prevResourceId !== -12587) {
                            b.append("<tr><td></td></tr>");
                        }
                        prevResourceId = curTask.Resource.Id;
                        baseDate = startDate;
                        baseRow = "<tr>";                        
                    }
                    let ld = baseDate;
                    if (curTask.Production.length > 0) {
                        isTaskSpecific = false;
                    } else {
                        isTaskSpecific = true;
                    }
                    let r = baseRow;
                    var isToday = "";
                    while (ld <= drawMaxDate) { //endDate
                        isToday = ld.valueOf() === dtn.valueOf() ? this.TodayChar : "";
                        if (ld.valueOf() === curTask.DueDate.valueOf()) {
                            if (!isTaskSpecific) {
                                isTaskSpecific = true;
                                baseRow = r;
                                baseDate = ld;
                            }
                            if (this.IsOutOfWorkDay(ld, curTask.Resource)) {
                                r += "<td class='DueDateOO'>" + isToday + "</td>";
                            } else {
                                r += "<td class='DueDate'>" + isToday + "</td>";
                            }
                        } else if (!this.IsOpenDay(ld, curTask.Resource)) {
                            r += "<td class='Closed'>" + isToday + "</td>";
                        } else if (this.IsProductionDay(ld, curTask)) {
                            if (!isTaskSpecific) {
                                isTaskSpecific = true;
                                baseRow = r;
                                baseDate = ld;
                            }
                            if (ld > curTask.DueDate) {
                                r += "<td class='AfterDueDate'>" + isToday + "</td>";
                            } else {
                                if (curTask.Resource.BC !== undefined && curTask.Resource.BC !== "") {
                                    r += "<td style='background-color:" + curTask.Resource.BC + ";'/>";
                                } else {
                                    r += "<td class='Worked'>" + isToday + "</td>";
                                }
                            }
                        } else if (this.IsOutOfWorkDay(ld, curTask.Resource)) {
                            r += "<td class='OutOfWork'>" + isToday + "</td>";
                        } else {
                            r += "<td>" + isToday + "</td>";
                        }

                        ld = new Date(ld.valueOf() + 864E5);
                    }
                    b.append($(r + "</tr>"));
                }
                b.append("<tr><td></td></tr>");
                $(this.Id + " .Planning").html(t);
                break;
        }

        this.DrawingDuration = Date.now() - perf_startDrawing;
        $(this.Id + " .Messages").html(
            "Planning duration: " + this.PlanningDuration.toString() + ", Drawing duration: " +
            perf_DrawingTasksDuration.toString() + " + " +
            (this.DrawingDuration - perf_DrawingTasksDuration).toString());
    };    

    MbtPlanner.prototype.IsProductionDay = function (day, task) {
        return task.Production.findIndex(function (d) { return d.valueOf() === day.valueOf() }) !== -1;
    };

    MbtPlanner.prototype.IsOpenDay = function (date, resource) {
        var d = getIsoDay(date);
        if (this.ClosedIsoDays.indexOf(d) !== -1)
            return false;
        if (resource.ClosedIsoDays.indexOf(d) !== -1)
            return false;
        return true;
    };

    MbtPlanner.prototype.IsOutOfWorkDay = function (date, resource) {
        var r =
            (this.OutOfWorkDays.findIndex(function (d) { return d.valueOf() === date.valueOf(); }) !== -1) ||
            (resource.OutOfWorkDays.findIndex(function (d) { return d.valueOf() === date.valueOf(); }) !== -1);
        return r;
    };    

    MbtPlanner.prototype.AddToWorkableDay = function (date, days, resource) {
        date = getDate(date);
        if (typeof (days) === "string") {
            days = parseInt(days);
        }
        
        if (days < 0) {
            while (current > date) {
                if (!this.IsOpenDay(current, resource))
                    date = new Date(date.valueOf() - 864E5);
                current = new Date(current.valueOf() - 864E5);
            }
            while (!this.IsOpenDay(date, resource))
                date = new Date(date.valueOf() - 864E5);
        } else {
            while (!this.IsOpenDay(date, resource) || this.IsOutOfWorkDay(date, resource)) {
                date = new Date(date.valueOf() + 864E5);
            }
            var current = date;
            date = new Date(date.valueOf() + days * 864E5);            
            while (current < date) {
                if (!this.IsOpenDay(date, resource) || this.IsOutOfWorkDay(date, resource)) {
                    date = new Date(date.valueOf() + 864E5);
                }
                current = new Date(current.valueOf() + 864E5);
            }
        }
        return date;
    };

    MbtPlanner.prototype.GetResource = function (id) {
        if (id === undefined) {
            id = null;
        }
        let resource = this.Resources.find(function (e) {
            return e.Id === id;
        });
        if (resource === undefined) {
            resource = {
                Id: id,
                OutOfWorkDays: [],
                ClosedIsoDays: []
            };
            this.Resources.push(resource);
        }
        return resource;
    }

    /**
     * Append an array of tasks to the planner.
     * @param {array} tasks an array of appendable task object.
	 *
	 *     {
	 *       Id:         not null, unique identifier of the task. task silently ignored if not set
     *                   if two tasks has the same id they will be considered as one.
	 *       Code:       not null, task type identifier. task silently ignored if not set
	 *
	 *       ResourceId: can be null, unique identifier of a resource
	 *       Label:      can be null, default : "", not used but in CustomFormatter
	 *       IRef:       can be null, default : "", case identifier
	 *       DueDate:    can be null, date yyyy-MM-dd or json date
	 *       Load:       can be null, float, default : 0, float the whole load (in day) for the task
	 *                       => can be set by Code and this.DefaultLoads = [ {
	 *                           Code : not null
	 *                           Load : not null, float
	 *                       }]
	 *       Completion: can be null, default : 0, % of completion of the task,
	 *                       => load to be planned = Load * ( 1 - Completion / 100),
	 *       ExOrder:    can be null, int, default null
	 *
	 *       OTask:      the original object, may be used by CustomFormatter
	 *     }
     *
     * @param {array} codes can be null. Default load for task code.
     * 
     *     {
     *       Code:       not null
     *       Load:       not null, float
     *     }
     * 
     * @description Append a list of tasks to the planner and create the associated resources.
     * there is no other way to append a resource to the planner.
     */
    MbtPlanner.prototype.AppendTasks = function (tasks, codes) {
        for (var i = 0; i < tasks.length; i++) {
            if (tasks[i].Id === undefined || tasks[i].Id === null) {
                continue;
            }
            let dupIndex = this.Tasks.findIndex((o) => {
                return o.Id === tasks[i].Id; 
            });
            if (dupIndex !== -1)
                continue;
            if (tasks[i].Code === undefined || tasks[i].Code === null) {
                continue;
            } 
            let resource = this.GetResource(tasks[i].ResourceId);//.bind(this);
            var dd = null;
            if (tasks[i].DueDate !== undefined && tasks[i].DueDate !== null) {
                dd = getDate(tasks[i].DueDate);
                while (!this.IsOpenDay(dd, resource))
                    dd = new Date(dd.valueOf() + 864E5);
                if (this.MaxDueDate === null || dd > this.MaxDueDate) {
                    this.MaxDueDate = dd;
                }
                if (dd < this.MinDueDate) {
                    this.MinDueDate = dd;
                }
            }
            if (tasks[i].Load === undefined || tasks[i].Load === null) {
                var l = 0;
            } else {
                if (typeof (tasks[i].Load) === "string") {
                    l = parseFloat(tasks[i].Load);
                } else {
                    l = tasks[i].Load;
                }
            }
            if (tasks[i].IRef === undefined || tasks[i].IRef === null) {
                var iref = "";
            } else {
                iref = tasks[i].IRef;
            }
            if (tasks[i].Completion === undefined || tasks[i].Completion === null) {
                var completion = 0;
            } else {
                completion = parseFloat(tasks[i].Completion);
                if (completion > 100)
                    completion = 100;
            }
            if (tasks[i].ExOrder === undefined || tasks[i].ExOrder === null) {
                var eo = null;
            } else {
                if (typeof (tasks[i].ExOrder) === "string") {
                    eo = parseInt(tasks[i].ExOrder);
                } else {
                    eo = tasks[i].ExOrder;
                }
            }
            var t = {
                Id: tasks[i].Id,
                Resource: resource,
                Label: tasks[i].Label,
                IRef: iref,
                Code: tasks[i].Code,
                DueDate: dd,
                InitialExOrder : eo,
                ExOrder: eo,
                Load: l,
                StartDate: null,
                EndDate: null,
                //Completion éditée via le planner
                Completion: completion,
                //completion déclarée par la source de données
                InitialCompletion: completion,

                OTask: tasks[i]
            };
            if (t.DueDate === null)
                t.DueDate = _endOfTheWorld;
            if (t.Load === 0) {
                if (codes !== undefined && codes !== null) {
                    let c = codes.find(function (o) { return o.Code === t.Code; })
                    t.Load = (c !== undefined) ? c.Load : 0;
                }
            }
            this.Tasks.push(t);
        }
    };

    MbtPlanner.prototype.SortTasks = function () {
        this.Tasks.sort(function (a, b) {
            if (a.Resource !== null) {
                if (b.Resource !== null) {
                    if (a.Resource.Id !== b.Resource.Id) {
                        if (a.Resource.Id < b.Resource.Id)
                            return -1;
                        return 1;
                    }
                } else {
                    return -1;
                }
            } else {
                if (b.Resource !== null)
                    return 1;
            }
            if (a.ExOrder !== null) {
                if (b.ExOrder !== null) {
                    if (a.ExOrder !== b.ExOrder) {
                        if (a.ExOrder < b.ExOrder)
                            return -1;
                        return 1;
                    }
                } else {
                    return -1;
                }
            } else {
                if (b.ExOrder !== null)
                    return 1;
            }
            if (a.DueDate !== _endOfTheWorld) {
                if (b.DueDate !== _endOfTheWorld) {
                    if (a.DueDate < b.DueDate)
                        return -1;
                    return 1;
                } else {
                    return -1;
                }
            } else {
                if (b.DueDate !== _endOfTheWorld)
                    return 1;
            }
            return 0;
        });
    };

    /**
     * Append an out of work day
     * @param {?("*"|any)} resourceId an idnetifier of the resource. If "*" then you alter `this.OutOfWorkDays`.
     * @param {!(date|string)} day string may be a json serailized date or a "yyyy-mm-dd".
     */
    MbtPlanner.prototype.AppendOutOfWorkDay = function (resourceId, day) {
        day = getDate(day);
        if (resourceId === "*") {
            if (this.OutOfWorkDays.findIndex(function (d) { return d.valueOf() === day.valueOf(); }) === -1) {
                this.OutOfWorkDays.push(day);
            }
        } else {
            var resource = this.GetResource(resourceId);//.bind(this);
            if (resource.OutOfWorkDays.findIndex(function (d) { return d.valueOf() === day.valueOf(); }) === -1) {
                resource.OutOfWorkDays.push(day);
            }
        }
    };

    /**
     * Append a closed day  
     * @param {?("*"|any)} resourceId an identifier of the resource. If "*" then you alter `this.ClosedIsoDays`.
     * @param {!int} day between 1 (Monday) to 7 (Sunday)
     */
    MbtPlanner.prototype.AppendClosedIsoDay = function (resourceId, day) {
        if (day < 1 || day > 7)
            throw "not an iso day";
        if (resourceId === "*") {
            if (this.ClosedIsoDays.findIndex(function (d) { return d === day }) === -1) {
                this.ClosedIsoDays.push(day);
            }
        } else {
            var resource = this.GetResource(resourceId);//.bind(this);
            if (resource.ClosedIsoDays.findIndex(function (d) { return d === day; }) === -1) {
                resource.ClosedIsoDays.push(day);
            }
        }
    };

    /**
     * Set the the color of worked days for the resource.
     * @param {?any} resourceId an identifier of the resource. "*" is inefective ins this context.
     * @param {!string} color a color that will be set as background through a css style. Edit MbtPlanner.css for this.
     */
    MbtPlanner.prototype.SetBackGroundColor = function (resourceId, color) {
        var resource = this.GetResource(resourceId);//.bind(this);
        resource.BC = color;
    }

    /**
     * 
     * @param {string} direction "forward" or "forward". Default: "forward"
     * @param {?(date|string)} from The date used a the first planned day. Default: now() + 1 day
     * 
     * @description Tasks are planned ordered by resource, then by ExOrder, then by due date.
     */
    MbtPlanner.prototype.Plan = function (direction, from) {
        let scale = 100;
        var startPlanning = Date.now();
        if (direction === undefined || direction === null) {
            if (this.LastDirection === undefined || this.LastDirection === null) {
                this.LastDirection = "forward";
            }
            direction = this.LastDirection;
        } else {
            this.LastDirection = direction;
        }
        if (from === undefined || from === null) {
            if (this.LastFrom === undefined || this.LastFrom === null) {
                this.LastFrom = getDate(new Date(Date.now() + 864E5));
            }
            from = this.LastFrom;
        } else {
            from = getDate(from);
            this.LastFrom = from;
        }

        this.SortTasks();
        this.MinDate = null;
        this.MaxDate = null;
        this.Resources.forEach(function (o, i) {
            o.NextWorkableDay = this.AddToWorkableDay(from, 0, o);
            o.dayResidue = 1 * scale;
        }.bind(this));
        this.Tasks.forEach(function (t, i) {
            t.StartDate = null;
            t.EndDate = null;
            t.Production = [];
        });
        switch (direction) {
            case "forward":
                var curOrder = 1;
                for (let i = 0; i < this.Tasks.length; i++) {                    
                    var curTask = this.Tasks[i];                    
                    if (curTask.ExOrder === null) {
                        curTask.ExOrder = curOrder++;
                        curTask.InitialExOrder = curTask.ExOrder;
                    } else {
                        curOrder = curTask.ExOrder + 1;
                    }
                    if (curTask.DueDate !== _endOfTheWorld) {
                        if (this.MaxDate === null || curTask.DueDate > this.MaxDate) {
                            this.MaxDate = curTask.DueDate;
                        }
                    }
                    let load = (curTask.Load * scale) * (1 - curTask.Completion / 100);                    
                    curTask.StartDate = curTask.Resource.NextWorkableDay;
                    while (load >= (1 * scale)) {
                        curTask.Production.push(curTask.Resource.NextWorkableDay);
                        curTask.Resource.NextWorkableDay = this.AddToWorkableDay(curTask.Resource.NextWorkableDay, 1, curTask.Resource);
                        load -= (1 * scale);
                    }
                    //scale / this.Resolution <=> fraction de journée qui ne doit pas être planifiée
                    if (load > scale / this.Resolution) {                       
                        curTask.Production.push(curTask.Resource.NextWorkableDay);
                        if (load > curTask.Resource.dayResidue) {
                            curTask.Resource.NextWorkableDay = this.AddToWorkableDay(curTask.Resource.NextWorkableDay, 1, curTask.Resource);
                            curTask.Production.push(curTask.Resource.NextWorkableDay);
                            curTask.Resource.dayResidue = (curTask.Resource.dayResidue + (1 * scale)) - load;
                        } else {
                            curTask.Resource.dayResidue -= load;
                        }
                        if (curTask.Resource.dayResidue === 0) {
                            curTask.Resource.NextWorkableDay = this.AddToWorkableDay(curTask.Resource.NextWorkableDay, 1, curTask.Resource);
                            curTask.Resource.dayResidue = (1 * scale);
                        }                        
                    }

                    if (curTask.Production.length > 0) {
                        curTask.EndDate = curTask.Production[curTask.Production.length - 1];
                    } else {
                        curTask.EndDate = curTask.StartDate;
                    }
                    if (this.MaxDate === null || curTask.Resource.NextWorkableDay > this.MaxDate) {
                        this.MaxDate = curTask.Resource.NextWorkableDay;
                    }
                    if (this.MinDate === null || curTask.StartDate < this.MinDate) {
                        this.MinDate = curTask.StartDate;
                    }
                }
                break;
        }
        this.PlanningDuration = Date.now() - startPlanning;
    }

    exports.MbtPlanner = MbtPlanner;

    function getIsoDay(jsDate) {
        return jsDate.getDay() || 7;
    };

    function dateToString(d) {
        return d.getFullYear() + "-" + ("0" + (d.getMonth() + 1).toString()).slice(-2) + "-" + ("0" + d.getDate().toString()).slice(-2);
    }

    function getDate(date) {
        if (typeof (date) === "string") {
            if (date.startsWith("/Date(")) {
                date = new Date(parseInt(date.substr(6)));
            } else {
                if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(date)) {
                    throw "day must be a yyyy-MM-dd string"
                }
                return new Date(date);
            }
        } else if (typeof (date) === "number") {
            date = new Date(date);
        }
        if (typeof (date.getFullYear) !== "function") {
            throw "parameter is not a date";
        }
        return new Date(date.getFullYear().toString() + "-" + ("0" + (date.getMonth() + 1).toString()).slice(-2) + "-" + ("0" + date.getDate().toString()).slice(-2));

    }

    /*
     * For testing purposes
     * */
    exports.mbtpGetDate = getDate;
    
})(this);