diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0bb4b1a..6047b39 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 70c32c9..7621777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/check-versions.sh b/check-versions.sh new file mode 100755 index 0000000..e6fa255 --- /dev/null +++ b/check-versions.sh @@ -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 diff --git a/manifest.json b/manifest.json index 8c19217..4cb61fc 100644 --- a/manifest.json +++ b/manifest.json @@ -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" diff --git a/src/TimeChimpApi.ts b/src/TimeChimpApi.ts index 0140cea..8a42488 100644 --- a/src/TimeChimpApi.ts +++ b/src/TimeChimpApi.ts @@ -46,6 +46,7 @@ export class TimeChimpApi { export interface User { id: number; userName: string; + contractHours?: number; } export interface Time { diff --git a/src/content/add-billability-chart.ts b/src/content/add-billability-chart.ts index a3f131b..c4a0ecf 100644 --- a/src/content/add-billability-chart.ts +++ b/src/content/add-billability-chart.ts @@ -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(); @@ -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'); @@ -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; } /** diff --git a/src/content/chart.ts b/src/content/chart.ts index 0d931c5..2687e92 100644 --- a/src/content/chart.ts +++ b/src/content/chart.ts @@ -17,6 +17,7 @@ const TC_BLURPLE = '#6559d2'; export function createOrUpdateChart( rollingStats: RollingStats[], + relativeToContractHours: boolean, billableColor?: string, element?: HTMLElement, ) { @@ -43,7 +44,8 @@ export function createOrUpdateChart( }, yAxis: { min: 0, - max: 100, + softMax: 100, + max: relativeToContractHours ? undefined : 100, tickInterval: 25, title: { text: undefined, @@ -52,6 +54,14 @@ export function createOrUpdateChart( format: '{text}%', style: textStyle, }, + plotLines: relativeToContractHours + ? [ + { + value: 100, + width: 2, + }, + ] + : undefined, }, tooltip: { shared: true, diff --git a/src/content/index.ts b/src/content/index.ts index 4253e7f..56fe771 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -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. diff --git a/src/content/settings.ts b/src/content/settings.ts new file mode 100644 index 0000000..44e7f44 --- /dev/null +++ b/src/content/settings.ts @@ -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 = { + ...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)); +} diff --git a/src/content/stats.ts b/src/content/stats.ts index 2a56dbd..17916e7 100644 --- a/src/content/stats.ts +++ b/src/content/stats.ts @@ -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(); } @@ -59,20 +60,32 @@ function removeLeaveOnlyWeeks(timesByYearWeek: TimesByYearWeek) { }); } -function calculateStatsPerWeek(timesByYearWeek: TimesByYearWeek) { +function calculateStatsPerWeek( + timesByYearWeek: TimesByYearWeek, + contractHours?: number, +) { return Object.entries(timesByYearWeek) .map(([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)), @@ -85,10 +98,16 @@ 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) => @@ -96,6 +115,17 @@ function calculateStatsPerWeek(timesByYearWeek: TimesByYearWeek) { ); } +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, diff --git a/src/content/style.css b/src/content/style.css index e3e2ead..6941907 100644 --- a/src/content/style.css +++ b/src/content/style.css @@ -1,4 +1,8 @@ -.billability-card { - padding-top: 4px; +.billability-card > .chart { height: 350px; } + +.billability-card > .actions > button { + margin-right: 20px; + margin-bottom: 20px; +}