Detecting new and shifting public holidays with Ruby
Tags: ruby, rails
Published: 2022-09-24 06:38:04

Our app has a few features that involve patient communications, specifically around not sending those communications out when there’s a public holiday (when a clinic might be closed and unable to handle incoming calls).

Countries all have different holidays, but that also applies to regions and cities. Because people from all across the country use our app, we account for that. We’ll detect if there’s a holiday or not for a given set of users based on location, which will affect those comms going out.

In the last couple of years, some interesting cases had confused customers because holidays started behaving weirdly, sending out things when we weren’t expecting to, or not sending things when we should.

We use the holidays gem for detecting holidays on given dates, so I’ll go over what we ran into and how some previously-unused features of the gem helped us.

**tl;dr**
- We had a case of an existing public holiday shifting from when it would usually occur, and we needed to account for the new date.
- We had a case of a new, one-time public holiday that we needed to define ahead of time.

## The basics

The holidays gem uses a series of yaml definition files that list all of the holidays in a given region. An example of one of these definitions is this one for New Year’s Day:

```yaml
months:
  1:
    - name: New Year’s Day
      regions: [au, au_nsw, au_vic, au_act, au_sa, au_wa, au_nt, au_qld]
      mday: 1
      observed: to_monday_if_weekend(date)
```

There are a few parts here:
- You specify the month the holiday occurs.
- You can define more granular regions observe the holiday. In this case, New Year’s Day applies to all states in Australia as well as whole AU region.
- You can define the day of the month it occurs on (you can also specify the week number it occurs in and the day of that week)
- You can also define functions to handle the definition. In this case, handling moving the observed date to the following Monday if the holiday falls on a weekend. You can also just use a function to handle everything if things are particularly complex.

Then when you want to check if there’s a holiday in a certain date, you can check it in the following way:

```ruby
Holidays.on(Date.civil(2008, 4, 25), :au)
=> [{:name => ‘ANZAC Day’,…}]
```

## Holidays that don’t want to sit still

One of the interesting gotchas from the past couple of years with COVID is having to handle holidays that shift around due to extenuating circumstances.

In this particular case, we had support tickets from confused Brisbane customers asking us why things weren’t being sent out as expected on a random Wednesday in August. It turns out this holiday had a series of interesting cases from 2020–2021. In 2020, it got moved to a Friday when it usually occurs on a Wednesday, and in 2021 it was moved to a later date (October some time). On this most recent occurrence in 2022, it was a case of the holiday being detected by `holidays` on the wrong Wednesday in August.

Taking a bit of a closer look, we found the holiday defined as follows:

```yaml
8:
  - name: Ekka
    regions: [au_qld_brisbane]
    week: -3
    wday: 3
```

It turns out that this mostly works, but, is actually a little _too_ simple for how the holiday actually occurs. As taken from the [QLD public holidays page](https://www.qld.gov.au/recreation/travel/holidays/public):

>The Royal National Agricultural (RNA) Show Day (Brisbane only) is held on the Wednesday during the RNA Show period. The RNA Show commences on the first Friday in August, unless the first Friday is prior to 5 August, then it commences on the second Friday of August.

So the holiday has a bit of a dynamic aspect not currently being taken into account. On a [recent post](https://www.antonivanopoulos.com/hunting-down-spooky-json-module-redefinition-via-oj/), I had someone comment that they had used my post as a launchpad for making a change in an open source project, and that inspired me to go and sort this out myself. It ended up being [a pretty simple change](https://github.com/holidays/definitions/pull/229) (my first proper OSS contribution, hell yeah), but essentially changes the holiday definition to:

```
- name: Ekka
  regions: [au_qld_brisbane]
  function: qld_brisbane_ekka_holiday(year)
```

With an accompanying function (I found writing this ruby function in yaml to be pretty funky but we got there):

```yaml
qld_brisbane_ekka_holiday:
  # https://www.qld.gov.au/recreation/travel/holidays/public
  # The Ekka holiday occurs on the Wednesday during the RNA Show perido, the RNA show occurs on the first Friday of August, unless that’s prior to August 5, then it occurs on the second.  arguments: year
  ruby: |
    first_friday = Holidays::Factory::DateCalculator.day_of_month_calculator.call(year, 8, :first, :friday)
    if first_friday < 5
      second_friday = Date.civil(year, 8, Holidays::Factory::DateCalculator.day_of_month_calculator.call(year, 8, :second, :friday))
      second_friday + 5 # The next Wednesday
    else
      Date.civil(year, 8, first_friday) + 5
    end
```

Something I found pretty cool while putting this together is how the tests are done, they’re also in the yaml file. The development flow has you changing the branch that the `holidays/definitions` submodule under `holidays/holidays` is using, running some make commands to generate the functions and tests, then running those tests

```yaml
- given:
    date: ‘2019–08–14’
    regions: [“au_qld_brisbane”]
  expect:
    name: “Ekka”
- given:
    date: ‘2022–08–10
    regions: [“au_qld_brisbane”]
  expect:
    name: “Ekka”
```

I added some extra tests for 2023/24, so we’re ready to rock when next year rolls around.

## New public holidays

With the recent passing of Her Majesty Queen Elizabeth II, it was announced that Australia would have a Nation Day of Mourning on September 22nd and that it would be a public holiday.

The `holidays` gem allows you to [load in custom holidays](https://github.com/holidays/holidays#loading-custom-definitions-on-the-fly), so we opted to go that route in the short term. We added a new initializer to load in a custom `holidays.yml` file that contained the definition (shout out to Alex for putting this together):

```ruby
# config/initializers/holidays.rb
Holidays.load_custom(“#{__dir__}/holidays.yaml”)
```

And then the definition:
```yaml
# Custom holiday definitions for the holidays gem (https://github.com/holidays/holidays)
#
# See https://github.com/holidays/definitions/blob/master/doc/SYNTAX.md— -months:
  9:
    - name: National Day of Mourning for Queen Elizabeth II
      regions: [au]
      mday: 22
      year_ranges:
        limited: [2022]
```

## Moving forward

This was all pretty straightforward stuff and I definitely don’t expect us to be needing to do these kinds of changes often (the definition for the Day of Mourning [is already merged into the gem itself](https://github.com/holidays/definitions/pull/232)), but I really enjoyed getting a better look at the internals of this gem. Holidays are a bit of a hairy beast (especially when you have new ones popping up), but it was interesting to poke at some of the features on offer here that we hadn’t really had to play with before.

Some final questions I’m left with:
- Can you override the definition of an existing holiday with something new?
- Can you exclude holidays from the bundled definitions? (It seems like an open point of discussion in [this issue](https://github.com/holidays/holidays/issues/240))