--- Gregorian calendar conversions and holidays. -- Ported from "Calendrical Calculations" (4th edition) -- by Nachum Dershowitz and Edward M. Reingold. -- Original Lisp code (CALENDRICA 4.0) is Apache 2.0 licensed. -- @module calendrica-gregorian -- @release 0.1 2026-07-19 local M = {} -- Forward declarations needed for mutual recursion / forward references local gregorian_date, gregorian_leap_year, fixed_from_gregorian, gregorian_year_from_fixed local gregorian_new_year, gregorian_from_fixed, kday_on_or_before local kday_before, kday_after, nth_kday, first_kday, last_kday local basic = require("calendrica-basic") -- === Month constants === local JANUARY = 1 local FEBRUARY = 2 local MARCH = 3 local APRIL = 4 local MAY = 5 local JUNE = 6 local JULY = 7 local AUGUST = 8 local SEPTEMBER = 9 local OCTOBER = 10 local NOVEMBER = 11 local DECEMBER = 12 -- === Epoch === local GREGORIAN_EPOCH = basic.rd(1) -- === Date constructor === --- Construct a Gregorian date from year, month, and day. -- @tparam number year Gregorian year. -- @tparam number month Month (1–12). -- @tparam number day Day of month. -- @treturn table {year, month, day} function M.gregorian_date(year, month, day) return {year, month, day} end gregorian_date = M.gregorian_date -- === Leap year === --- True if `g_year` is a leap year on the Gregorian calendar. -- @tparam number g_year Gregorian year. -- @treturn boolean function M.gregorian_leap_year(g_year) return g_year % 4 == 0 and (g_year % 400 == 0 or g_year % 100 ~= 0) end gregorian_leap_year = M.gregorian_leap_year -- === Conversion === --- Fixed date equivalent to the Gregorian date `g_date`. -- @tparam table g_date Gregorian date {year, month, day}. -- @treturn number Fixed date. function M.fixed_from_gregorian(g_date) local month = basic.standard_month(g_date) local day = basic.standard_day(g_date) local year = basic.standard_year(g_date) local correction if month <= 2 then correction = 0 elseif gregorian_leap_year(year) then correction = -1 else correction = -2 end return (GREGORIAN_EPOCH - 1) -- Days before start of calendar. + 365 * (year - 1) -- Ordinary days since epoch. + basic.quotient(year - 1, 4) -- Julian leap days since epoch... - basic.quotient(year - 1, 100) -- ...minus century years... + basic.quotient(year - 1, 400) -- ...plus 400-year cycles. + basic.quotient(367 * month - 362, 12) -- Days in prior months this year. + correction -- Correct for 28- or 29-day Feb. + day -- Days so far this month. end fixed_from_gregorian = M.fixed_from_gregorian --- Gregorian year corresponding to the fixed `date`. -- @tparam number date Fixed date. -- @treturn number Gregorian year. function M.gregorian_year_from_fixed(date) local d0 = date - GREGORIAN_EPOCH -- Prior days. local n400 = basic.quotient(d0, 146097) -- Completed 400-year cycles. local d1 = d0 % 146097 -- Prior days not in n400. local n100 = basic.quotient(d1, 36524) -- 100-year cycles not in n400. local d2 = d1 % 36524 -- Prior days not in n400 or n100. local n4 = basic.quotient(d2, 1461) -- 4-year cycles not in n400 or n100. local d3 = d2 % 1461 -- Prior days not in n400, n100, or n4. local n1 = basic.quotient(d3, 365) -- Years not in n400, n100, or n4. local year = 400 * n400 + 100 * n100 + 4 * n4 + n1 if n100 == 4 or n1 == 4 then return year -- Date is day 366 in a leap year. else return year + 1 -- Date is ordinal day (1 + d3 % 365) in (year + 1). end end gregorian_year_from_fixed = M.gregorian_year_from_fixed --- Gregorian date {year, month, day} corresponding to fixed `date`. -- @tparam number date Fixed date. -- @treturn table {year, month, day} function M.gregorian_from_fixed(date) local year = gregorian_year_from_fixed(date) local prior_days = date - gregorian_new_year(year) local correction if date < fixed_from_gregorian(gregorian_date(year, MARCH, 1)) then correction = 0 elseif gregorian_leap_year(year) then correction = 1 else correction = 2 end local month = basic.quotient(12 * (prior_days + correction) + 373, 367) local day = 1 + date - fixed_from_gregorian(gregorian_date(year, month, 1)) return gregorian_date(year, month, day) end gregorian_from_fixed = M.gregorian_from_fixed -- === Year boundaries === --- Fixed date of January 1 in `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.gregorian_new_year(g_year) return fixed_from_gregorian(gregorian_date(g_year, JANUARY, 1)) end gregorian_new_year = M.gregorian_new_year --- Fixed date of December 31 in `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.gregorian_year_end(g_year) return fixed_from_gregorian(gregorian_date(g_year, DECEMBER, 31)) end --- Half-open interval of fixed dates spanning Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn table Interval {jan1_of_year, jan1_of_next_year}. function M.gregorian_year_range(g_year) return basic.interval( gregorian_new_year(g_year), gregorian_new_year(g_year + 1) ) end local gregorian_year_range = M.gregorian_year_range -- === Date arithmetic === --- Number of days from Gregorian date `g_date1` until `g_date2`. -- @tparam table g_date1 Start date {year, month, day}. -- @tparam table g_date2 End date {year, month, day}. -- @treturn number Number of days. function M.gregorian_date_difference(g_date1, g_date2) return fixed_from_gregorian(g_date2) - fixed_from_gregorian(g_date1) end local gregorian_date_difference = M.gregorian_date_difference -- Day number in year of Gregorian date g_date. local function day_number(g_date) return gregorian_date_difference( gregorian_date(basic.standard_year(g_date) - 1, DECEMBER, 31), g_date ) end -- Days remaining in year after Gregorian date g_date. local function days_remaining(g_date) return gregorian_date_difference( g_date, gregorian_date(basic.standard_year(g_date), DECEMBER, 31) ) end -- Last day of month g_month in Gregorian year g_year. local function last_day_of_gregorian_month(g_year, g_month) local next_year = g_month == 12 and g_year + 1 or g_year local next_month = basic.amod(g_month + 1, 12) return gregorian_date_difference( gregorian_date(g_year, g_month, 1), gregorian_date(next_year, next_month, 1) ) end -- === Alternative formulas (from the book) === -- Alternative fixed-date from Gregorian date (local). local function alt_fixed_from_gregorian(g_date) -- luacheck: ignore local month = basic.standard_month(g_date) local day = basic.standard_day(g_date) local year = basic.standard_year(g_date) local m_prime = (month - 3) % 12 local y_prime = year - basic.quotient(m_prime, 10) return (GREGORIAN_EPOCH - 1) - 306 -- Days in March..December. + 365 * y_prime -- Ordinary days. + basic.sigma( { basic.to_radix(y_prime, {4, 25, 4}), {97, 24, 1, 0} }, function(y, a) return y * a end ) + basic.quotient(3 * m_prime + 2, 5) -- Days in prior months. + 30 * m_prime + day -- Days so far this month. end -- Alternative Gregorian date from fixed date (local). local function alt_gregorian_from_fixed(date) -- luacheck: ignore local y = gregorian_year_from_fixed( GREGORIAN_EPOCH - 1 + date + 306 ) local prior_days = date - fixed_from_gregorian( gregorian_date(y - 1, MARCH, 1) ) local month = basic.amod( basic.quotient(5 * prior_days + 2, 153) + 3, 12 ) local year = y - basic.quotient(month + 9, 12) local day = 1 + date - fixed_from_gregorian(gregorian_date(year, month, 1)) return gregorian_date(year, month, day) end -- Alternative Gregorian year from fixed date (local). local function alt_gregorian_year_from_fixed(date) -- luacheck: ignore local approx = basic.quotient( date - GREGORIAN_EPOCH + 2, 146097 / 400 ) local start = GREGORIAN_EPOCH + 365 * approx + basic.sigma( { basic.to_radix(approx, {4, 25, 4}), {97, 24, 1, 0} }, function(y, a) return y * a end ) if date < start then return approx else return approx + 1 end end -- === k-day helpers === --- Fixed date of the `k`-day on or before fixed `date`. -- @tparam number k Day of week (0=Sunday..6=Saturday). -- @tparam number date Fixed date. -- @treturn number Fixed date. function M.kday_on_or_before(k, date) return date - basic.day_of_week_from_fixed(date - k) end kday_on_or_before = M.kday_on_or_before --- Fixed date of the `k`-day on or after fixed `date`. -- @tparam number k Day of week. -- @tparam number date Fixed date. -- @treturn number Fixed date. function M.kday_on_or_after(k, date) return kday_on_or_before(k, date + 6) end local kday_on_or_after = M.kday_on_or_after --- Fixed date of the `k`-day nearest to fixed `date`. -- @tparam number k Day of week. -- @tparam number date Fixed date. -- @treturn number Fixed date. function M.kday_nearest(k, date) return kday_on_or_before(k, date + 3) end local kday_nearest = M.kday_nearest --- Fixed date of the `k`-day strictly after fixed `date`. -- @tparam number k Day of week. -- @tparam number date Fixed date. -- @treturn number Fixed date. function M.kday_after(k, date) return kday_on_or_before(k, date + 7) end kday_after = M.kday_after --- Fixed date of the `k`-day strictly before fixed `date`. -- @tparam number k Day of week. -- @tparam number date Fixed date. -- @treturn number Fixed date. function M.kday_before(k, date) return kday_on_or_before(k, date - 1) end kday_before = M.kday_before --- Return the `n`-th `k`-day relative to Gregorian date `g_date`. -- If `n` > 0, the `n`-th k-day on or after `g_date`. -- If `n` < 0, the `n`-th k-day on or before `g_date`. -- If `n` = 0, returns bogus. -- @tparam number n Occurrence count (positive = after, negative = before). -- @tparam number k Day of week. -- @tparam table g_date Gregorian date. -- @treturn number Fixed date, or "bogus" when n = 0. function M.nth_kday(n, k, g_date) if n > 0 then return 7 * n + kday_before(k, fixed_from_gregorian(g_date)) elseif n < 0 then return 7 * n + kday_after(k, fixed_from_gregorian(g_date)) else return basic.BOGUS end end nth_kday = M.nth_kday --- Fixed date of the first `k`-day on or after Gregorian date `g_date`. -- @tparam number k Day of week. -- @tparam table g_date Gregorian date. -- @treturn number Fixed date. function M.first_kday(k, g_date) return nth_kday(1, k, g_date) end first_kday = M.first_kday --- Fixed date of the last `k`-day on or before Gregorian date `g_date`. -- @tparam number k Day of week. -- @tparam table g_date Gregorian date. -- @treturn number Fixed date. function M.last_kday(k, g_date) return nth_kday(-1, k, g_date) end last_kday = M.last_kday -- === US holidays === --- Fixed date of United States Independence Day in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.independence_day(g_year) return fixed_from_gregorian(gregorian_date(g_year, JULY, 4)) end --- Fixed date of United States Labor Day in `g_year` (first Monday in September). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.labor_day(g_year) return first_kday(basic.MONDAY, gregorian_date(g_year, SEPTEMBER, 1)) end --- Fixed date of United States Memorial Day in `g_year` (last Monday in May). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.memorial_day(g_year) return last_kday(basic.MONDAY, gregorian_date(g_year, MAY, 31)) end --- Fixed date of United States Election Day in `g_year` -- (Tuesday after the first Monday in November). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.election_day(g_year) return first_kday(basic.TUESDAY, gregorian_date(g_year, NOVEMBER, 2)) end --- Fixed date of start of US daylight saving time in `g_year` (second Sunday in March). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.daylight_saving_start(g_year) return nth_kday(2, basic.SUNDAY, gregorian_date(g_year, MARCH, 1)) end --- Fixed date of end of US daylight saving time in `g_year` (first Sunday in November). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.daylight_saving_end(g_year) return first_kday(basic.SUNDAY, gregorian_date(g_year, NOVEMBER, 1)) end -- === Christian holidays === --- Fixed date of Christmas in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.christmas(g_year) return fixed_from_gregorian(gregorian_date(g_year, DECEMBER, 25)) end --- Fixed date of Advent in Gregorian year `g_year` (Sunday closest to November 30). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.advent(g_year) return kday_nearest( basic.SUNDAY, fixed_from_gregorian(gregorian_date(g_year, NOVEMBER, 30)) ) end --- Fixed date of Epiphany in the US in Gregorian year `g_year` -- (first Sunday after January 1). -- @tparam number g_year Gregorian year. -- @treturn number Fixed date. function M.epiphany(g_year) return first_kday(basic.SUNDAY, gregorian_date(g_year, JANUARY, 2)) end -- === Unlucky Fridays === -- List of Friday-the-13ths within range. local function unlucky_fridays_in_range(range) local a = basic.begin(range) local b = basic.end_(range) local fri = kday_on_or_after(basic.FRIDAY, a) local result = {} while a <= fri and fri < b do local date = gregorian_from_fixed(fri) if basic.standard_day(date) == 13 then result[#result + 1] = fri end fri = fri + 7 end return result end --- List of Friday-the-13ths in Gregorian year `g_year`. -- @tparam number g_year Gregorian year. -- @treturn {number,...} Fixed dates of all Friday the 13ths in the year. function M.unlucky_fridays(g_year) return unlucky_fridays_in_range(gregorian_year_range(g_year)) end -- === Exports === --- January month number. M.JANUARY = JANUARY --- February month number. M.FEBRUARY = FEBRUARY --- March month number. M.MARCH = MARCH --- April month number. M.APRIL = APRIL --- May month number. M.MAY = MAY --- June month number. M.JUNE = JUNE --- July month number. M.JULY = JULY --- August month number. M.AUGUST = AUGUST --- September month number. M.SEPTEMBER = SEPTEMBER --- October month number. M.OCTOBER = OCTOBER --- November month number. M.NOVEMBER = NOVEMBER --- December month number. M.DECEMBER = DECEMBER --- Day number (1..366) of Gregorian date `g_date` within its year. -- @function day_number -- @tparam table g_date Gregorian date {year, month, day}. -- @treturn number Day number. M.day_number = day_number --- Days remaining in the year after Gregorian date `g_date`. -- @function days_remaining -- @tparam table g_date Gregorian date {year, month, day}. -- @treturn number Days remaining. M.days_remaining = days_remaining --- Last day of month `g_month` in Gregorian year `g_year`. -- @function last_day_of_gregorian_month -- @tparam number g_year Gregorian year. -- @tparam number g_month Gregorian month. -- @treturn number Last day (28, 29, 30, or 31). M.last_day_of_gregorian_month = last_day_of_gregorian_month return M