shindai / Jira Tempo Worklog Extensions Cloud

// ==UserScript==
// @name        Jira Tempo Worklog Extensions Cloud
// @description ATTENTION: STILL IN TESTING MAY CAUSE PROBLEMS! Extends issue detail to set tempo worklog billable times to zero (for one or all worklogs of an issue). Also shows the total worktime/billable time. Tested with Jira Cloud
// @author      shindai
// @copyright 2020, shindai (https://openuserjs.org/users/shindai)
// @license MIT
// @include     https://app.tempo.io/timesheets/jira/issue-view-panel*
// @match       https://app.tempo.io/timesheets/jira/issue-view-panel*
// @updateURL https://openuserjs.org/meta/shindai/Jira_Tempo_Worklog_Extensions_Cloud.meta.js
// @require     http://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js
// @version     0.0.4
// @grant       none
// ==/UserScript==
//TODO: Check security is user has rights to set times to billable = 0
//TODO: Use GET/POST from Jira/Tempo, instead copying the code of headers
//TODO: Check performance
// PARAMETERS
var showTempoTimeStatistics = true;
//
// initiatilize my specific jQuery and avoid conflicts with Jira one
var $j = jQuery.noConflict(true);
var doing_modifications = false;
var ignore_dom_event = false;
$j(document).ready(function () {
  AddStyling();
  RegisterEvents();
  InitLayout();
  var timer = 0;
  var updateTimer = 0;
  $j(document).bind("DOMSubtreeModified", function () {
    if (timer) {
      window.clearTimeout(timer);
    }
    if (!doing_modifications) {
      timer = window.setTimeout(function () {
        InitLayout();
      }, 100);
    }
    if (ignore_dom_event) {
      return;
    }
    ignore_dom_event = true;
    $j(document).trigger("update-worklog-stats");
    var issueId = GetCurrentIssueId();
    if (issueId != null) {
      console.log("update");
      GetWorklogSumForIssue(issueId, () => {
        if (updateTimer) {
          window.clearTimeout(updateTimer);
        }
        updateTimer = window.setTimeout(function () {
          ignore_dom_event = false;
        }, 100);
      }, function (workflow) {
        $j(document).trigger('update-worklog-row', workflow);
      });
    }
  });
});
// INIT METHODS
function AddStyling() {
  $j('head').append('<style type="text/css">' +
    '.btn-billable-zero[data-billable-time="0"]{ opacity: 0.2;}' +
    '.sc-bZSQDF{margin-right: 0px;}' +
    '.btn-billable-zero{ height: 24px; }' +
    '.btn-billable-zero-all{ height: 32px; float: right; margin-top: 5px; }' +
    '.icon-time { width: 16px; height: 16px; background-image: url(//d2g6nngx86qj5q.cloudfront.net/atl-vertigo--shard-jira-prod-eu-16--2--jres.atlassian.net/s/-ipn83r/b/24/b80d0dad6eb3bb427b6dda8336a35d66e73f9102/_/download/resources/com.atlassian.jira.jira-atlaskit-plugin:overrides-general/images/time.svg); background-size: 16px 16px; }' +
    '.billable-stats-container{ color: #fff; background-color: #008da6; border-color: #008da6; border-radius: 3px; height: 28px; margin-top: 6px; padding: 4px; margin-right: 8px; text-align: left; word-wrap: break-word; overflow: hidden; text-overflow: ""; white-space: nowrap; }' +
    '</style>');
}
function getBillableIcon() {
  return `<span class="sc-eHfQar"><span color="currentColor" height="20" width="20" data-testid="AtIcon" class="sc-bdfBwQ bdoyjv"><svg height='20' width='20'  fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="-2 -1 27 27" x="0px" y="0px"><title>_</title><path d="M21,12a9.00984,9.00984,0,0,1-9,9V19.5A7.5,7.5,0,1,0,4.5,12H3a9,9,0,0,1,18,0ZM7.452,16.25555l-.69538-.099a.77781.77781,0,0,1-.41144-.16834.328.328,0,0,1-.09423-.23981c0-.30975.247-.49469.66064-.49469a1.39528,1.39528,0,0,1,1.03729.33533l.17572.16809.17286-.171.72851-.7204.1817-.17962-.18359-.17774a2.54873,2.54873,0,0,0-1.41242-.69848V13h-1.5v.87018A1.939,1.939,0,0,0,4.64,15.79089a1.81363,1.81363,0,0,0,.49622,1.33631,2.24482,2.24482,0,0,0,1.28345.56024l.71441.09949a.62554.62554,0,0,1,.38636.14178.41279.41279,0,0,1,.10235.31476c0,.11835,0,.47851-.79419.47851a1.67171,1.67171,0,0,1-1.24737-.42413l-.177-.178-.17725.1778-.74463.7467-.175.17554.17407.17651a2.7392,2.7392,0,0,0,1.63031.79767V21h1.5v-.84894A1.97194,1.97194,0,0,0,9.25,18.20911a1.89329,1.89329,0,0,0-.53265-1.4314A2.22463,2.22463,0,0,0,7.452,16.25555ZM11.25,6v6.75H16v-1.5H12.75V6Z"></path></svg></span></span>`
}
function getTimeIcon() {
  return `<span class="sc-eHfQar"><span color="currentColor" height="20" width="20" data-testid="AtIcon" class="sc-bdfBwQ bdoyjv"><svg viewBox="0 0 20 20" fill="#001c3d"><g fill-rule="evenodd"><path d="M4.5 10a5.5 5.5 0 1011 0 5.5 5.5 0 00-11 0zM3 10a7 7 0 1114 0 7 7 0 01-14 0z"></path><path d="M12.63 10.791a.75.75 0 01-.808 1.264l-2.226-1.423A.75.75 0 019.25 10V6.5a.75.75 0 011.5 0v3.09l1.88 1.201z"></path></g></svg></span></span>`
}
function getGraphIcon() {
  return `<span class="sc-eHfQar"><span color="currentColor" height="20" width="20" data-testid="AtIcon" class="sc-bdfBwQ bdoyjv"><svg viewBox="0 0 20 20" fill="#001c3d"><g fill-rule="evenodd"><rect x="13" y="9" width="3" height="6" rx="0.5"></rect><rect x="8.5" y="11" width="3" height="4" rx="0.5"></rect><rect x="4" y="5" width="3" height="10" rx="0.5"></rect></g></svg></span></span>`
}
function RegisterEvents() {
  $j(document).on("click", ".btn-billable-zero-all", function () {
    if (confirm("Are you sure?")) {
      var issueId = GetCurrentIssueId();
      SetAllWorklogsForIssueToBillableZero(issueId, function (worklog) {
        $j(document).trigger("update-worklog-row", worklog);
      }, function (parameter) {
        alert("Worklogs edit complete: " + parameter.successCounter + "/" + parameter.total + " were successfully edited!");
        $j(document).trigger("update-worklog-stats");
      });
    }
  });
  $j(document).on("click", ".btn-billable-zero", function (element) {
    var $element;
    if ($j(element.target).hasClass('.btn-billable-zero')) {
      $element = $j(element.target);
    }
    else {
      $element = $j(element.target).closest('.btn-billable-zero');
    }
    var issueId = $element.attr("data-issueid");
    var worklogId = $element.attr("data-worklogid");
    SetOneWorklogForIssueToBillableZero(
      issueId,
      worklogId,
      function (worklog) {
        $j(document).trigger("update-worklog-stats");
        $j(document).trigger("update-worklog-row", worklog);
        alert("Worklog edit complete.");
      },
      function () {
        alert("Worklog edit failed.");
      },
      function () {}
    );
  });
  $j(document).on('update-worklog-row', function (element, worklog) {
    var button = $j(".btn-billable-zero[data-worklogid='" + worklog.originId + "']");
    if (button.length !== 0) {
      button.attr("data-billable-time", worklog.billableSeconds)
      button.prop('disabled', false);
      if (worklog.billableSeconds <= 0) {
        button.prop('disabled', true);
      }
    }
    var worklogWorkLabel = $j("div[name='value_work_" + worklog.originId + "']");
    if (worklogWorkLabel.length !== 0) {
      var billabelTimeLabel = worklogWorkLabel.parent().find('.billable-time-label');
      if (billabelTimeLabel.length === 0) {
        billabelTimeLabel = $j("<div class='billable-time-label' title='billable time'></div>").insertAfter(worklogWorkLabel);
      }
      billabelTimeLabel.html(getBillableIcon() + (worklog.billableSeconds / 60 / 60) + "h");
    }
  });
  $j(document).on('update-worklog-stats', function () {
    var $billableStatsContainer = $j(".billable-stats-container");
    if ($billableStatsContainer.length !== 0) {
      var issueId = GetCurrentIssueId();
      if (issueId == null) {
        return;
      }
      GetWorklogSumForIssue(issueId, function (result) {
        $billableStatsContainer.html(getTimeIcon() + (result.totalTimeInSeconds / 60 / 60) + "h |" + getBillableIcon() + (result.totalTimeBillableInSeconds / 60 / 60) + "h<wbr> |" + getGraphIcon() + result.percentage + "%");
      }, function (worklog) {});
    }
  });
}
function InitLayout() {
  try {
    doing_modifications = true;
    InitBillableInfoIfRequired();
    InitAllBillableToZeroButtonIfRequired();
    InitSingleBillableToZeroButtonsIfRequired();
  }
  catch (exception) {
    doing_modifications = false;
    console.log("Error found on the page :" + exception);
  }
  doing_modifications = false;
}
function InitBillableInfoIfRequired() {
  if (showTempoTimeStatistics === false) {
    return;
  }
  var $tempoButtonRow = $j('#tempoIssueViewPanel .sc-hzMMCg');
  if ($tempoButtonRow.length === 0) {
    return;
  }
  var $billableStatsContainer = $j('#tempoIssueViewPanel .sc-hzMMCg .billable-stats-container');
  if ($billableStatsContainer.length !== 0) {
    return;
  }
  var issueId = GetCurrentIssueId();
  if (issueId == null) {
    return;
  }
  GetWorklogSumForIssue(issueId, function (result) {
    if ($j('#tempoIssueViewPanel .sc-hzMMCg .billable-stats-container').length !== 0) {
      return;
    }
    $j("<div class=\"billable-stats-container \" style=\"display:inline-block;\">" + getTimeIcon() + (result.totalTimeInSeconds / 60 / 60) + "h |" + getBillableIcon() + (result.totalTimeBillableInSeconds / 60 / 60) + "h<wbr> |" + getGraphIcon() + result.percentage + "%</div>").prependTo($tempoButtonRow);
  }, function (workflow) {
    $j(document).trigger('update-worklog-row', workflow);
  });
}
function InitAllBillableToZeroButtonIfRequired() {
  var $tempoButtonRow = $j('#tempoIssueViewPanel .sc-kYrkKh .sc-bZSQDF');
  if ($tempoButtonRow.length === 0) {
    return;
  }
  var $billableZeroButton = $j('#tempoIssueViewPanel .sc-kYrkKh .sc-bZSQDF .btn-billable-zero-all');
  if ($billableZeroButton.length !== 0) {
    return;
  }
  $j("<button class=\"sc-kEjbxe GjHin sc-hBEYos btn-billable-zero-all\" title=\"Set the billable time of all worklogs of this issue to 0.\"><span class=\"sc-gqjmRU ffarVT\" style=\"margin-top: 2px;\"><span class=\"sc-VigVT eAvoVS\">Set all worklogs to billable = 0</span></span></button>").appendTo($tempoButtonRow);
}
function InitSingleBillableToZeroButtonsIfRequired() {
  var $tempoWorklogsContainer = $j('#tempoIssueViewPanel #issuePanelTableWrapper');
  if ($tempoWorklogsContainer.length === 0) {
    return;
  }
  var $rowActionContainers = $j('#tempoIssueViewPanel #issuePanelTableWrapper table tr .tempo-activity-actions-container');
  if ($rowActionContainers.length === 0) {
    return;
  }
  var issueId = GetCurrentIssueId();
  if (issueId == null) {
    return;
  }
  for (var i = 0; i < $rowActionContainers.length; i++) {
    var $rowActionContainer = $j($rowActionContainers[i]);
    if ($rowActionContainer.find(".btn-billable-zero").length > 0) {
      continue;
    }
    var $row = $rowActionContainer.closest('tr');
    var selector = "div[name^=\"value_work_\"]";
    var $hiddenField = $row.find(selector);
    if ($hiddenField.length !== 1) {
      continue;
    }
    var hiddenFieldNameAttr = $hiddenField.attr('name');
    var worklogId = hiddenFieldNameAttr.replace("value_work_", "");
    $j("<button class=\"sc-kEjbxe GjHin sc-hBEYos btn-billable-zero\" data-issueid=\"" + issueId + "\" data-worklogid=\"" + worklogId + "\" style=\"width: 49px;\" title=\"Set the billable time of this worklog to 0.\"><span class=\"sc-gqjmRU ffarVT\"><span class=\"sc-VigVT eAvoVS\">To 0</span></span></button>").appendTo($rowActionContainer);
  }
}
// API HELPER METHODS
function GetCurrentIssueId() {
  if (window.INITIAL_STATE == null) {
    return null;
  }
  var issueId = window.INITIAL_STATE.issue_id;
  return issueId;
}
function GetWorklogSumForIssue(issueId, sumResultCallback, singleWorklogResultCallback) {
  GetWorklogsForIssue(issueId, function (data) {
    function ResolveAll(worklogIdList) {
      var promises = [];
      for (var i = 0; i < worklogIdList.length; i++) {
        var worklogId = worklogIdList[i];
        promises.push(new Promise(function (resolve, reject) {
          GetWorklogById(worklogId, resolve, reject);
        }));
      }
      return Promise.all(promises);
    }
    var worklogIds = data.activities.map(x => x.id);
    var result = ResolveAll(worklogIds).then(function (results) {
      var totalTimeInSeconds = 0;
      var totalTimeBillableInSeconds = 0;
      for (var i = 0; i < results.length; i++) {
        var worklog = results[i];
        totalTimeInSeconds += worklog.timeSpentSeconds;
        totalTimeBillableInSeconds += worklog.billableSeconds;
        singleWorklogResultCallback(worklog);
      }
      var percentage = Math.floor((totalTimeBillableInSeconds / totalTimeInSeconds) * 100, 2);
      var result = {
        percentage: percentage,
        totalTimeInSeconds: totalTimeInSeconds,
        totalTimeBillableInSeconds: totalTimeBillableInSeconds
      };
      sumResultCallback(result);
    }, function (reason) {
      console.log('fetching worklogs for statistics failed -> ' + reason);
    });
  });
}
function SetAllWorklogsForIssueToBillableZero(issueId, singleWorklogUpdateCallback, finishedCallback) {
  var url = "/rest/tempo-time-activities/1/issue/" + issueId + "/?page=1&size=9999&activityType=work&currentUser=false";
  $j.ajax({
    headers: getTempoHeaders(),
    type: "GET",
    url: url
  }).done(function (data) {
    var worklogIds = data.activities.map(x => x.id);
    var counter = 0;
    var successCounter = 0;
    var failCounter = 0;
    function showResult() {
      if (counter === worklogIds.length) {
        var arg = {
          successCounter: successCounter,
          total: worklogIds.length
        }
        finishedCallback(arg);
      }
    }
    for (var i = 0; i < worklogIds.length; i++) {
      var worklogId = worklogIds[i];
      SetOneWorklogForIssueToBillableZero(
        issueId,
        worklogId,
        function () {
          var worklog = arguments[1];
          singleWorklogUpdateCallback(worklog);
          successCounter++;
        },
        function () {
          failCounter++;
        },
        function () {
          counter++;
          showResult();
        }
      );
    }
  });
}
function SetOneWorklogForIssueToBillableZero(issueId, worklogId, successCallback, failCallback, alwaysCallback) {
  var pObj = {
    billableSeconds: 0,
    originId: worklogId,
    originTaskId: "" + issueId,
    remainingEstimate: null,
    endDate: null,
    includeNonWorkingDays: false
  };
  $j.ajax({
      headers: getTempoHeaders(),
      type: "PUT",
      url: "/rest/tempo-timesheets/4/worklogs/" + worklogId + "/",
      contentType: "application/json",
      data: JSON.stringify(pObj),
    }).done(successCallback)
    .fail(failCallback)
    .always(alwaysCallback);
}
function GetWorklogsForIssue(issueId, successCallback) {
  var url = "/rest/tempo-time-activities/1/issue/" + issueId + "/?page=1&size=9999&activityType=work&currentUser=false";
  $j.ajax({
    headers: getTempoHeaders(),
    type: "GET",
    url: url
  }).done(successCallback);
}
function GetWorklogById(worklogId, resolve, reject) {
  $j.ajax({
      headers: getTempoHeaders(),
      type: "GET",
      url: "/rest/tempo-timesheets/4/worklogs/" + worklogId
    }).done(resolve)
    .fail(reject);
}
function getTempoHeaders() {
  return {
    Accept: 'application/json, application/vnd-ms-excel',
    'Content-Type': 'application/json',
    Pragma: 'no-cache',
    'x-atlassian-force-account-id': 'true',
    Authorization: `Tempo-Bearer ${window.INITIAL_STATE.token.token}`,
    'Tempo-User-TimeZone': window.timezone || 'GMT',
  };
};