new-site/scripts/formation/holidays.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

304 lines
11 KiB
Python

"""
Holiday calendar for web automation scheduling.
Covers:
- US federal holidays (observed) — relevant to IRS, SSA, and US state portals
- Canadian federal statutory holidays — relevant to CRTC, BC Registry
- BC provincial holidays — relevant to BC Corporate Registry, BC Online
- State-specific observed holidays (closures vary by SOS office)
Usage:
from scripts.formation.holidays import is_holiday, next_business_day
if is_holiday(date.today(), jurisdiction="US"):
...
if is_holiday(date.today(), jurisdiction="BC"):
...
"""
from datetime import date, timedelta
from typing import Literal, Optional
Jurisdiction = Literal["US", "CA", "BC", "IRS"]
# ── Helpers ────────────────────────────────────────────────────────────────────
def _nth_weekday(year: int, month: int, weekday: int, n: int) -> date:
"""Return the nth occurrence of weekday (Mon=0 … Sun=6) in given month/year.
n=1 → first, n=2 → second, n=-1 → last.
"""
if n > 0:
first = date(year, month, 1)
offset = (weekday - first.weekday()) % 7
return first + timedelta(days=offset + 7 * (n - 1))
else: # last
# find last day, walk back
if month == 12:
last = date(year + 1, 1, 1) - timedelta(days=1)
else:
last = date(year, month + 1, 1) - timedelta(days=1)
offset = (last.weekday() - weekday) % 7
return last - timedelta(days=offset)
def _observed(d: date) -> date:
"""Return the observed date for a holiday falling on a weekend.
Saturday → Friday, Sunday → Monday.
"""
if d.weekday() == 5: # Saturday
return d - timedelta(days=1)
if d.weekday() == 6: # Sunday
return d + timedelta(days=1)
return d
# ── US Federal Holidays ────────────────────────────────────────────────────────
def _us_federal_holidays(year: int) -> set[date]:
"""Return the set of US federal holiday observed dates for a given year."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
holidays = set()
# New Year's Day — Jan 1
holidays.add(_observed(date(year, 1, 1)))
# Martin Luther King Jr. Day — 3rd Monday in January
holidays.add(_nth_weekday(year, 1, MON, 3))
# Presidents' Day (Washington's Birthday) — 3rd Monday in February
holidays.add(_nth_weekday(year, 2, MON, 3))
# Memorial Day — last Monday in May
holidays.add(_nth_weekday(year, 5, MON, -1))
# Juneteenth — June 19
holidays.add(_observed(date(year, 6, 19)))
# Independence Day — July 4
holidays.add(_observed(date(year, 7, 4)))
# Labor Day — 1st Monday in September
holidays.add(_nth_weekday(year, 9, MON, 1))
# Columbus Day — 2nd Monday in October
holidays.add(_nth_weekday(year, 10, MON, 2))
# Veterans Day — November 11
holidays.add(_observed(date(year, 11, 11)))
# Thanksgiving — 4th Thursday in November
holidays.add(_nth_weekday(year, 11, THU, 4))
# Christmas — December 25
holidays.add(_observed(date(year, 12, 25)))
# New Year's Day (observed for next year, sometimes Dec 31)
ny_next = _observed(date(year + 1, 1, 1))
if ny_next.year == year:
holidays.add(ny_next)
return holidays
# ── Canadian Federal Statutory Holidays ───────────────────────────────────────
def _canada_federal_holidays(year: int) -> set[date]:
"""Return Canadian federal statutory holiday dates for a given year."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
holidays = set()
# New Year's Day
holidays.add(_observed(date(year, 1, 1)))
# Good Friday — Friday before Easter
easter = _easter(year)
holidays.add(easter - timedelta(days=2)) # Good Friday
# Easter Monday
holidays.add(easter + timedelta(days=1))
# Victoria Day — Monday before May 25
may25 = date(year, 5, 25)
days_since_mon = may25.weekday() # Mon=0
if days_since_mon == 0:
holidays.add(may25 - timedelta(days=7))
else:
holidays.add(may25 - timedelta(days=days_since_mon))
# Canada Day — July 1
holidays.add(_observed(date(year, 7, 1)))
# Labour Day — 1st Monday in September
holidays.add(_nth_weekday(year, 9, MON, 1))
# National Day for Truth and Reconciliation — Sept 30 (federal)
holidays.add(_observed(date(year, 9, 30)))
# Thanksgiving — 2nd Monday in October
holidays.add(_nth_weekday(year, 10, MON, 2))
# Remembrance Day — November 11
holidays.add(_observed(date(year, 11, 11)))
# Christmas Day — December 25
holidays.add(_observed(date(year, 12, 25)))
# Boxing Day — December 26
holidays.add(_observed(date(year, 12, 26)))
return holidays
# ── BC Provincial Holidays ────────────────────────────────────────────────────
def _bc_holidays(year: int) -> set[date]:
"""Return BC provincial statutory holidays (superset of Canadian federal)."""
MON = 0
holidays = _canada_federal_holidays(year)
# BC Day — 1st Monday in August
holidays.add(_nth_weekday(year, 8, MON, 1))
# Family Day — 3rd Monday in February (BC-specific — started 2013)
if year >= 2013:
holidays.add(_nth_weekday(year, 2, MON, 3))
return holidays
# ── Easter (Gregorian) ────────────────────────────────────────────────────────
def _easter(year: int) -> date:
"""Return date of Easter Sunday using the Anonymous Gregorian algorithm."""
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
# ── Cache ─────────────────────────────────────────────────────────────────────
_cache: dict[tuple[int, str], set[date]] = {}
def _get_holidays(year: int, jurisdiction: Jurisdiction) -> set[date]:
key = (year, jurisdiction)
if key not in _cache:
if jurisdiction == "US" or jurisdiction == "IRS":
_cache[key] = _us_federal_holidays(year)
elif jurisdiction == "CA":
_cache[key] = _canada_federal_holidays(year)
elif jurisdiction == "BC":
_cache[key] = _bc_holidays(year)
else:
_cache[key] = set()
return _cache[key]
# ── Public API ────────────────────────────────────────────────────────────────
def is_holiday(d: date, jurisdiction: Jurisdiction = "US") -> bool:
"""Return True if `d` is a holiday in the given jurisdiction."""
return d in _get_holidays(d.year, jurisdiction)
def is_weekend(d: date) -> bool:
"""Return True if `d` is Saturday or Sunday."""
return d.weekday() >= 5
def is_business_day(d: date, jurisdiction: Jurisdiction = "US") -> bool:
"""Return True if `d` is a weekday and not a holiday."""
return not is_weekend(d) and not is_holiday(d, jurisdiction)
def next_business_day(
after: Optional[date] = None,
jurisdiction: Jurisdiction = "US",
) -> date:
"""Return the next business day after `after` (default: today)."""
d = (after or date.today()) + timedelta(days=1)
while not is_business_day(d, jurisdiction):
d += timedelta(days=1)
return d
def holiday_name(d: date, jurisdiction: Jurisdiction = "US") -> Optional[str]:
"""Return a human-readable name for the holiday on `d`, or None."""
# Build a labelled lookup for the year
labels = _labelled_holidays(d.year, jurisdiction)
return labels.get(d)
def _labelled_holidays(year: int, jurisdiction: Jurisdiction) -> dict[date, str]:
"""Return holiday dates mapped to their names."""
MON, TUE, WED, THU, FRI, SAT, SUN = range(7)
result: dict[date, str] = {}
if jurisdiction in ("US", "IRS"):
result[_observed(date(year, 1, 1))] = "New Year's Day"
result[_nth_weekday(year, 1, MON, 3)] = "Martin Luther King Jr. Day"
result[_nth_weekday(year, 2, MON, 3)] = "Presidents' Day"
result[_nth_weekday(year, 5, MON, -1)] = "Memorial Day"
result[_observed(date(year, 6, 19))] = "Juneteenth"
result[_observed(date(year, 7, 4))] = "Independence Day"
result[_nth_weekday(year, 9, MON, 1)] = "Labor Day"
result[_nth_weekday(year, 10, MON, 2)] = "Columbus Day"
result[_observed(date(year, 11, 11))] = "Veterans Day"
result[_nth_weekday(year, 11, THU, 4)] = "Thanksgiving"
result[_observed(date(year, 12, 25))] = "Christmas Day"
ny_next = _observed(date(year + 1, 1, 1))
if ny_next.year == year:
result[ny_next] = "New Year's Day (observed)"
elif jurisdiction in ("CA", "BC"):
easter = _easter(year)
result[_observed(date(year, 1, 1))] = "New Year's Day"
result[easter - timedelta(days=2)] = "Good Friday"
result[easter + timedelta(days=1)] = "Easter Monday"
may25 = date(year, 5, 25)
daysback = may25.weekday() if may25.weekday() > 0 else 7
result[may25 - timedelta(days=daysback)] = "Victoria Day"
result[_observed(date(year, 7, 1))] = "Canada Day"
result[_nth_weekday(year, 9, MON, 1)] = "Labour Day"
result[_observed(date(year, 9, 30))] = "National Day for Truth and Reconciliation"
result[_nth_weekday(year, 10, MON, 2)] = "Thanksgiving"
result[_observed(date(year, 11, 11))] = "Remembrance Day"
result[_observed(date(year, 12, 25))] = "Christmas Day"
result[_observed(date(year, 12, 26))] = "Boxing Day"
if jurisdiction == "BC":
result[_nth_weekday(year, 8, MON, 1)] = "BC Day"
if year >= 2013:
result[_nth_weekday(year, 2, MON, 3)] = "Family Day (BC)"
return result
def upcoming_holidays(
days: int = 30,
jurisdiction: Jurisdiction = "US",
from_date: Optional[date] = None,
) -> list[tuple[date, str]]:
"""Return (date, name) pairs for holidays in the next `days` days."""
start = from_date or date.today()
end = start + timedelta(days=days)
labelled = _labelled_holidays(start.year, jurisdiction)
if end.year != start.year:
labelled.update(_labelled_holidays(end.year, jurisdiction))
return sorted(
[(d, name) for d, name in labelled.items() if start <= d <= end],
key=lambda x: x[0],
)