In my current job, and for much of my last job, I induct new employees and make some of the arrangements for leaving employees. Part of that is working out how much annual leave they’re entitled to. Unfortunately people tend not to start exactly at the beginning of the leave year and leave exactly at the end of the leave year.

Many years ago I wrote a simple Python script to do this for me — it asks for the start and end dates, and prints out how much annual leave the person would accrue over that period.

You can run the calculator in your browser at Repl.it. (It has an odd name in the URL because originally the script only handled new starts, not leavers, and I don’t want to break the URL for my old colleagues who use it.)

I’ve cleaned it up today after seeing that Repl.it can publish new repos to GitHub (though that feature has some rough edges).

You can find the code on GitHub. Mostly it’s uninteresting, the first 40 lines being the module docstring and the last 30 being mostly wrappers around input(), so here’s the meat of it:

 1# Modify these constants to suit your circumstances
 2DEFAULT_AL_YEAR_START = date.today().replace(month=1, day=1)
 3DEFAULT_AL = 28
 4RESULT_DECIMAL_PLACES = 2
 5
 6
 7def main() -> None:
 8    al_for_full_year = prompt_for_al_amount()
 9
10    al_year_start = prompt_for_date(
11        "Leave year start",
12        default=DEFAULT_AL_YEAR_START
13    )
14    al_year_end = al_year_start.replace(
15        year=al_year_start.year + 1
16    ) - timedelta(days=1)
17
18    start_date = prompt_for_date("Employee start", default=al_year_start)
19    end_date = prompt_for_date("Employee finish", default=al_year_end)
20
21    al_year_days = (al_year_end - al_year_start).days + 1
22    employed_days = (end_date - start_date).days + 1
23    # +1 as we assume, eg, starting and leaving on Jan 1 accrues
24    # 1 day's worth of leave, not zero
25
26    proportion_of_al_year_worked = employed_days / al_year_days
27    al_days_available = al_for_full_year * proportion_of_al_year_worked
28    print(
29        round(al_days_available, RESULT_DECIMAL_PLACES),
30        "days annual leave"
31    )

One thing to state up front is that this only considers leave accrual within a single annual leave year. Crossing a leave-year boundary isn’t as simple as adding additional leave, as it’ll typically involve some limit on how much leave can be carried across (which may be zero).

There’s also little error-handling, so if you enter something that parses but is nonsensical (negative amount of leave, an end date earlier than the start date) then the result will be nonsensical.

Until I refactored the script today, I’d made assumptions about the leave year that meant you’d have to edit the script more than a little to use leave years that don’t match the calendar year. I changed that today by prompting the user for the start of the leave year (defaulting to January 1) and calculating the leave year end with some basic date manipulation in lines 14-16. (This was to fix a regression I introduced, not the result of any great foresight!)

This manipulation isn’t completely robust, but if you say your leave year starts on February 29 then that’s your responsibility.

Lines 21 & 22 are noteworthy for the + 1, so that you get an inclusive range of days, with the assumption being that the person works on the “start day” and also on the “finish day”. There’s some redundancy between lines 16 and 21, calculating the leave year by subtracting a day and adding it back later, but that’s to fit my mental model that the leave year runs eg from January 1 to December 31, and not January 1 to January 1.

The rest of the script just works out the proportion of the leave year worked against the length of the full leave year, and computes the same proportion of the total number of leave days available for the full year.

Nothing really tricky, but I work in a small company so it’s easy to misremember the process when you only do it a couple of times a year.

Here’s an example session:

How many days annual leave for the full year? [28] 30
Leave year start date [2020-01-01]: 2020-04-01
Employee start date [2020-04-01]:
Employee finish date [2021-03-31]: 2020-06-05
5.42 days annual leave

It’s a bit awkward to put in a “start date” for employees who have been employed since before the start of the leave year, and similar for employees who (you hope!) will continue past the end of the leave year, but the prompting helpers take a default value which you can accept by pressing return.

There’s no need to review the prompting helper functions but I will take a moment to appreciate the signature of the typed wrapper around input(). It is generic over some type T, takes a function str → T, and a default T, which is returned if the user input is empty.

T = TypeVar("T")
def _prompt_wrapper(
    message: str,
    parser: Callable[[str], T],
    default: T
) -> T: ...

A more general version would probably take a T? as the default and perhaps have T? as the return type also if parsing fails. But in this case all callers supply a default and, as far as the callers are concerned, parsing never fails because the user is prompted repeatedly until they enter something that does parse (or they accept the default).