Name

global.BurnDownUtil

Description

Contains the functions needed for burn down chart creation.

Script

var BurnDownUtil = Class.create();

BurnDownUtil.RELEASE = "RELEASE";
BurnDownUtil.SPRINT = "SPRINT";

BurnDownUtil.RELEASE_STATE_DRAFT = -6;
BurnDownUtil.RELEASE_STATE_PLANNING = 1;
BurnDownUtil.RELEASE_STATE_CURRENT = 2;
BurnDownUtil.RELEASE_STATE_COMPLETE = 3;
BurnDownUtil.RELEASE_STATE_CANCELLED = 4;

BurnDownUtil.SPRINT_STATE_DRAFT = -6;
BurnDownUtil.SPRINT_STATE_PLANNING = 1;
BurnDownUtil.SPRINT_STATE_CURRENT = 2;
BurnDownUtil.SPRINT_STATE_COMPLETE = 3;
BurnDownUtil.SPRINT_STATE_CANCELLED = 4;

BurnDownUtil.STORY_STATE_TESTING = -8;
BurnDownUtil.STORY_STATE_READY_FOR_TESTING = -7;
BurnDownUtil.STORY_STATE_DRAFT = -6;
BurnDownUtil.STORY_STATE_BLOCKED = -5;
BurnDownUtil.STORY_STATE_READY = 1;
BurnDownUtil.STORY_STATE_WORK_IN_PROGRESS = 2;
BurnDownUtil.STORY_STATE_COMPLETE = 3;
BurnDownUtil.STORY_STATE_CANCELLED = 4;

BurnDownUtil.isComplete = function(state){
  if (!state)
      return false;

  return state == BurnDownUtil.STORY_STATE_COMPLETE;
};

BurnDownUtil.isCancelled = function(state){
  if (!state)
      return false;

  return state == BurnDownUtil.STORY_STATE_CANCELLED;
};

BurnDownUtil.isWorkInProgress = function(state){
  if (!state)
      return false;

  return state == BurnDownUtil.STORY_STATE_WORK_IN_PROGRESS;
};

BurnDownUtil.isDraft = function(state){
  if (!state)
      return false;

  return state == BurnDownUtil.STORY_STATE_DRAFT;
};

BurnDownUtil.isPlanning = function(state){
  if (!state)
      return false;

  return state == BurnDownUtil.SPRINT_STATE_PLANNING;
};

BurnDownUtil.isReady = function(state){
  return BurnDownUtil.isPlanning(state);
};

BurnDownUtil.prototype = {
  initialize: function(recordSysId, burnDownType){
      if (!recordSysId)
          return;

      this.sourceSysId = recordSysId;
      this.burnDownType = null;
      this.sourceRecord = null;

      switch (burnDownType) {
          case BurnDownUtil.RELEASE:
              this.burnDownType = BurnDownUtil.RELEASE;
              this.sourceRecord = this._getRecord("rm_release_scrum", recordSysId);
              this.storyRefField = "release";
              this.totalsMetric = BurnDownMetricUtil.RELEASE_METRIC;
              break;
          case BurnDownUtil.SPRINT:
          default:
              this.burnDownType = BurnDownUtil.SPRINT;
              this.sourceRecord = this._getRecord("rm_sprint", recordSysId);
              this.storyRefField = "sprint";
              this.totalsMetric = BurnDownMetricUtil.SPRINT_METRIC;
      }
      
      var dates = this._calcStartAndEndDates();
      this.startDate = dates.startDate;
      this.endDate = dates.endDate;
  },

  isValid: function(){
      return this.sourceRecord != null && this.sourceRecord.isValid();
  },

  isSprintBurnDown: function(){
      return this.burnDownType == BurnDownUtil.SPRINT;
  },

  isReleaseBurnDown: function(){
      return this.burnDownType == BurnDownUtil.RELEASE;
  },

  getTitle: function(){
      return this.sourceRecord.number + ": " + this.sourceRecord.short_description;
  },
  
  getInitialDate: function(){
      var initial = this.getStartDate();
      initial.addDays(-1);
      
      return initial.getDate();
  },
  
  getStartDate: function(){
      return new GlideDateTime(this.startDate).getLocalDate();
  },
  
  getEndDate: function(){
      return new GlideDateTime(this.endDate).getLocalDate();
  },
  
  // We don't manipulate the end date, so return the same instance
  _getEndDate: function(){
      return this.endDate.getLocalDate();
  },

  /**
   * 
   * @param sprintSysId
   * @returns [{startDate, dailyPoints}]
   */
  getDailyPoints: function(){
      var data = [];

      var startDate = this.getStartDate();
      var endDate = this._getEndDate();

      // Grab all the metrics or the total amount of work to be done and
      // chuck it into a "map" keyed date.
      var sprintMetrics = this._getDailyPointsMap();
      startDate.addDays(-1);
      data.push({
          key: new GlideDateTime(startDate).getDate(),
          value: sprintMetrics.startingValue
      });
      startDate.addDays(1);

      while (startDate.compareTo(endDate) <= 0) {
          data.push({
              key: new GlideDateTime(startDate).getDate(),
              value: sprintMetrics[startDate].dailyPoints
          });
          startDate.addDays(1);
      }

      return data;
  },

  /**
   * @returns {dailyPoints, changed} keyed on date
   */
  _getDailyPointsMap: function(){
      var data = {};

      var startDate = this.getStartDate();
      var endDate = this._getEndDate();

      // Grab all the metrics or the total amount of work to be done and
      // chuck it into a "map" keyed date.
      var metrics = this._getDailyTotalMetrics();
      var dailyPointsTotal = metrics.startingValue;

      while (startDate.compareTo(endDate) <= 0) {
          if (JSUtil.nil(metrics[startDate])) {
              data[startDate] = {
                  dailyPoints: dailyPointsTotal,
                  changed: false
              };
          } else {
              dailyPointsTotal = metrics[startDate];
              data[startDate] = {
                  dailyPoints: dailyPointsTotal,
                  changed: true
              };
          }

          startDate.addDays(1);
      }

      data.startingValue = metrics.startingValue;
      
      return data;
  },

  getIdeal: function(){
      var data = [];

      var startDate = this.getStartDate();
      var endDate = this._getEndDate();
      var daysRemaining = (endDate.getNumericValue() - startDate.getNumericValue()) / 1000 / 3600 / 24 + 1;
     
      // Grab all the metrics or the total amount of work to be done and
      // chuck it into a "map" keyed date.
      var dailyTotalsMap = this._getDailyPointsMap();
      var dailyPointsTotal = dailyTotalsMap.startingValue;
      var dailyAfterBurnDown = dailyPointsTotal;
      var burnDownAmount = dailyPointsTotal / daysRemaining;

      // Force initial value into series
      startDate.addDays(-1);
      data.push({
          key: new GlideDateTime(startDate).getDate(),
          value: dailyAfterBurnDown
      });
      startDate.addDays(1);

      while (startDate.compareTo(endDate) <= 0) {
          // Fill in any missing values between startDate and endDate
          dailyAfterBurnDown += dailyTotalsMap[startDate].dailyPoints - dailyPointsTotal;
          dailyPointsTotal = dailyTotalsMap[startDate].dailyPoints;

          if (dailyTotalsMap[startDate].changed)
              burnDownAmount = dailyAfterBurnDown / daysRemaining;

          dailyAfterBurnDown -= burnDownAmount;
          daysRemaining--;
          
          data.push({
              key: new GlideDateTime(startDate).getDate(),
              value: Math.max(dailyAfterBurnDown, 0)
          });
          startDate.addDays(1);
      }

      return data;
  },

  getActual: function(){
      var data = [];

      // Grab all the metrics or the total amount of work to be done and
      // chuck it into a "map" keyed date.
      var dailyTotalsMap = this._getDailyPointsMap();

      // Grab all the metrics or the closed stories
      // and chuck it into a "map" keyed by date.
      var startDate = this.getStartDate();
      var endDate = this._getEndDate();
      var today = new GlideDate().getLocalDate();
      var storyMetrics = this._getStoryMetrics();
      var points = 0;

      // Force initial value into series
      startDate.addDays(-1);
      data.push({
          key: new GlideDateTime(startDate).getDate(),
          value: dailyTotalsMap.startingValue
      });
      startDate.addDays(1);
      
      while (startDate.compareTo(endDate) <= 0 && startDate.compareTo(today) <= 0) {
          points += storyMetrics[startDate] ? storyMetrics[startDate] : 0;
          
          data.push({
              key: new GlideDateTime(startDate).getDate(),
              value: Math.max(dailyTotalsMap[startDate].dailyPoints + points, 0)
          });
          startDate.addDays(1);
      }

      return data;
  },

  hasMetricData: function(){
      if (!this.isValid())
          return false;

      var gr = new GlideRecord("metric_instance");
      gr.addQuery("id", this.sourceSysId);
      gr.addQuery("definition.name", this.totalsMetric);
      gr.query();

      if (gr.next())
          return true;

      return false;
  },

  hasValidDates: function(){
      if (!this.isValid())
          return false;

      return !this.sourceRecord.start_date || !this.sourceRecord.end_date ? false : true;
  },

  _getDailyTotalMetrics: function(){
      var totals = {};

      var gr = new GlideRecord("metric_instance");
      gr.addQuery("id", this.sourceSysId);
      gr.addQuery("definition.name", this.totalsMetric);
      gr.orderBy("start");
      gr.query();

      if (gr.next()) {
          if (gr.start.getGlideObject().getLocalDate().compareTo(this.getStartDate()) <= 0)
              totals.startingValue = gr.value - 0;
          else
              totals.startingValue = 0;

          do {
              totals[gr.start.getGlideObject().getLocalDate()] = gr.value - 0;
          } while (gr.next());
      }

      return totals;
  },

  _getStoryMetrics: function(){
      var metrics = {};
      var storyPoints = {};
      var storySysIds = [];

      var startDate = this.getStartDate();
      var endDate = this._getEndDate();

      // Get all the stories that have metric records created against them.
      // Then put store the points value for later use.
      var gr = new GlideRecord("rm_story");
      gr.addQuery(this.storyRefField, this.sourceSysId);
      gr.query();

      while (gr.next()) {
          storyPoints[String(gr.sys_id)] = gr.story_points - 0;
          storySysIds.push(String(gr.sys_id));
      }

      // Retrieve all the metrics for the stories of the sprint
      // and add the points stored in the map above
      gr = new GlideRecord("metric_instance");
      gr.addQuery("definition.name", BurnDownMetricUtil.STORY_METRIC);
      gr.addQuery("id", "IN", storySysIds.join(","));
      gr.orderBy("start");
      gr.query();

      // All metrics should begin with a completed or cancelled entry. If we encounter an entry that's not
      // cancelled or completed first we should disregard it as it's more than likely a story that was cancelled
      // in a previous sprint that's been moved to the current sprint.
      var storyCarryOvers = {};

      while (gr.next()) {
          var date = gr.start.getGlideObject().getLocalDate();
          var value = 0;

          // Exclude any dates that are outside the sprint range
          if (date.compareTo(startDate) < 0 || date.compareTo(endDate) > 0)
              continue;

          switch (gr.field_value - 0) {
              case BurnDownUtil.STORY_STATE_CANCELLED:
                  if (!storyCarryOvers[String(gr.id)])
                      storyCarryOvers[String(gr.id)] = BurnDownUtil.STORY_STATE_CANCELLED;
                  break;
              case BurnDownUtil.STORY_STATE_COMPLETE:
                  if (!storyCarryOvers[String(gr.id)])
                      storyCarryOvers[String(gr.id)] = BurnDownUtil.STORY_STATE_CANCELLED;

                  value = storyPoints[gr.id] * -1;
                  break;
              default:
                  if (storyCarryOvers[String(gr.id)])
                      value = storyPoints[gr.id];
          }

          metrics[date] = metrics[date] ? metrics[date] + value : value;
      }

      return metrics;
  },

  _calcStartAndEndDates: function(){
      var startDate = this.sourceRecord.start_date.getGlideObject();
      var endDate = this.sourceRecord.end_date.getGlideObject();

      return {
          startDate: startDate,
          endDate: endDate
      };
  },

  _getRecord: function(tableName, sysId){
      var gr = new GlideRecord(tableName);
      if (gr.get(sysId))
          return gr;

      return null;
  },

  type: 'BurnDownUtil'
};

Sys ID

c7409b7237131000913e40ed9dbe5d6f

Offical Documentation

Official Docs: