Toggl - Weekly report

Calculate and display the work day percentages

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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 <[email protected]>
// @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);