Skip to content

Latest commit

 

History

History
262 lines (200 loc) · 8.75 KB

File metadata and controls

262 lines (200 loc) · 8.75 KB

20250102-holidays-gist

Gist file: https://gist.github.com/rjvitorino/4a8356707be032b82fa419ca37b5f283

Description: Cassidy's interview question of the week: given a year, a script that determines weekdays and dates for US holidays (New Year's, Easter, Memorial Day, Independence Day, Thanksgiving, Christmas)

holidays.py

"""
Holiday date calculation module.

This module provides functionality to calculate dates and weekdays for various holidays
in the Gregorian calendar, including both fixed-date holidays (like Christmas and New
Years Day) and floating holidays (like Easter and Thanksgiving).
"""

from datetime import date, timedelta
from enum import Enum
from dataclasses import dataclass
from typing import Optional

WEEKDAYS = [
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
    "Sunday",
]


@dataclass
class HolidayInfo:
    """Information about a holiday.

    Attributes:
        name: Display name of the holiday
        month: Month number (1-12, 0 for special cases like Easter)
        day: Day of the month for fixed-date holidays
        weekday: Day of week (0=Monday, 6=Sunday) for floating holidays
        week_of_month: Week number (1=first, -1=last) for floating holidays
        fixed_weekday: Name of weekday for holidays always falling on the same weekday
    """

    name: str
    month: int
    day: Optional[int] = None
    weekday: Optional[int] = None  # 0=Monday, 6=Sunday
    week_of_month: Optional[int] = None  # 1=first, -1=last
    fixed_weekday: Optional[str] = None  # Holidays always falling on the same weekday


class Holiday(Enum):
    """Enumeration of supported holidays with their date information."""

    NEW_YEARS_DAY = HolidayInfo("New Year's Day", month=1, day=1)
    INDEPENDENCE_DAY = HolidayInfo("Independence Day", month=7, day=4)
    CHRISTMAS_DAY = HolidayInfo("Christmas Day", month=12, day=25)
    THANKSGIVING = HolidayInfo(
        "Thanksgiving Day", month=11, week_of_month=4, fixed_weekday="Thursday"
    )  # 4th Thursday of November
    MEMORIAL_DAY = HolidayInfo(
        "Memorial Day", month=5, weekday=0, week_of_month=-1
    )  # Last Monday of May
    EASTER = HolidayInfo(
        "Easter Sunday", month=0, day=0
    )  # Special case, requires calculation


class HolidayCalculator:
    """Calculator for determining dates and weekdays of various holidays."""

    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def get_holiday_date(self, holiday: Holiday, year: int) -> date:
        """Get the date for a specific holiday in a given year.

        Args:
            holiday: The holiday to calculate
            year: The year for which to calculate the holiday

        Returns:
            date: The calculated date of the holiday

        Raises:
            ValueError: If the holiday cannot be calculated with the given parameters
        """
        info = holiday.value

        # Special case for Easter
        if holiday == Holiday.EASTER:
            return self._calculate_easter(year)

        if info.day:  # Fixed date holiday
            return date(year, info.month, info.day)

        # Convert fixed_weekday to weekday number if specified
        weekday = (
            WEEKDAYS.index(info.fixed_weekday) if info.fixed_weekday else info.weekday
        )
        if weekday is not None and info.week_of_month is not None:
            return self._get_weekday_based_date(
                year, info.month, weekday, info.week_of_month
            )

        raise ValueError(f"Unable to calculate date for {holiday.name}")

    def get_holiday_weekday(self, holiday: Holiday, year: int) -> str:
        """Get the weekday name for a specific holiday in a given year."""
        info = holiday.value
        if info.fixed_weekday:
            return info.fixed_weekday
        return WEEKDAYS[self.get_holiday_date(holiday, year).weekday()]

    @staticmethod
    def _get_weekday_based_date(
        year: int, month: int, weekday: int, week_of_month: int
    ) -> date:
        """Calculate date for holidays based on weekday and week of month.

        Args:
            year: The year for calculation
            month: The month (1-12)
            weekday: The day of week (0=Monday, 6=Sunday)
            week_of_month: Which week (1=first, -1=last)

        Returns:
            date: The calculated date
        """
        first_day = date(year, month, 1)

        if week_of_month == -1:  # Last occurrence
            last_day = (
                date(year, month + 1, 1) if month < 12 else date(year + 1, 1, 1)
            ) - timedelta(days=1)
            day = last_day
            while day.weekday() != weekday:
                day -= timedelta(days=1)
            return day

        # Find first occurrence of weekday
        day = first_day + timedelta(days=(weekday - first_day.weekday() + 7) % 7)
        # Add weeks as needed
        return day + timedelta(weeks=week_of_month - 1)

    def _calculate_easter(self, year: int) -> date:
        """Calculate Easter Sunday using Butcher's Algorithm.

        This algorithm determines Easter Sunday's date for any year in the Gregorian calendar.
        Easter falls on the first Sunday following the first ecclesiastical full moon
        that occurs on or after March 21.

        Args:
            year: The year for which to calculate Easter

        Returns:
            date: Easter Sunday's date

        References:
            - Butcher's Algorithm: https://en.wikipedia.org/wiki/Computus#Butcher's_algorithm
        """
        golden_year = year % 19  # Position in the 19-year Metonic cycle
        century = year // 100
        year_in_century = year % 100

        # Calculate corrections
        leap_years = century // 4  # Number of leap years
        non_leap = century % 4
        lunar_correction = (century + 8) // 25
        solar_correction = (century - lunar_correction + 1) // 3

        # Calculate moon phase
        moon_phase = (
            19 * golden_year + century - leap_years - solar_correction + 15
        ) % 30

        # Calculate Sunday date
        century_leap_years = year_in_century // 4
        sunday_offset = (
            32
            + 2 * non_leap
            + 2 * century_leap_years
            - moon_phase
            - (year_in_century % 4)
        ) % 7

        # Calculate Easter date
        lunar_offset = (golden_year + 11 * moon_phase + 22 * sunday_offset) // 451

        # Final date calculation
        month = (moon_phase + sunday_offset - 7 * lunar_offset + 114) // 31
        day = ((moon_phase + sunday_offset - 7 * lunar_offset + 114) % 31) + 1

        return date(year, month, day)


# Simplify the public interface using the singleton calculator
_calculator = HolidayCalculator()


def get_holiday_weekday(holiday: Holiday, year: int) -> str:
    """Get the weekday name for any holiday in a given year.

    Args:
        holiday: The holiday to query
        year: The year to check

    Returns:
        str: Name of the weekday (e.g., "Monday")
    """
    return _calculator.get_holiday_weekday(holiday, year)


def get_holiday_date(holiday: Holiday, year: int) -> str:
    """Get the ISO formatted date for any holiday in a given year."""
    return _calculator.get_holiday_date(holiday, year).isoformat()


def new_years_day(year: int) -> str:
    """Get the weekday name for New Year's Day of the given year.

    This is an example of how to create a simple, specific holiday function
    while leveraging the more flexible underlying implementation.
    """
    return get_holiday_weekday(Holiday.NEW_YEARS_DAY, year)


def main():
    # Original requirement test
    assert new_years_day(2024) == "Monday"
    assert new_years_day(2025) == "Wednesday"

    # More comprehensive tests
    assert get_holiday_weekday(Holiday.NEW_YEARS_DAY, 2024) == "Monday"
    assert get_holiday_weekday(Holiday.CHRISTMAS_DAY, 2024) == "Wednesday"
    assert get_holiday_weekday(Holiday.INDEPENDENCE_DAY, 2024) == "Thursday"
    assert get_holiday_date(Holiday.MEMORIAL_DAY, 2024) == "2024-05-27"
    assert get_holiday_date(Holiday.THANKSGIVING, 2024) == "2024-11-28"
    assert get_holiday_date(Holiday.EASTER, 2024) == "2024-03-31"

    # Assertions for 2025
    assert get_holiday_weekday(Holiday.NEW_YEARS_DAY, 2025) == "Wednesday"
    assert get_holiday_weekday(Holiday.CHRISTMAS_DAY, 2025) == "Thursday"
    assert get_holiday_weekday(Holiday.INDEPENDENCE_DAY, 2025) == "Friday"
    assert get_holiday_date(Holiday.MEMORIAL_DAY, 2025) == "2025-05-26"
    assert get_holiday_date(Holiday.THANKSGIVING, 2025) == "2025-11-27"
    assert get_holiday_date(Holiday.EASTER, 2025) == "2025-04-20"

    print("All checks passed!")


if __name__ == "__main__":
    main()