Skip to content

Commit

Permalink
fix(missing-days): add tests for edge cases
Browse files Browse the repository at this point in the history
- added tests for edge cases
- fixed cases where since starts during the day and until ends days after
- updated README with a better intro, how to get started and what is to come
- also updated the CLI help message to include examples
- the script automatically does calculations in local timezone
  • Loading branch information
lonelydev committed Oct 15, 2024
1 parent 1818def commit 6da61b3
Show file tree
Hide file tree
Showing 6 changed files with 258 additions and 64 deletions.
77 changes: 63 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
# Introduction

In some organisations, engineers get compensated for going *on-call outside of working hours*. I have a couple of teams at Kaluza that I look after. Both with at least 4 people on the rota. We also have an incident commander rota. And every month, us managers have to fill in a spreadsheet for payroll to account for our on-call so that we all get paid at the end of the month for both on-call and our primary job. This sounds like a simple thing, and it is. Just that it takes about 5 minutes per team. If your team is geographically distributed, then you have more than one sheet to fill as each location has a different payroll and on-call rates and things.
In some organisations, engineers get compensated for going *on-call outside of working hours*.

I have a couple of teams at Kaluza that I look after. Both with at least 4 people on the rota. Similarly, we have an *incident commander* rota. And every month, managers have to fill in a spreadsheet for payroll to account for our OOH (out of hours) on-call so that we all get compensated at the end of the month for on-call. This sounds like a simple thing, and it is. Just that it takes about 5 minutes of reconciliation per team's on-call rota.
And if your team is geographically distributed, then you probably have more than one sheet to fill as each location might have a different payroll and could even have different on-call rates and maybe they even get compensated an additional amount every time they respond to an actual incident by the hour! Who knows?!

That is a lot of productive minutes lost doing the same thing every month. 1 manager with two teams, could take 10 minutes. So imagine having 24 of them do this monthly! 240 minutes of doing mundane things for the company when that could have been invested in more useful, creative work, like building the next big thing!

That's why I wrote `caloohpay` - a thing that calculates OOH Pay.

## How to get started?

Expand All @@ -10,6 +17,19 @@ Checkout `package.json` for the `npm` tasks used to build and run the project.

Don't forget to run `npm install` to install all dependencies, including [Pager Duty's JS Client](https://github.com/PagerDuty/pdjs).

1. Clone this repository locally
2. Create `.env` file with the key-value pair for `API_TOKEN` taken from Your Pagerduty Profile > User Settings > Create API User Token

Your `.env` file should look something like this:

```sh
API_TOKEN=u+IrTEYvqbPOc4dMNLyR
```

3. Run `npm install`
4. Run `npm link`
5. Run `caloohpay help`

### Tests with ts-jest

Followed instructions on [jest via ts-jest](https://jestjs.io/docs/getting-started#via-ts-jest).
Expand All @@ -21,21 +41,50 @@ Login to pagerduty, hover over your profile icon, go to *My Profile*. Then go to
## The CLI

```sh
Usage: calc-ooh-pay [options] [args]
caloohpay [options] <args>

Options:
--version Show version number [boolean]
-r, --rota-ids 1 scheduleId or multiple scheduleIds separated by comma [string] [required]
-t, --timeZoneId the timezone id of the schedule. Refer https://developer.pagerduty.com/docs/1afe25e9c94cb-types#time-zone for details. [string]
-s, --since start of the schedule period (inclusive) in https://en.wikipedia.org/wiki/ISO_8601 format [string]
-u, --until end of the schedule period (inclusive) in https://en.wikipedia.org/wiki/ISO_8601 format [string]
-k, --key API_TOKEN to override environment variable API_TOKEN.
Get your API User token from
My Profile -> User Settings -> API Access -> Create New API User Token [string]
-o, --output-file the path to the file where you want the on-call payments table printed [string]
--help Show help [boolean]

Examples:
caloohpay -r "PQRSTUV,PSTUVQR,PTUVSQR" Calculates on-call payments for the comma separated pagerduty scheduleIds. The default timezone is the local timezone. The default period is the previous month.
caloohpay -r "PQRSTUV" -s "2021-08-01T00:00:00+01:00" -u "2021-09-01T10:00:00+01:00" Calculates on-call payments for the schedules with the given scheduleIds for the month of August 2021.

```

Calculates the payments due for people that were on-call Out of Hours on Pagerduty
## What works?

options:
-r, --rotaIds <rotaIds> Rota(s), aka, schedule(s) on pagerduty, accepts comma separated list of scheduleIds - e.g. PTOC5MW or PTOC5MW,PLNOGFB
-k, --key <key> uses this key, instead of fetching from API_TOKEN env var
-o, --output-file <path/to/file> print the auditable on-call payments matrix in the file
-s, --since <dateISOString> date in YYYY-MM-DDTHH:mm:ss.sssZ format
-u, --until <dateISOString> date in YYYY-MM-DDTHH:mm:ss.sssZ format
-h, --help display this usage message
The solution currently accepts `rotaIds` and assigns defaults to the start and end dates if it isn't provided:

reference:
- more on dateISO - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString
```
- `since` : first day of the previous month with time starting at `00:00:00` in local time
- `until` : first day of the current month with the time `10:00:00` in local time

The latter is set to the value it is to ensure the calculator includes the evening of the last day of the previous month.

## What is to come?

- allow CLI to accept just date in YYYYMMDD format. Allow the script to automatically creates the time range.
- allow weekday and weekends to be configurable
- allow weekday rates and weekend rates to be configurable
- add some colour to console output
- add file generation for output
- make installable package
- host this on our internal developer platform and schedule it to run monthly and create a CSV file of auditable on call payment records and send it to the finance team

## How does it work?
## References

- [Time Zones on PagerDuty](https://developer.pagerduty.com/docs/1afe25e9c94cb-types)
- [Time Zones in Javascript](https://stackoverflow.com/a/54500197/2262959)
- [Jest Docs](https://jestjs.io/docs/getting-started)
- [ts-node docs](https://typestrong.org/ts-node/docs/)
- [yargs documentation - not beginner friendly](https://yargs.js.org/docs/)
- [Retrieve time zones in nodejs environments](https://stackoverflow.com/a/44096051/2262959)
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 52 additions & 35 deletions src/CalOohPay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import { api } from '@pagerduty/pdjs';
import * as dotenv from 'dotenv';
import { hideBin } from 'yargs/helpers';
import yargs, { Argv, ArgumentsCamelCase } from "yargs";
import yargs from "yargs";
import { OnCallUser } from './OnCallUser';
import { OnCallPeriod } from './OnCallPeriod';
import { FinalSchedule } from './FinalSchedule';
import { KaluzaOnCallPaymentsCalculator } from './KaluzaOnCallPaymentsCalculator';
import { ScheduleEntry } from './ScheduleEntry';
import { CommandLineOptions } from './CommandLineOptions.js';

dotenv.config();

Expand All @@ -24,6 +25,17 @@ function sanitiseEnvVariable(envVars: NodeJS.ProcessEnv): Environment {
};
}

function toLocalIsoStringWithOffset(date: Date): string {
var timezoneOffsetInMilliseconds = date.getTimezoneOffset() * 60000;
var localISOTime = (new Date(date.getTime() - timezoneOffsetInMilliseconds)).toISOString().slice(0, -5);
let timezoneOffsetInHours = - (timezoneOffsetInMilliseconds / 3600000);
let localISOTimeWithOffset =
localISOTime +
(timezoneOffsetInHours >= 0 ? '+' : '-') +
(Math.abs(timezoneOffsetInHours) < 10 ? '0' : '') +
timezoneOffsetInHours + ':00';
return localISOTimeWithOffset;
}
const sanitisedEnvVars: Environment = sanitiseEnvVariable(process.env);

const yargsInstance = yargs(hideBin(process.argv));
Expand All @@ -38,37 +50,48 @@ const argv: CommandLineOptions = yargsInstance
demandOption: true,
example: 'R1234567,R7654321'
})
.option('timeZoneId', {
type: 'string',
demandOption: false,
alias: 't',
description: 'the timezone id of the schedule. Refer https://developer.pagerduty.com/docs/1afe25e9c94cb-types#time-zone for details.'
})
.default('t', function defaultTimeZoneId(): string {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
})
.option('since', {
type: 'string',
alias: 's',
description: 'start of the schedule period',
description: 'start of the schedule period (inclusive) in https://en.wikipedia.org/wiki/ISO_8601 format',
example: '2021-08-01T00:00:00+01:00'
})
.default('s', function firstDayOfPreviousMonth(): string {
let today = new Date();
return new Date(Date.UTC(today.getUTCFullYear(), (today.getUTCMonth() - 1), 1)).toISOString();
})
return toLocalIsoStringWithOffset(new Date(new Date(today.getFullYear(), (today.getMonth() - 1), 1)));
}, 'the first day of the previous month')
.option('until', {
type: 'string',
alias: 'u',
description: 'end of the schedule period',
description: 'end of the schedule period (inclusive) in https://en.wikipedia.org/wiki/ISO_8601 format',
example: '2021-08-01T00:00:00+01:00'
})
.default('u', function lastDayOfPreviousMonth(): string {
let today = new Date();
return new Date(
Date.UTC(
today.getUTCFullYear(),
today.getUTCMonth(),
0,
23,
59,
59)
).toISOString();
})
return toLocalIsoStringWithOffset(new Date(
new Date(
today.getFullYear(),
today.getMonth(),
1,
10)
));
}, 'the first day of the this month')
.option('key', {
type: 'string',
demandOption: false,
alias: 'k',
description: 'this command line argument API_TOKEN to override environment variable API_TOKEN'
description: 'API_TOKEN to override environment variable API_TOKEN.\n' +
'Get your API User token from \n' +
'My Profile -> User Settings -> API Access -> Create New API User Token'
})
.option('output-file', {
type: 'string',
Expand All @@ -81,6 +104,12 @@ const argv: CommandLineOptions = yargsInstance
alias: 'h',
description: 'Show help'
})
.example([
['caloohpay -r "PQRSTUV,PSTUVQR,PTUVSQR"',
'Calculates on-call payments for the comma separated pagerduty scheduleIds. The default timezone is the local timezone. The default period is the previous month.'],
['caloohpay -r "PQRSTUV" -s "2021-08-01T00:00:00+01:00" -u "2021-09-01T10:00:00+01:00"',
'Calculates on-call payments for the schedules with the given scheduleIds for the month of August 2021.'],
])
.help()
.check((argv) => {
if (argv.since && !Date.parse(argv.since)) {
Expand All @@ -94,30 +123,20 @@ const argv: CommandLineOptions = yargsInstance

calOohPay(argv);


interface CommandLineOptions {
rotaIds: string;
since: string;
until: string;
key: string;
outputFile: string;
help: boolean;
}

function getOnCallUserFromScheduleEntry(scheduleEntry: ScheduleEntry): OnCallUser {
let onCallPeriod = new OnCallPeriod(scheduleEntry.start, scheduleEntry.end);
let onCallUser = new OnCallUser(
scheduleEntry.user?.id || "",
scheduleEntry.user?.id || "",
scheduleEntry.user?.summary || "", [onCallPeriod]);
return onCallUser
}

function extractOnCallUsersFromFinalSchedule(finalSchedule: FinalSchedule): Record<string,OnCallUser> {
let onCallUsers: Record<string,OnCallUser> = {};
if(finalSchedule.rendered_schedule_entries){
function extractOnCallUsersFromFinalSchedule(finalSchedule: FinalSchedule): Record<string, OnCallUser> {
let onCallUsers: Record<string, OnCallUser> = {};
if (finalSchedule.rendered_schedule_entries) {
finalSchedule.rendered_schedule_entries.forEach(scheduleEntry => {
let onCallUser = getOnCallUserFromScheduleEntry(scheduleEntry);
if(onCallUser.id in onCallUsers){
if (onCallUser.id in onCallUsers) {
onCallUsers[onCallUser.id].addOnCallPeriods(onCallUser.onCallPeriods);
} else {
onCallUsers[onCallUser.id] = onCallUser;
Expand All @@ -129,22 +148,20 @@ function extractOnCallUsersFromFinalSchedule(finalSchedule: FinalSchedule): Reco

function calOohPay(cliOptions: CommandLineOptions) {
const pagerDutyApi = api({ token: sanitisedEnvVars.API_TOKEN });
console.log("CLI Options: %s", JSON.stringify(cliOptions));
// invoke the pd api to get schedule data
for (let rotaId of cliOptions.rotaIds.split(',')) {
console.log(`Fetching schedule data for rotaId: ${rotaId} between ${cliOptions.since} and ${cliOptions.until}`);
pagerDutyApi
.get(`/schedules/${rotaId}`,
{
data: {
overflow: false,
since: cliOptions.since,
time_zone: "Europe/London",
time_zone: cliOptions.timeZoneId,
until: cliOptions.until
}
}
).then(
({ data, resource, response, next }) => {
console.log('-'.repeat(process.stdout.columns || 80));
console.log("Schedule name: %s", data.schedule.name);
console.log("Schedule URL: %s", data.schedule.html_url);
let onCallUsers = extractOnCallUsersFromFinalSchedule(data.schedule.final_schedule);
Expand Down
9 changes: 9 additions & 0 deletions src/CommandLineOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface CommandLineOptions {
rotaIds: string;
since: string;
until: string;
timeZoneId: string;
key: string;
outputFile: string;
help: boolean;
}
55 changes: 47 additions & 8 deletions src/OnCallPeriod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ export class OnCallPeriod {

readonly since: Date;
readonly until: Date;

private _numberOfOohWeekDays: number = 0;
private _numberOfOohWeekendDays: number = 0;

constructor (s:Date, u:Date) {
constructor(s: Date, u: Date) {
this.since = new Date(s);
this.until = new Date(u);
this.initializeOohWeekDayAndWeekendDayCount();
Expand All @@ -22,7 +22,6 @@ export class OnCallPeriod {
this._numberOfOohWeekendDays++;
}
}

curDate.setDate(curDate.getDate() + 1);
}
}
Expand All @@ -38,11 +37,51 @@ export class OnCallPeriod {
private static isWeekDay(dayNum: number): boolean {
return dayNum > 0 && dayNum < 5;
}

private static wasPersonOnCallOOH(dateToCheck: Date, onCallUntilDate: Date): boolean {
var dateToCheckEvening = new Date(dateToCheck);
dateToCheckEvening.setHours(18);
return (dateToCheckEvening > dateToCheck && dateToCheckEvening < onCallUntilDate)

/**
* Currently works on the assumption that dates don't have anything to do with timezones
* and anything after 6pm in whichever timezone the date is in is considered evening.
* @param since start of the shift
* @param until end of the shift
* @returns true if the person was on call OOH, false otherwise
*/
private static wasPersonOnCallOOH(since: Date, until: Date): boolean {
/**
* if dateToCheck in the evening after 6pm and onCallUntilDate is at least 12 hours
* longer than dateToCheck, then the person was on call OOH
*/
return (OnCallPeriod.doesShiftSpanEveningTillNextDay(since, until) &&
OnCallPeriod.isShiftLongerThan6Hours(since, until));
}

private static doesShiftSpanEveningTillNextDay(since: Date, until: Date): boolean {
/**
* a shift could start during working hours and end after the working hours
* a shift could start after working hours and end after the working hours
*/
let endOfWorkingHours = new Date(since);
endOfWorkingHours.setHours(17, 30);
return (endOfWorkingHours < until) &&
OnCallPeriod.doesShiftSpanDays(since, until);
}

private static isShiftLongerThan6Hours(date: Date, onCallUntilDate: Date): boolean {
return (onCallUntilDate.getTime() - date.getTime()) >= 6 * 60 * 60 * 1000;
}

private static doesShiftSpanDays(since: Date, until: Date): boolean {
/**
* if the dates are not the same, then the shift spans days
* this is to cover all cases, whether the shift since is end of a month
* and until is early in the next month.
*/
return (since.getDate() !== until.getDate());
}

private static convertTZ(date: Date, timeZoneId: string): Date {
return new Date(
(typeof date === "string" ? new Date(date) : date)
.toLocaleString("en-GB", {timeZone: timeZoneId}));
}

toString() {
Expand Down
Loading

0 comments on commit 6da61b3

Please sign in to comment.