// ==UserScript==
// @name Jortt hour and project overview
// @namespace https://www.gears-for-engineers.com/
// @version 2024-03-01
// @description Make an overview of all registered hours, default fixed hourly rate
// @license MIT
// @author timdrijvers
// @match https://app.jortt.nl/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=jortt.nl
// @grant GM.setValue
// @grant GM.getValue
// ==/UserScript==
(function() {
'use strict';
////////////////////////////////////////////////////////////////////////
// Bunch of helper functions
////////////////////////////////////////////////////////////////////////
function injectCSS(node, css) {
let el = document.createElement('style');
el.type = 'text/css';
el.innerText = css;
node.appendChild(el);
return el;
}
function prefixzero(d) {
return (d > 9 ? '' : '0') + d;
}
function yyyymmdd(d) {
var mm = d.getMonth() + 1; // getMonth() is zero-based
var dd = d.getDate();
return [d.getFullYear(),
prefixzero(mm),
prefixzero(dd)
].join('-');
}
function row(columns, className="") {
let row = document.createElement("tr");
if (className !== "") {
row.className = className;
}
for (const col of columns) {
let cell = document.createElement("td");
cell.append(col);
row.append(cell);
}
return row;
}
function elem(type, attr, ...children) {
let el = document.createElement(type);
if (attr !== undefined) {
el = Object.assign(el, attr);
}
for (let child of children) {
el.append(child);
}
return el;
}
function whiteContainer(...children) {
return elem("div", {"style": "background-color: #fff; padding: 20px; margin-top: 20px;"}, ...children);
}
function triggerInput(input, newValue) {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype, 'value'
).set;
nativeInputValueSetter.call(input, newValue);
const event = new Event('input', { bubbles: true });
input.dispatchEvent(event);
}
function findAggregateId() {
// Hours of projects get stored in projects, which are called aggregates
const match = window.location.href.match("/aggregate_id/([^/]+)");
if (match === null) {
return null;
}
return match[1];
}
////////////////////////////////////////////////////////////////////////
// Callback functions that do the actual work.
// Get triggered based on new elements with an ID of the map below
////////////////////////////////////////////////////////////////////////
const callbackFunctions = {
"projects-new": (node) => {
//
// Allow user to set a default hourly rate when editing a project
//
// Only when we're editing show this form
const projectId = findAggregateId()
if (projectId === null) {
return;
}
const cacheKey = "fixedprice:" + projectId;
let rootForm = node.shadowRoot.querySelector("form");
injectCSS(
rootForm,
".timd-custom-form div.button {display: flex; align-items: flex-end; justify-content: end; margin-top: 16px;}\n"+
".timd-custom-form button {border-radius: 8px; line-height: 1.1 !important; font-weight: 600; align-items: center; justify-content: center; gap: 6px; min-height: 38px; padding: 4px 10px; background-color: #39c; color: #fff; border: 1px solid transparent; cursor: pointer; }\n"+
".timd-custom-form label {display: block; min-height: 21px; color: #000; font-size: 14px; font-weight: 600;}\n"+
".timd-custom-form div.input {display: flex; border: 1px solid #E0E0E0; border-radius: 6px; height: 38px; line-height: 1.15; padding: 0 10px;}\n"+
".timd-custom-form input {font-size: 14px; font-weight: 400; line-height: 1.5; border: none; flex-grow: 1; width: 100%;}"
);
let button = elem("button", {}, "Opslaan");
let input = elem("input", {});
let container = whiteContainer(
elem("label", {}, "Standaard uurtarief"),
elem("div", {"className": "input"}, input),
elem("div",{"className": "button"}, button)
);
container.className = "timd-custom-form";
rootForm.parentNode.after(container);
button.addEventListener("click", function () {
if (isNaN(parseFloat(input.value.replace(/,/, '.')))) {
alert("Geen geldig nummer");
return;
}
GM.setValue(cacheKey, input.value);
});
GM.getValue(cacheKey, "").then((result) => {input.value = result});
},
"project-line-item-edit": (node) => {
//
// Set a default hourly rate when adding a new line item
//
const projectId = findAggregateId()
if (projectId === null) {
return;
}
const cacheKey = "fixedprice:" + projectId;
GM.getValue(cacheKey, "").then((fixedPrice) => {
if (fixedPrice !== "") {
triggerInput(node.shadowRoot.querySelector("input[name='line_item_amount']"), fixedPrice);
}
});
},
"projects-list": (node) => {
//
// Render a table with all hours of projects aggregated in a single overview for this month
//
// Create our own container
let rootContainer = node.shadowRoot.querySelector("div[class*='PageLayout__Scrollable']");
let container = whiteContainer();
rootContainer.appendChild(container);
injectCSS(
container,
".timd-custom-table {width: 100%; border-collapse: collapse; }\n" +
".timd-custom-table thead tr {color: #39c; font-weight: 600; border-bottom: 1px solid #39c;}\n" +
".timd-custom-table td {padding: 10px;}\n" +
".timd-custom-table tr.first {border-top: 1px solid #39c;}\n" +
".timd-custom-table tr.weekend {background-color: #eee;}\n" +
".timd-custom-table tr.today {font-weight: bold;}\n" +
".timd-custom-table tbody tr:hover {background-color: #F3FBFF; }\n"
);
const today = new Date();
container.appendChild(
elem(
"h2",
{"style": "font-size: 18px; line-height: 24px;"},
"Samenvatting: "+today.toLocaleString('default',{ month: 'long' })
)
);
let table = container.appendChild(
elem(
"table",
{"className": "timd-custom-table"},
elem("thead", {}, row(["Dag", "Project", "Uren"]))
)
);
let tableBody = table.appendChild(document.createElement("tbody"));
fetch('/next_js/page/projects/list?', {method: 'GET'})
.then(Result => Result.json())
.then(response => {
// Generate urls for all projects
const urls = response.projects.map(
(project) => "/next_js/page/projects/show?period_cycle=month&period_date="+yyyymmdd(today)+"&aggregate_id="+project.aggregate_id
);
// Start fetching aggregated statistics for all projects
var requests = urls.map(url => fetch(url).then(response => response.json()));
Promise.all(requests)
.then(
(results) => {
// Helpers
const daysOfWeek = ["Zo", "Ma", "Di", "Wo", "Do", "Vr", "Za"];
const isWeekend = (day) => day == 0 || day == 6;
const getProjectName = (project) => project.name + (project.customer_name !== null ? " | "+project.customer_name : "");
// Aggregate statistics for all projects date => [project lines]
let aggregated = {};
for (const project of results) {
for (const line_item of project.project_line_item_records) {
if (!(line_item.date in aggregated)) {
aggregated[line_item.date] = [];
}
aggregated[line_item.date].push({"project": getProjectName(project.project), "hours": line_item.quantity});
}
}
// Fill the table
let currentYear = today.getFullYear();
let currentMonth = today.getMonth();
let currentDay = today.getDate();
let daysOfMonth = new Date(currentYear, currentMonth+1, 0).getDate();
for (let day = 1; day <= daysOfMonth; day++) {
let first = true;
const dayKey = currentYear+"-"+prefixzero(currentMonth+1)+"-"+prefixzero(day);
const dayOfWeek = new Date(currentYear, currentMonth, day).getDay();
const dayCell = day+" - "+daysOfWeek[dayOfWeek];
const classNames = (f) => [
f?"first":"",
isWeekend(dayOfWeek)?"weekend": "",
currentDay == day ? "today" : ""
].join(" ");
if (!(dayKey in aggregated)) {
tableBody.append(row(
[dayCell, "", ""],
classNames(first)
));
continue;
}
for (const line_item of aggregated[dayKey]) {
tableBody.append(
row([
dayCell,
line_item.project,
line_item.hours
],classNames(first))
);
first = false;
}
}
}
);
});
}
};
////////////////////////////////////////////////////////////////////////
// Setup MutationObserver to keep track of the application's state
////////////////////////////////////////////////////////////////////////
// Select the node that will be observed for mutations
const targetNode = document.getElementById("next_js_app-root");
const config = { childList: true, subtree: true };
// Callback function to execute when mutations are observed
const callback = (mutationList, observer) => {
for (const mutation of mutationList) {
if (mutation.type === "childList") {
for (const addedNode of mutation.addedNodes) {
if (addedNode.id in callbackFunctions) {
callbackFunctions[addedNode.id](addedNode);
}
}
}
}
};
// Create an observer instance linked to the callback function
const observer = new MutationObserver(callback);
// Start observing the target node for configured mutations
observer.observe(targetNode, config);
})();