Calculate '3rd Monday in May' script

Wrote this a while ago in a rougher form, but I’ve given it some love recently and made it simpler to update and maintain.

It’s on GitHub - Special Day if anyone feels like adding more interesting or different ‘special’ days.

The point is to be able to programatically work out all those annoying dates that are the ‘second Monday of the month’ or ‘48 days after Easter’ and generally don’t fall on a specific date (although it does those too so you can have dates being flagged from the same place).

Check For Special Day

Check if today is a special day against a set of calculated and pre-set special days and set a text helper with the description of the day, and a boolean helper if the day is special.

The special days are defined in a dictionary in the script, and can be modified to add or remove special days as needed. The script calculates the date of Easter for the current year, and uses that to determine the dates of Easter-related special days.

Requirements

Requires two helper variables to be defined:

  input_boolean.special_day - set to true if today is a special day, or false
  input_text.special_day_text - set to the text description, or empty

Special Day Syntax

The special_days dictionary uses the following syntax to specify a date:

<base> [ ± <count><unit>[@<weekday>] ]

where:

  base        Base date or Easter (Easter, 1/5, 25/12)
  ±           Plus or minus
  <count>     Integer number (3, 1, 4)
  <unit>      d, w, or m (+3d, -1w)
  @<weekday>  Optional weekday for that offset (@Mon, @Sun)

Some examples:

Easter+39d - 39 days after Easter
1/5+1@Mon - First Monday after 1st May
1/7+3w@Mon - Monday in the third week of July
25/12-4@Sun - 4th Sunday before Christmas
1/4-1@Sun - Last Sunday before April
6/5+2w - 2 weeks after the 6th of May

Example Automation

alias: Daily Special Day Check
trigger:
  - platform: time
    at: '00:00:00'
action:
  - service: script.check_for_special_day
mode: single

Here is the full script:

alias: Check For Special Day
sequence:
  - variables:
      special_text: |-
        {%- set special_days = {
          "New Year's Day": "1/1",
          "Ninth Night": "2/1",
          "Tenth Night": "3/1",
          "Eleventh Night": "4/1",
          "Twelfth Night": "5/1",
          "Epiphany": "6/1",
          "Burns Night": "25/1",

          "Shrove Monday": "easter-48d",
          "Pancake Day": "Easter-47d",
          "Ash Wednesday": "Easter-46d",
          "the first Sunday of Lent": "Easter-42d",
          "the second Sunday of Lent": "Easter-35d",
          "the third Sunday of Lent": "Easter-28d",
          "Mothering Sunday": "Easter-21d",
          "Passion Sunday": "Easter-14d",
          "Palm Sunday": "Easter-7d",
          "Holy Monday": "Easter-6d",
          "Holy Tuesday": "Easter-5d",
          "Holy Wednesday": "Easter-4d",
          "Maundy Thursday": "Easter-3d",
          "Good Friday": "Easter-2d",
          "Holy Saturday": "Easter-1d",
          "Easter Day": "Easter",
          "Easter Monday": "Easter+1d",
          "Easter Tuesday": "Easter+2d",
          "Easter Wednesday": "Easter+3d",
          "Easter Thursday": "Easter+4d",
          "Easter Friday": "Easter+5d",
          "Easter Saturday": "Easter+6d",
          "Divine Mercy Sunday": "Easter+7d",
          "Ascension Day": "Easter+39d",
          "Ascension Sunday": "Easter+42d",
          "Pentecost": "Easter+49d",
          "Trinity Sunday": "Easter+56d",
          "the Feast of the Sacred Heart": "Easter+68d",

          "Candlemas": "2/2",
          "Groundhog Day": "2/2",
          "Super Bowl Sunday": "1/2+2@sun",
          "Valentines Day": "14/2",

          "the day the clocks move forward": "1/4-1@sun",
          "St David's Day": "1/3",
          "St Patrick's Day": "17/3",

          "Aril Fool's Day": "1/4",
          "St George's Day": "23/4",
          "Terry Pratchett Day": "28/4",

          "the Early May Bank Holiday": "1/5+1@mon",
          "May Day": "1/5",
          "Star Wars Day": "4/5",
          "the Revenge of the Fifth (-o-)": "5/5",
          "Coronation Day": "6/5",
          "the Spring Bank Holiday": "1/6-1@mon",

          "Fathers Day": "1/6+3@sun",
          "Trooping of the Colour": "1/6+2@sat",
          "St John's Eve": "23/6",
          "the Nativity of John the Baptist": "24/6",

          "the start of Swan Upping week": "1/7+3w@mon",
          "Saint Swithin's Day": "15/7",

          "the Summer Bank Holiday": "31/8-1@mon",
          "the Perseids Meteor Shower peak viewing night": "12/8",

          "the Feast of the Nativity of Mary": "8/9",
          "Michaelmas": "29/9",

          "the day the clocks move backwards": "31/10-1@sun",
          "Halloween": "31/10",

          "All Saints' Day": "1/11",
          "All Souls' Day": "2/11",
          "Guy Fawkes Night": "5/11",
          "Thanksgiving": "1/11+4@thu",
          "Remembrance Day": "11/11",
          "St Andrew's Day": "30/11",
          "Remembrance Sunday": "1/11+2@sun",

          "the Feast of the Immaculate Conception": "8/12",
          "the First Advent Sunday": "25/12-4@sun",
          "the Second Advent Sunday": "25/12-3@sun",
          "the Third Advent Sunday": "25/12-2@sun",
          "the Fourth Advent Sunday": "25/12-1@sun",
          "Christmas Eve": "24/12",
          "Christmas Day": "25/12",
          "Boxing Day": "26/12",
          "Third Night": "27/12",
          "Fourth Night": "28/12",
          "Fifth Night": "29/12",
          "Sixth Night": "30/12",
          "New Year's Eve": "31/12"
        } -%}

        {# DO NOT EDIT BELOW HERE #}

        {%- macro ordinal_suffix_of(i) -%}
          {%- set j = i % 10 -%}
          {%- set k = i % 100 -%}
          {%- if j == 1 and k != 11 -%} {{ i|string + "st" }}
          {%- elif j == 2 and k != 12 -%} {{ i|string + "nd" }}
          {%- elif j == 3 and k!= 13 -%} {{ i|string + "rd" }}
          {%- else -%} {{ i|string + "th" }}
          {%- endif -%}
        {%- endmacro -%}

        {%- macro textify_list(r=[]) -%}
          {%- if r | count > 1 -%}
            {{- (r[:-1] | join(', ')) + ' and ' + r[-1] | string -}}
          {%- else -%}
            {{- r | join('') -}}
          {%- endif -%}
        {%- endmacro -%}

        {%- macro day_of(d1, d2) -%}
          {{ (d2-d1).days + 1 }}
        {%- endmacro -%}

        {%- macro easter_date(Y) -%}
          {%- set G = Y % 19 + 1 -%}
          {%- set C = (Y / 100) | int + 1 -%}
          {%- set X = (3*C / 4) | int - 12 -%}
          {%- set Z = ((8*C + 5) / 25) | int - 5 -%}
          {%- set D = (5*Y / 4)| int - X - 10 -%}
          {%- set E = (11*G + 20 + Z - X ) % 30 -%}
          {%- set E = E+1 if (E == 25 and G > 11 or E == 24) else E -%}
          {%- set N = 44 - E -%}
          {%- set N = N+30 if N < 21 else N -%}
          {%- set N = N + 7 - (( D + N ) % 7) -%}
          {%- set M = 4 if N > 31 else 3 -%}
          {%- set N = N-31 if N > 31 else N -%}
          {{ as_local(as_datetime('%04d-%02d-%02dT00:00:00'%(Y,M,N))) }}
        {%- endmacro -%}

        {%- macro get_special_date(date_str) -%}
          {%- set pattern = '(\\d{1,2}/\\d{1,2}|easter)([+-])?(\\d+)?(d|w|m)?(?:@)?(mon|tue|wed|thu|fri|sat|sun)?' -%}
          {%- set match = date_str | lower | regex_findall(pattern) -%}
          {%- if match -%}
            {%- set m = match[0] -%}
            {%- set base_date = m[0] -%}
            {%- set modifier = m[1] -%}
            {%- set amount = m[2]|int if m[2] else 0 -%}
            {%- set unit = m[3] -%}
            {%- set weekday = m[4] -%}

            {%- if base_date == 'easter' -%}
              {%- set date = easter -%}
            {%- else -%}
              {%- set date = as_local(strptime(base_date ~ '/' ~ now().year, '%d/%m/%Y', none)) -%}
            {%- endif -%}

            {%- if modifier -%}
              {%- set direction = 1 if modifier == '+' else -1 -%}

              {%- if unit == 'd' or (not unit and not weekday) -%}
                {%- set date = date + timedelta(days=direction * amount) -%}

              {%- elif unit == 'w' and weekday -%}
                {# Week-based weekday offset (e.g. +3w@mon) #}
                {%- set month_start = date.replace(day=1) -%}
                {%- set days = {'mon':1,'tue':2,'wed':3,'thu':4,'fri':5,'sat':6,'sun':7} -%}
                {%- set target = days[weekday] -%}
                {%- if direction == 1 -%}
                  {%- set first_day = month_start.isoweekday() -%}
                  {%- set week1_monday = month_start - timedelta(days=first_day - 1) -%}
                  {%- set date = week1_monday + timedelta(days=(target - 1) + 7 * (amount - 1)) -%}
                {%- else -%}
                  {%- set next_month = date.replace(day=28) + timedelta(days=4) -%}
                  {%- set month_end = next_month.replace(day=1) - timedelta(days=1) -%}
                  {%- set last_day = month_end.isoweekday() -%}
                  {%- set days_to_last = (last_day - target) % 7 -%}
                  {%- set last_occurrence = month_end - timedelta(days=days_to_last) -%}
                  {%- set date = last_occurrence - timedelta(days=7 * (amount - 1)) -%}
                {%- endif -%}

              {%- elif weekday -%}
                {# Nth weekday occurrence after base date (e.g. +3@mon) #}
                {%- set days = {'mon':1,'tue':2,'wed':3,'thu':4,'fri':5,'sat':6,'sun':7} -%}
                {%- set current = date.isoweekday() -%}
                {%- set target = days[weekday] -%}

                {%- if direction == 1 -%}
                  {%- set days_ahead = (target - current) % 7 -%}
                  {%- set date = date + timedelta(days=days_ahead + 7 * (amount - 1)) -%}
                {%- else -%}
                  {%- set days_back = (current - target) % 7 -%}
                  {%- set date = date - timedelta(days=days_back + 7 * (amount - 1)) -%}
                {%- endif -%}
              {%- endif -%}
            {%- endif -%}

            {{- as_local(date) -}}
          {%- endif -%}
        {%- endmacro -%}

        {%- set cur_date =
        as_local(as_datetime('%04d-%02d-%02dT00:00:00'%(now().year, now().month,
        now().day))) -%}

        {# set cur_date = as_local(as_datetime('%04d-%02d-%02dT00:00:00'%(2025,
        3, 4))) #}

        {%- set cur_year = cur_date.year -%}
        {%- set easter = as_local(as_datetime(easter_date(now().year))) -%}
        {%- set events = namespace(special_events=[]) -%}

        {# Check bank holidays #}
        {%- set christmas_eve =
        as_local(as_datetime('%04d-12-24T00:00:00'%(cur_year))) -%}
        {%- set christmas_day =
        as_local(as_datetime('%04d-12-25T00:00:00'%(cur_year))) -%}
        {%- set boxing_day =
        as_local(as_datetime('%04d-12-26T00:00:00'%(cur_year))) -%}
        {%- set new_years_day =
        as_local(as_datetime('%04d-01-01T00:00:00'%(cur_year))) -%}

        {# Christmas Day Bank Holiday #}
        {%- if christmas_day.isoweekday() in [6,7] and cur_date ==
        as_local(christmas_day + timedelta(days=2)) -%}
          {%- set events.special_events = events.special_events + ["Christmas Day Bank Holiday"] -%}
        {%- endif -%}

        {# Boxing Day Bank Holiday #}
        {%- if boxing_day.isoweekday() in [6,7] and cur_date ==
        as_local(boxing_day + timedelta(days=2)) -%}
          {%- set events.special_events = events.special_events + ["Boxing Day Bank Holiday"] -%}
        {%- elif christmas_day.isoweekday() == 5 and cur_date ==
        as_local(christmas_day + timedelta(days=3)) -%}
          {%- set events.special_events = events.special_events + ["Boxing Day Bank Holiday"] -%}
        {%- endif -%}

        {# New Year's Day Bank Holiday #}
        {%- if new_years_day.isoweekday() == 6 and cur_date ==
        as_local(new_years_day + timedelta(days=2)) -%}
          {%- set events.special_events = events.special_events + ["New Year's Day Bank Holiday"] -%}
        {%- elif new_years_day.isoweekday() == 7 and cur_date ==
        as_local(new_years_day + timedelta(days=1)) -%}
          {%- set events.special_events = events.special_events + ["New Year's Day Bank Holiday"] -%}
        {%- endif -%}

        {# Check special days #}
        {%- for day_name, date_str in special_days.items() -%}
          {%- set special_date = get_special_date(date_str) -%}
          {%- if special_date and cur_date == as_local(as_datetime(special_date)) -%}
            {%- set events.special_events = events.special_events + [day_name] -%}
          {%- endif -%}
        {%- endfor -%}

        {# If not found anything then see if it's lent or advent #}
        {%- if events.special_events | length == 0  -%}
          {# Check for days of lent #}
          {%- set ash_wednesday = as_local(easter - timedelta(days=46)) -%}
          {%- if cur_date > ash_wednesday and cur_date < easter -%}
            {%- set days_since_ash_wednesday = day_of(ash_wednesday, cur_date) | int(0) -%}
            {%- set sundays_since_ash_wednesday = ((days_since_ash_wednesday + 2) // 7) -%}
            {%- set day_of_lent = days_since_ash_wednesday - sundays_since_ash_wednesday -%}
            {%- set events.special_events = events.special_events + ["the " + ordinal_suffix_of(day_of_lent) + " day of Lent"] -%}
            {# If you count sundays as part of lent... #}
            {# set events.special_events = events.special_events + ["the " + ordinal_suffix_of((day_of(ash_wednesday, cur_date)|int(0))) + " day of Lent"] #}
          {%- endif -%}

          {# Check for days of advent #}
          {%- set first_advent = as_local(christmas_day - timedelta(days = 21 + (christmas_day.isoweekday()))) -%}
          {%- if cur_date > first_advent and cur_date < christmas_eve -%}
            {%- set events.special_events = events.special_events + ["the " + ordinal_suffix_of((day_of(first_advent, cur_date)|int(0))) + " day of Advent"] -%}
          {%- endif -%}
        {%- endif -%}

        {# Check moon phases #}
        {%- if states('sensor.moon') == "new_moon" -%}
          {%- set events.special_events = events.special_events + ["a new moon"] -%}
        {%- elif states('sensor.moon') == "full_moon" -%}
          {%- set events.special_events = events.special_events + ["a full moon"] -%}
        {%- endif -%}

        {{ textify_list(events.special_events) }}
  - if:
      - condition: template
        value_template: "{{ special_text != '' }}"
    then:
      - data:
          value: "{{ special_text }}"
        target:
          entity_id: input_text.special_day_text
        action: input_text.set_value
      - data: {}
        target:
          entity_id: input_boolean.special_day
        action: input_boolean.turn_on
    else:
      - data:
          value: ""
        target:
          entity_id: input_text.special_day_text
        action: input_text.set_value
      - data: {}
        target:
          entity_id: input_boolean.special_day
        action: input_boolean.turn_off
mode: queued
max: 10
icon: mdi:calendar
2 Likes

that’s pretty cool…

can the script calculate/handle a “moveable” day that is not necessarily anchored like the Easter centric days? For example make 1st Feb “special” but only if on a Friday and if it’s not Friday then make the first Monday in Feb the special day (so eg should be Mon Feb 2nd in 2026)…

Think that would require some extension to the syntax - could add @weekday @weekend or perhaps @mon,tue type syntax?