Toggl - Weekly report

Calculate and display the work day percentages

// ==UserScript==
// @name         Toggl - Weekly report
// @namespace    https://github.com/fabiencrassat
// @version      0.8.6
// @description  Calculate and display the work day percentages
// @author       Fabien Crassat <fabien@crassat.com>
// @include      https://toggl.com/app/*
// @grant        none
// ==/UserScript==

/* global $ */

/* eslint no-console: ["error", { allow: ["info", "warn", "error"] }] */

'use strict';

const weekDaySunday = 0;
const weekDayMonday = 1;
const weekDayTuesday = 2;
const weekDayWednesday = 3;
const weekDayThursday = 4;
const weekDayFriday = 5;
const weekDaySaturday = 6;

const weekDays = [
  weekDaySunday,
  weekDayMonday,
  weekDayTuesday,
  weekDayWednesday,
  weekDayThursday,
  weekDayFriday,
  weekDaySaturday
];
// eslint-disable-next-line max-len, prefer-named-capture-group
const urlToFollow = /^https:\/\/toggl\.com\/app\/reports\/weekly\/\d+\/period\/([a-z])\w+/u;
// eslint-disable-next-line max-len
const apiTimeEntriesUrlToFollow = 'https://toggl.com/reports/api/v3/workspace/2294752/weekly/time_entries';
const apiProjectsUrlToFollow = 'https://toggl.com/api/v9/me/projects';
const decimalLenght = 2;

let projects = [];

// Must be on the global scope
const oldFetch = fetch;

const buildFetch = function buildFetch(doSomethingWithResponse) {
  // We want to overwrite fetch with our new one
  // eslint-disable-next-line no-global-assign
  fetch = function fetch(url, options) {
    const promise = oldFetch(url, options);
    // Do something with the promise
    promise.then(doSomethingWithResponse).catch(error => {
      console.error('Error in fetch processing', error);
    });
    return promise;
  };
};
const backFetch = function backFetch() {
  // eslint-disable-next-line no-global-assign
  fetch = oldFetch;
};

const sleep = function sleep() {
  const timeout = 1500;
  // eslint-disable-next-line no-promise-executor-return
  return new Promise(resolve => setTimeout(resolve, timeout));
};

const cleanDisplay = function cleanDisplay() {
  $('.fcr-toggl').remove();
};

const percentage = function percentage(numerator, denumerator) {
  const denumeratorIsZero = 0;
  if (!numerator || !denumerator || denumerator === denumeratorIsZero) {
    return denumeratorIsZero;
  }
  return numerator / denumerator;
};

const getProjectName = function getProjectName(project) {
  if (project) {
    return project.name;
  }
  return null;
};

/**
 * The weeklyData argument in the V3 API has this values
 * [
 *   {
 *     'user_id': 2644339,
 *     'project_id': 150741509,
 *     'seconds': // Mon, Tue, Wed, Thu, Fri, Sat, Sun
 *       [0, 7200, 0, 0, 0, 0, 0],
 *   },
 *   {...}
 * ]
 *
 * The result:
 * [
 *   {
 *     client: '',
 *     project: 'name',
 *     data: // Mon, Tue, Wed, Thu, Fri, Sat, Sun
 *       [0, 0.23, 0, 0, 0, 0, 0],
 *     conso: sum(weeklyData[].seconds) / sum(allProjectsDays),
 *   },
 *   {...}
 * ]
 */
const calculate = function calculate(weeklyData) {
  const projectSum = {};
  const daysSum = [];
  weeklyData.forEach(line => {
    // Sum the line
    projectSum[line.project_id] = line.seconds.reduce((acc, cur) => acc + cur);
    // Sum the days
    weekDays.forEach(day => {
      const initialValue = 0;
      daysSum[day] = (daysSum[day] || initialValue) + line.seconds[day];
    });
  });

  // Sum the week
  const weekSum = daysSum.reduce((acc, cur) => acc + cur);

  const result = [];
  weeklyData.forEach(line => {
    const data = [];
    line.seconds.forEach((day, index) => {
      data.push(percentage(day, daysSum[index]));
    });
    const project = projects.find(prj => prj.id === line.project_id);
    result.push({
      client: '',
      conso: percentage(projectSum[line.project_id], weekSum),
      data,
      project: getProjectName(project)
    });
  });

  return result;
};

const filterDataFromProject = function filterDataFromProject(
  data,
  lineElement
) {
  const text = $(lineElement)
    .find('.css-70qvj9.efdmxuc2 > span:first-child')
    .text();
  if (text.trim() === 'Without project') {
    return data.find(value => value.project === null);
  }
  return data.find(value => value.project === text);
};

const displayValue = function displayValue(value) {
  return `<p class="fcr-toggl">${value}</p>`;
};

const defaultDataValue = 0;

const getDataValue = function getDataValue(columnsLength, indexColumn, data) {
  // eslint-disable-next-line no-magic-numbers
  if (columnsLength === indexColumn + 1) {
    return data.conso || defaultDataValue;
  }
  return data.data[indexColumn] || defaultDataValue;
};

const displayInTheLine = function displayInTheLine(lineElement, data) {
  // For each line, select only days and total columns
  const columns = $(lineElement).find('.euf6jrl1');
  // eslint-disable-next-line no-magic-numbers
  if (columns.length <= 0) {
    console.warn('There is no display column', columns);
    return;
  }
  columns.each(function displayColumn(indexColumn) {
    const dataInCeil = getDataValue(columns.length, indexColumn, data);
    if (dataInCeil !== defaultDataValue) {
      // eslint-disable-next-line no-invalid-this
      $(this).append(displayValue(dataInCeil.toFixed(decimalLenght)));
    }
  });
};

const display = function display(data = []) {
  // Select the data line in the tab
  const displayLines = $('.css-1v0lzu.euf6jrl0:not(:first, :last)');
  // eslint-disable-next-line no-magic-numbers
  if (!displayLines || displayLines.length === 0) {
    console.warn('There is no display line', displayLines);
    return;
  }
  displayLines.each(function displayLine() {
    // eslint-disable-next-line no-invalid-this
    displayInTheLine(this, filterDataFromProject(data, this));
  });
};

const calculateAndDisplay = async function calculateAndDisplay(data) {
  // Need to wait to the table built
  await sleep();
  cleanDisplay();
  display(calculate(data));
};

const fillProjects = async function fillProjects(data = []) {
  projects = await data.map(project => ({
    clientId: project.client_id,
    id: project.id,
    name: project.name
  }));
};

const checkResponseAndUrl = function checkResponseAndUrl(
  response,
  url,
  apiRUl
) {
  const responseStatus200 = 200;
  return response.ok &&
    response.status === responseStatus200 &&
    url &&
    url.startsWith(apiRUl);
};

const response = function response(responseToClone) {
  // Clone to consume json body stream response
  const responseClone = responseToClone.clone();
  const { url } = responseToClone;
  if (checkResponseAndUrl(responseClone, url, apiProjectsUrlToFollow)) {
    console.info('Url to follow found!', url);
    responseClone.json().then(fillProjects);
  }
  if (checkResponseAndUrl(responseClone, url, apiTimeEntriesUrlToFollow)) {
    console.info('Url to follow found!', url);
    responseClone.json().then(calculateAndDisplay);
  }
};

const fireOnChange = function fireOnChange(url = '') {
  // Check if we are in the good page
  if (urlToFollow.test(url)) {
    buildFetch(response);
    return true;
  }
  backFetch();
  return false;
};

console.info('== Toggl - Weekly report ==');
// Follow the HTML5 url change in the API browser
(function followUrl(old) {
  window.history.pushState = function pushState(...args) {
    old.apply(window.history, args);
    fireOnChange(window.location.href);
  };
}(window.history.pushState));
fireOnChange(location.href);

// Add CSS
const styleElement = document.createElement('style');
const textNode = '.fcr-toggl { padding-left: 8px; }';
styleElement.appendChild(document.createTextNode(textNode));

(document.body || document.head || document.documentElement)
  .appendChild(styleElement);