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>
304 lines
11 KiB
Python
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],
|
|
)
|