Skip to content

Commit

Permalink
Add mode for calculating billability relative to contract hours (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
LucaScorpion authored Feb 3, 2024
1 parent ecc3684 commit bd50702
Show file tree
Hide file tree
Showing 11 changed files with 218 additions and 30 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: ./check-versions.sh
- uses: actions/setup-node@v3
with:
node-version: 20
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## v1.7.0

- Add mode for viewing chart and stats relative to contract hours.

## v1.6.0

- Load theme color for billable section of the chart from company configuration.
Expand Down
32 changes: 32 additions & 0 deletions check-versions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -euo pipefail

if ( ! command -v jq > /dev/null )
then
echo 'The jq command is required for this script.'
exit 1
fi

allMatch=1
function checkVersionMatch() {
echo "- $1: $2"
if [ ! "$manifestVersion" = "$2" ]
then
allMatch=0
fi
}

echo "Detected versions:"

manifestVersion=$(jq -r .version 'manifest.json')
checkVersionMatch 'manifest.json' "$manifestVersion"

checkVersionMatch 'CHANGELOG.md' "$(sed -nE 's/^## v(.*)$/\1/p' 'CHANGELOG.md' | head -n 1)"

if [ ! "$allMatch" = 1 ]
then
echo 'Not all versions match.'
exit 1
else
echo 'All version match!'
fi
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "TimeChimp Billability Chart",
"description": "Adds a billability chart on the TimeChimp hours page.",
"version": "1.6.0",
"version": "1.7.0",
"manifest_version": 3,
"permissions": [
"webRequest"
Expand Down
1 change: 1 addition & 0 deletions src/TimeChimpApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class TimeChimpApi {
export interface User {
id: number;
userName: string;
contractHours?: number;
}

export interface Time {
Expand Down
65 changes: 55 additions & 10 deletions src/content/add-billability-chart.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { createOrUpdateChart } from './chart';
import { TimeChimpApi } from '../TimeChimpApi';
import { TimeChimpApi, User } from '../TimeChimpApi';
import { toIsoDate } from '../date';
import { endOfWeek, getWeek, startOfWeek, subWeeks } from 'date-fns';
import { calculateTimeStats } from './stats';
import { getSettings, updateSettings } from './settings';
import { render } from './index';

const api = new TimeChimpApi();

Expand All @@ -17,13 +19,13 @@ const GET_TIMES_WEEKS = SHOW_WEEKS + ROLLING_AVG_WEEKS * 2;
/**
* Adds a billability chart on basis of times for the given date from TimeChimp.
*/
export async function addBillabilityChart(date: Date, userId: number) {
await doAddBillabilityChart(date, userId).catch((e) =>
export async function addBillabilityChart(date: Date, user: User) {
await doAddBillabilityChart(date, user).catch((e) =>
console.error(`Error when adding billability chart: ${e}`),
);
}

async function doAddBillabilityChart(date: Date, userId: number) {
async function doAddBillabilityChart(date: Date, user: User) {
const addTimePanel = document.querySelector('.col-md-4');
if (!addTimePanel?.querySelector('form[name="addTimeForm"]')) {
console.debug('Add time form not found, returning');
Expand All @@ -34,23 +36,66 @@ async function doAddBillabilityChart(date: Date, userId: number) {
// If not, create a new element which can be used as the chart parent.
let chartContainer: HTMLElement | undefined;
if (!addTimePanel.querySelector('#billability-card')) {
chartContainer = addTimePanel.appendChild(createBillabilityCard());
chartContainer = createBillabilityCard(addTimePanel);
}

const [times, company] = await Promise.all([
getTimes(userId, date, GET_TIMES_WEEKS),
getTimes(user.id, date, GET_TIMES_WEEKS),
api.getCompany(),
]);

const stats = calculateTimeStats(times, SHOW_WEEKS, ROLLING_AVG_WEEKS);
createOrUpdateChart(stats, company.theme?.mainColor, chartContainer);
const settings = getSettings();

const stats = calculateTimeStats(
times,
settings.relativeToContractHours ? user.contractHours : undefined,
SHOW_WEEKS,
ROLLING_AVG_WEEKS,
);
createOrUpdateChart(
stats,
settings.relativeToContractHours,
company.theme?.mainColor,
chartContainer,
);
}

function createBillabilityCard() {
function createBillabilityCard(addTimePanel: Element) {
const card = document.createElement('div');
card.className = 'card billability-card';
card.id = 'billability-card';
return card;

const chartContainer = document.createElement('div');
chartContainer.className = 'chart';
card.appendChild(chartContainer);

const actions = document.createElement('div');
card.appendChild(actions);
actions.className = 'actions text-align-right';

const toggleViewBtn = document.createElement('button');

const setBtnText = () => {
toggleViewBtn.textContent = `Relatief aan: ${
getSettings().relativeToContractHours
? 'contracturen'
: 'uren gewerkt'
}`;
};
setBtnText();

actions.appendChild(toggleViewBtn);
toggleViewBtn.className = 'btn btn-timechimp-border';
toggleViewBtn.addEventListener('click', () => {
updateSettings({
relativeToContractHours: !getSettings().relativeToContractHours,
});
setBtnText();
render();
});

addTimePanel.appendChild(card);
return chartContainer;
}

/**
Expand Down
12 changes: 11 additions & 1 deletion src/content/chart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const TC_BLURPLE = '#6559d2';

export function createOrUpdateChart(
rollingStats: RollingStats[],
relativeToContractHours: boolean,
billableColor?: string,
element?: HTMLElement,
) {
Expand All @@ -43,7 +44,8 @@ export function createOrUpdateChart(
},
yAxis: {
min: 0,
max: 100,
softMax: 100,
max: relativeToContractHours ? undefined : 100,
tickInterval: 25,
title: {
text: undefined,
Expand All @@ -52,6 +54,14 @@ export function createOrUpdateChart(
format: '{text}%',
style: textStyle,
},
plotLines: relativeToContractHours
? [
{
value: 100,
width: 2,
},
]
: undefined,
},
tooltip: {
shared: true,
Expand Down
15 changes: 8 additions & 7 deletions src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,16 @@ chrome.runtime.onMessage.addListener(async (msg: Message) => {
currentDate = new Date(msg.date);
}

if (
!currentUser ||
(msg.userName && msg.userName !== currentUser.userName)
) {
currentUser = await getUser(msg.userName);
await render(msg.userName);
});

export async function render(userName?: string) {
if (!currentUser || (userName && userName !== currentUser.userName)) {
currentUser = await getUser(userName);
}

await addBillabilityChart(currentDate, currentUser.id);
});
await addBillabilityChart(currentDate, currentUser);
}

/**
* Get the user info based on a userName.
Expand Down
60 changes: 60 additions & 0 deletions src/content/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const STORAGE_KEY = 'tcbc-settings';

export interface Settings {
relativeToContractHours: boolean;
}

let settings: Settings | undefined;

const DEFAULT_SETTINGS: Settings = {
relativeToContractHours: false,
};

export function getSettings(): Settings {
// Try to load the settings.
if (!settings) {
tryLoadSettings();
}

// If the settings are still unset, loading failed.
// Set the default settings.
if (!settings) {
settings = { ...DEFAULT_SETTINGS };
saveSettings();
}

return settings;
}

export function updateSettings(updates: Partial<Settings>) {
settings = {
...DEFAULT_SETTINGS,
...settings,
...updates,
};
saveSettings();
}

function tryLoadSettings() {
try {
loadSettings();
} catch (e) {
console.error(`Failed to load ${STORAGE_KEY}: ${e}`);
}
}

function loadSettings() {
const str = localStorage.getItem(STORAGE_KEY);
if (!str) {
return;
}

const obj = JSON.parse(str);
settings = {
relativeToContractHours: obj.relativeToContractHours,
};
}

function saveSettings() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
48 changes: 39 additions & 9 deletions src/content/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ export interface RollingStats extends Stats {

export function calculateTimeStats(
times: Time[],
contractHours: number | undefined,
showWeeks: number,
rollWeeks: number,
) {
const timesByYearWeek = groupTimesByYearWeek(times);
removeLeaveOnlyWeeks(timesByYearWeek);
const stats = calculateStatsPerWeek(timesByYearWeek);
const stats = calculateStatsPerWeek(timesByYearWeek, contractHours);
const rollingStats = calculateRollingStats(stats, showWeeks, rollWeeks);
return rollingStats.reverse();
}
Expand All @@ -59,20 +60,32 @@ function removeLeaveOnlyWeeks(timesByYearWeek: TimesByYearWeek) {
});
}

function calculateStatsPerWeek(timesByYearWeek: TimesByYearWeek) {
function calculateStatsPerWeek(
timesByYearWeek: TimesByYearWeek,
contractHours?: number,
) {
return Object.entries(timesByYearWeek)
.map<Stats>(([yearWeekStr, times]) => {
const billableHours = sum(
times.filter((t) => t.billable).map((t) => t.hours),
);
const nonBillableHours = sum(
let nonBillableHours = sum(
times.filter((t) => !t.billable).map((t) => t.hours),
);
const excludedLeaveHours = sum(
times
.filter(
(t) => !t.billable && !LEAVE_TASKS.includes(t.taskName),
(t) => !t.billable && LEAVE_TASKS.includes(t.taskName),
)
.map((t) => t.hours),
);
const totalHoursWithoutLeave = billableHours + nonBillableHours;

// If we are not calculating relative to contract hours,
// exclude leave tasks from the non-billable (and thus total) hours.
if (!contractHours) {
nonBillableHours -= excludedLeaveHours;
}
const totalHours = billableHours + nonBillableHours;

return {
year: Number(yearWeekStr.substring(0, 4)),
Expand All @@ -85,17 +98,34 @@ function calculateStatsPerWeek(timesByYearWeek: TimesByYearWeek) {
.filter((t) => LEAVE_TASKS.includes(t.taskName))
.map((t) => t.hours),
),
billableHoursPercentage:
(100 * billableHours) / totalHoursWithoutLeave,
nonBillableHoursPercentage:
(100 * nonBillableHours) / totalHoursWithoutLeave,
billableHoursPercentage: calculateHoursPercentage(
billableHours,
totalHours,
contractHours,
),
nonBillableHoursPercentage: calculateHoursPercentage(
nonBillableHours,
totalHours,
contractHours,
),
};
})
.sort((a, b) =>
a.year === b.year ? b.week - a.week : b.year - a.year,
);
}

function calculateHoursPercentage(
hours: number,
totalHours: number,
contractHours?: number,
) {
// Here we specifically want to use || instead of ??,
// since we also want to use totalHours if contractHours is 0.
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
return (100 * hours) / (contractHours || totalHours);
}

function calculateRollingStats(
stats: Stats[],
showWeeks: number,
Expand Down
8 changes: 6 additions & 2 deletions src/content/style.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.billability-card {
padding-top: 4px;
.billability-card > .chart {
height: 350px;
}

.billability-card > .actions > button {
margin-right: 20px;
margin-bottom: 20px;
}

0 comments on commit bd50702

Please sign in to comment.