Module: FatDate::Date::ClassMethods

Defined in:
lib/fat_date/date.rb

Utilities collapse

COMMON_YEAR_DAYS_IN_MONTH =

An Array of the number of days in each month indexed by month number, starting with January = 1, etc.

[
  31,
  31,
  28,
  31,
  30,
  31,
  30,
  31,
  31,
  30,
  31,
  30,
  31
].freeze
GOLDEN_TO_FULLMOON =

Map from "golden number" (y % 19) of year to month and day of Pachal full moon. From https://www.calendarbede.com/book/calculation-julian-easter-sunday

{
  1 => [4, 5],
  2 => [3, 25],
  3 => [4, 13],
  4 => [4, 2],
  5 => [3, 22],
  6 => [4, 10],
  7 => [3, 30],
  8 => [4, 18],
  9 => [4, 7],
  10 => [3, 27],
  11 => [4, 15],
  12 => [4, 4],
  13 => [3, 24],
  14 => [4, 12],
  15 => [4, 1],
  16 => [3, 21],
  17 => [4, 9],
  18 => [3, 29],
  19 => [4, 17],
}

Parsing collapse

Utilities collapse

Instance Method Details

#days_in_month(year, month) ⇒ Object

Raises:

  • (ArgumentError)


1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
# File 'lib/fat_date/date.rb', line 1788

def days_in_month(year, month)
  raise ArgumentError, 'illegal month number' if month < 1 || month > 12

  days = COMMON_YEAR_DAYS_IN_MONTH[month]
  if month == 2
    ::Date.new(year, month, 1).leap? ? 29 : 28
  else
    days
  end
end

#dow_to_wday(s) ⇒ Object

Return the wday number for the given DOW string allowing flexibility on how the days are written



1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
# File 'lib/fat_date/date.rb', line 1891

def dow_to_wday(s)
  case s.clean
  when /\ASu/i
    0
  when /\AMo/i
    1
  when /\ATu/i
    2
  when /\AWe/i
    3
  when /\ATh/i
    4
  when /\AFr/i
    5
  when /\ASa/i
    6
  else
    raise ArgumentError, "There is no weekday named #{s}"
  end
end

#easter(year, reform_year: 1582) ⇒ ::Date

Return the date of Easter for the Western Church in the given year, accounting for calendar reform.

Parameters:

  • year (Integer)

    the year of interest

  • reform_year (Integer, nil) (defaults to: 1582)

    the year of Gregorian calendar adoption (default: 1582)

Returns:

  • (::Date)

    the date of Easter for year



1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
# File 'lib/fat_date/date.rb', line 1984

def easter(year, reform_year: 1582)
  require 'date'
  year = year.to_i
  y = year
  # No Easter before the Resurrection!
  return if year < 30

  # Note that since the reform took place in October 1582 in Catholic
  # Europe and in Spetember 1752 in England
  if y <= reform_year
    golden = y % 19 + 1
    p_full = ::Date.new(
      y,
      GOLDEN_TO_FULLMOON[golden][0],
      GOLDEN_TO_FULLMOON[golden][1],
    )
    if p_full.wday.zero?
      p_full + 7.days
    else
      easter = p_full
      easter += 1 until easter.wday.zero?
      easter
    end
  else
    # Gregorian calendar calculation (original code)
    a = y % 19
    b, c = y.divmod(100)
    d, e = b.divmod(4)
    f = (b + 8) / 25
    g = (b - f + 1) / 3
    h = (19 * a + b - d - g + 15) % 30
    i, k = c.divmod(4)
    l = (32 + 2 * e + 2 * i - h - k) % 7
    m = (a + 11 * h + 22 * l) / 451
    n, p = (h + l - 7 * m + 114).divmod(31)
    ::Date.new(y, n, p + 1)
  end
end

#ensure(dat) ⇒ Date, DateTime Also known as: ensure_date

Ensure that date is of class Date based either on a string or Date object or an object that responds to #to_date or #to_datetime. If the given object is a String, use Date.parse to try to convert it.

If given a DateTime, it returns the unrounded DateTime; if given a Time, it converts it to a DateTime.

Parameters:

  • dat (String, Date, Time)

    the object to be converted to Date

Returns:



2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
# File 'lib/fat_date/date.rb', line 2032

def ensure(dat)
  if dat.is_a?(Date) || dat.is_a?(DateTime)
    dat
  elsif dat.is_a?(Time)
    dat.to_datetime
  elsif dat.respond_to?(:to_datetime)
    dat.to_datetime
  elsif dat.respond_to?(:to_date)
    dat.to_date
  elsif dat.is_a?(String)
    begin
      ::Date.parse(dat)
    rescue ::Date::Error
      raise ArgumentError, "cannot convert string '#{dat}' to a Date or DateTime"
    end
  else
    raise ArgumentError, "cannot convert class '#{dat.class}' to a Date or DateTime"
  end
end

#mo_name_to_num(name) ⇒ Integer

Return the 1-indexed integer that corresponds to a month name.

Parameters:

  • name (String)

    a name of a month

Returns:

  • (Integer)

    the integer integer that corresponds to a month name, or nil of no month recognized.



1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
# File 'lib/fat_date/date.rb', line 1805

def mo_name_to_num(name)
  case name.clean
  when /\Ajan/i
    1
  when /\Afeb/i
    2
  when /\Amar/i
    3
  when /\Aapr/i
    4
  when /\Amay/i
    5
  when /\Ajun/i
    6
  when /\Ajul/i
    7
  when /\Aaug/i
    8
  when /\Asep/i
    9
  when /\Aoct/i
    10
  when /\Anov/i
    11
  when /\Adec/i
    12
  end
end

#nth_wday_in_year_month(nth, wday, year, month) ⇒ Object

Return the nth weekday in the given month. If n is negative, count from last day of month.

Parameters:

  • nth (Integer)

    the ordinal number for the weekday

  • wday (Integer)

    the weekday of interest with Sunday 0 to Saturday 6

  • year (Integer)

    the year of interest

  • month (Integer)

    the month of interest with January 1 to December 12

Raises:

  • (ArgumentError)


1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
# File 'lib/fat_date/date.rb', line 1841

def nth_wday_in_year_month(nth, wday, year, month)
  wday = wday.to_i
  raise ArgumentError, 'illegal weekday number' if wday.negative? || wday > 6

  month = month.to_i
  raise ArgumentError, 'illegal month number' if month < 1 || month > 12

  nth = nth.to_i
  if nth.abs > 5
    raise ArgumentError, "#{nth.abs} out of range: can be no more than 5 of any day of the week in any month"
  end

  result =
    if nth.positive?
      # Set d to the 1st wday in month
      d = ::Date.new(year, month, 1)
      d += 1 while d.wday != wday
      # Set d to the nth wday in month
      nd = 1
      while nd != nth
        d += 7
        nd += 1
      end
      d
    elsif nth.negative?
      nth = -nth
      # Set d to the last wday in month
      d = ::Date.new(year, month, 1).end_of_month
      d -= 1 while d.wday != wday
      # Set d to the nth wday in month
      nd = 1
      while nd != nth
        d -= 7
        nd += 1
      end
      d
    else
      raise ArgumentError, 'Argument nth cannot be zero'
    end

  dow = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][wday]
  if result.month != month
    raise ArgumentError, "There is no #{nth}th #{dow} in #{year}-#{month}"
  end

  result
end

#parse_american(str) ⇒ ::Date

Convert a string str with an American style date into a ::Date object

An American style date is of the form MM/DD/YYYY, that is it places the month first, then the day of the month, and finally the year. The European convention is typically to place the day of the month first, DD/MM/YYYY. A date found in the wild can be ambiguous, e.g. 3/5/2014, but a date string known to be using the American convention can be parsed using this method. Both the month and the day can be a single digit. The year can be either 2 or 4 digits, and if given as 2 digits, it adds 2000 to it to give the year.

Examples:

::Date.parse_american('9/11/2001') #=> ::Date(2011, 9, 11)
::Date.parse_american('9/11/01')   #=> ::Date(2011, 9, 11)
::Date.parse_american('9/11/1')    #=> ArgumentError

Parameters:

  • str (String, #to_s)

    a stringling of the form MM/DD/YYYY

Returns:

  • (::Date)

    the date represented by the str paramenter.



1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
# File 'lib/fat_date/date.rb', line 1468

def parse_american(str)
  re = %r{\A\s*(\d\d?)\s*[-/]\s*(\d\d?)\s*[-/]\s*((\d\d)?\d\d)\s*\z}
  unless str.to_s =~ re
    raise ArgumentError, "date string must be of form 'MM?/DD?/YY(YY)?'"
  end

  year = $3.to_i
  month = $1.to_i
  day = $2.to_i
  year += 2000 if year < 100
  ::Date.new(year, month, day)
end

#roman_to_week(s) ⇒ Object

Return the week number indicated by roman numerals i to vi, regardless of case.



1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
# File 'lib/fat_date/date.rb', line 1914

def roman_to_week(s)
  case s.clean
  when /\Ai\z/i
    1
  when /\Aii\z/i
    2
  when /\Aiii\z/i
    3
  when /\Aiv\z/i
    4
  when /\Av\z/i
    5
  when /\Avi\z/i
    6
  else
    raise ArgumentError, "There is no week number #{s}"
  end
end

#spec(spec, spec_type = :from) ⇒ ::Date

Convert a 'period spec' spec to a ::Date. A date spec is a short-hand way of specifying a calendar period either absolutely or relative to the computer clock. This method returns the first date of that period, when spec_type is set to :from, the default, and returns the last date of the period when spec_type is :to.

There are a number of forms the spec can take. In each case, ::Date.spec returns the first date in the period if spec_type is :from and the last date in the period if spec_type is :to:

  • YYYY-MM-DD a particular date, so :from and :to return the same
  • YYYY is the whole year YYYY,
  • YYYY-1H or YYYY-H1 is the first calendar half in year YYYY,
  • H2 or 2H is the second calendar half of the current year,
  • YYYY-3Q or YYYY-Q3 is the third calendar quarter of year YYYY,
  • Q3 or 3Q is the third calendar quarter in the current year,
  • YYYY-04 or YYYY-4 is April, the fourth month of year YYYY,
  • 4-12 or 04-12 is the 12th of April in the current year,
  • 4 or 04 is April in the current year,
  • YYYY-W32 or YYYY-32W is the 32nd week in year YYYY,
  • W32 or 32W is the 32nd week in the current year,
  • W32-4 or 32W-4 is the 4th day of the 32nd week in the current year,
  • YYYY-MM-A or YYYY-MM-B is the first or second half of the given month,
  • YYYY-MM-i or YYYY-MM-v is the first or fifth week of the given month,
  • YYYY-MM-I or YYYY-MM-V is also the first or fifth week of the given month, i.e., case does not matter,
  • MM-i or MM-v is the first or fifth week of the current month,
  • YYYY-MM-3Tu or YYYY-MM-4Mo or YYYY-MM-3Tue or YYYY-MM-4Mon is the third Tuesdsay and fourth Monday of the given month,
  • MM-3Tu or MM-4Mo (MM-3Tue or MM-4Mon) is the third Tuesdsay and fourth Monday of the given month in the current year,
  • 3Tu or 4Mo is the third Tuesdsay and fourth Monday of the current month,
  • YYYY-E is Easter of the given year YYYY,
  • E is Easter of the current year YYYY,
  • YYYY-E+50 and YYYY-E-40 is 50 days after and 40 days before Easter of the given year,
  • E+50 and E-40 is 50 days after and 40 days before Easter of the current year,
  • YYYY-001 and YYYY-182 is first and 182nd day of the given year,
  • 001 and 182 is first and 182nd day of the current year,
  • this_<chunk> where <chunk> is one of year, half, quarter, bimonth, month, semimonth, biweek, week, or day, the corresponding calendar period in which the current date falls,
  • last_<chunk> where <chunk> is one of year, half, quarter, bimonth, month, semimonth, biweek, week, or day, the corresponding calendar period immediately before the one in which the current date falls,
  • today is the same as this_day,
  • yesterday is the same as last_day,
  • forever is the period from ::Date::BOT to ::Date::EOT, essentially all dates of commercial interest, and
  • never causes the method to return nil.

In all of the above example specs, the letter used for calendar chunks, W, Q, and H can be written in lower case as well, as can roman numeral week numbers. Also, you can use / to separate date components instead of -. Likewise, days of the week can be any string, upper or lower case, that starts with at least two letters of the day-of-week name: 'Su', 'su', 'sund', 'sunday', 'surgery', all are valid ways of writing 'Sunday'.

Each of the foregoing specs may have a 'skip modifier' appended to it for finding the following or preceding day-of-week from that date given by the main spec. A skip modifier is a construction appended to a date spec of the form '<Th', '>Th', '<=Th', or '>=Th' where 'Th' could be the name of any day-of-the-week, as described in the foregoing paragraph. It means that /starting from the date determined by the date spec/, find the first Thursday before (<), on or before (<=), after (>), or on or after (>=) the date. So Thanksgiving in 2028 could be found with Date.spec('2028-11<=Th', :to), i.e., the last Thursday on or before the last day of November, 2028.

Examples:

::Date.spec('2012-W32').iso      # => "2012-08-06"
::Date.spec('2012-W32', :to).iso # => "2012-08-12"
::Date.spec('W32').iso           # => "2012-08-06" if executed in 2012
::Date.spec('W32').iso           # => "2012-08-04" if executed in 2014

Parameters:

  • spec (String, #to_s)

    the spec to be interpreted as a calendar period

  • spec_type (Symbol, :from, :to) (defaults to: :from)

    return the first (:from) or last (:to) date in the spec's period respectively

Returns:

  • (::Date)

    date that is the first (:from) or last (:to) in the period designated by spec



1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
# File 'lib/fat_date/date.rb', line 1564

def spec(spec, spec_type = :from)
  spec = spec.to_s.strip.clean
  unless %i[from to].include?(spec_type)
    raise ArgumentError, "invalid date spec type: '#{spec_type}'"
  end

  today = ::Date.today
  if (md = spec.match(/(?<dir>[<>]=?)(?<dow>Su|Mo|Tu|We|Th|Fr|Sa)[a-z]*\z/i))
    skip_to = md[:dow]
    skip_dir = md[:dir]
    spec = spec.sub(/[<>]=?(Su|Mo|Tu|We|Th|Fr|Sa)[a-z]*\z/i, '')
  else
    skip_to = nil
    skip_dir = nil
  end
  result =
    case spec
    when %r{\A((?<yr>\d\d\d\d)[-/])?(?<doy>\d\d\d)\z}
      # With 3-digit YYYY-ddd, return the day-of-year
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      doy = Regexp.last_match[:doy].to_i
      max_doy = ::Date.gregorian_leap?(year) ? 366 : 365
      if doy > max_doy
        raise ArgumentError, "invalid day-of-year '#{doy}' (1..#{max_doy}) in '#{spec}'"
      end

      ::Date.new(year, 1, 1) + doy - 1
    when %r{\A((?<yr>\d\d\d\d)[-/])?(?<mo>\d\d?)([-/](?<dy>\d\d?))?\z}
      # MM, YYYY-MM, MM-DD
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      month = Regexp.last_match[:mo].to_i
      day = Regexp.last_match[:dy]&.to_i
      unless [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].include?(month)
        raise ArgumentError, "invalid month number (1-12): '#{spec}'"
      end

      if day
        ::Date.new(year, month, day)
      elsif spec_type == :from
        ::Date.new(year, month, 1)
      else
        ::Date.new(year, month, 1).end_of_month
      end
    when %r{\A((?<yr>\d\d\d\d)[-/])?(?<wk>\d\d?)W(-(?<dy>\d))?\z}xi,
      %r{\A((?<yr>\d\d\d\d)[-/])?W(?<wk>\d\d?)(-(?<dy>\d))?\z}xi
      # Commercial week numbers.  The first commercial week of the year is
      # the one that includes the first Thursday of that year. In the
      # Gregorian calendar, this is equivalent to the week which includes
      # January 4.  This appears to be the equivalent of ISO 8601 week
      # number as described at https://en.wikipedia.org/wiki/ISO_week_date
      year = Regexp.last_match[:yr]&.to_i
      week_num = Regexp.last_match[:wk].to_i
      day = Regexp.last_match[:dy]&.to_i
      unless (1..53).cover?(week_num)
        raise ArgumentError, "invalid week number (1-53): '#{spec}'"
      end
      if day && !(1..7).cover?(day)
        raise ArgumentError, "invalid ISO day number (1-7): '#{spec}'"
      end

      if spec_type == :from
        ::Date.commercial(year ? year : today.year, week_num, day ? day : 1)
      else
        ::Date.commercial(year ? year : today.year, week_num, day ? day : 7)
      end
    when %r{^((?<yr>\d\d\d\d)[-/])?(?<qt>\d)[Qq]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Qq](?<qt>\d)$}
      # Year-Quarter
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      quarter = Regexp.last_match[:qt].to_i
      unless [1, 2, 3, 4].include?(quarter)
        raise ArgumentError, "invalid quarter number (1-4): '#{spec}'"
      end

      month = quarter * 3
      if spec_type == :from
        ::Date.new(year, month, 1).beginning_of_quarter
      else
        ::Date.new(year, month, 1).end_of_quarter
      end
    when %r{^((?<yr>\d\d\d\d)[-/])?(?<hf>\d)[Hh]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Hh](?<hf>\d)$}
      # Year-Half
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      half = Regexp.last_match[:hf].to_i
      msg = "invalid half number: '#{spec}'"
      raise ArgumentError, msg unless [1, 2].include?(half)

      month = half * 6
      if spec_type == :from
        ::Date.new(year, month, 15).beginning_of_half
      else
        ::Date.new(year, month, 1).end_of_half
      end
    when /\A(?<yr>\d\d\d\d)\z/
      # Year only
      year = Regexp.last_match[:yr].to_i
      if spec_type == :from
        ::Date.new(year, 1, 1)
      else
        ::Date.new(year, 12, 31)
      end
    when %r{\A(((?<yr>\d\d\d\d)[-\/])?(?<mo>\d\d?)[-\/])?(?<hf_mo>(A|B))\z}i
      # Year, month, half-month, designated with A or B
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
      hf_mo = Regexp.last_match[:hf_mo]
      if hf_mo == "A"
        spec_type == :from ? ::Date.new(year, month, 1) : ::Date.new(year, month, 15)
      else
        # hf_mo == "B"
        spec_type == :from ? ::Date.new(year, month, 16) : ::Date.new(year, month, 16).end_of_month
      end
    when %r{\A((?<yr>\d\d\d\d)[-/])?((?<mo>\d\d?)[-/])?(?<wk>(i|ii|iii|iv|v|vi))\z}i
      # Year, month, week-of-month, partial-or-whole, designated with lowercase Roman
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
      wk = roman_to_week(Regexp.last_match[:wk])
      result =
        if spec_type == :from
          ::Date.new(year, month, 1).beginning_of_week + (wk - 1).weeks
        else
          ::Date.new(year, month, 1).end_of_week + (wk - 1).weeks
        end
      # If beginning of week of the 1st is in prior month, return the 1st
      result = [result, ::Date.new(year, month, 1)].max
      # If the whole week of the result is in the next month, there was no such week
      if result.beginning_of_week.month > month
        msg = sprintf("no week number #{wk} in %04d-%02d", year, month)
        raise ArgumentError, msg
      else
        # But if part of the result week is in this month, return end of month
        [result, ::Date.new(year, month, 1).end_of_month].min
      end
    when %r{\A((?<yr>\d\d\d\d)[-/])?((?<mo>\d\d?)[-/])?((?<nth>[-+]?\d+)(?<dow>(Su|Mo|Tu|We|Th|Fr|Sa)[a-z]*))\z}i
      # Year, month, ordinal dow name
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
      nth = Regexp.last_match[:nth].to_i
      wday = dow_to_wday(Regexp.last_match[:dow])
      unless (1..12).cover?(month)
        raise ArgumentError, "invalid month number (1-12): '#{month}' in '#{spec}'"
      end
      unless (1..5).cover?(nth.abs)
        raise ArgumentError, "invalid ordinal day number (1-5): '#{nth}' in '#{spec}'"
      end

      ::Date.nth_wday_in_year_month(nth, wday, year, month)
    when %r{\A(?<yr>\d\d\d\d[-/])?E(?<off>[+-]\d+)?\z}i
      # Easter for the given year, current year (if no year component),
      # optionally plus or minus a day offset
      year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
      offset = Regexp.last_match[:off]&.to_i || 0
      ::Date.easter(year) + offset
    when %r{\A(?<rel>(to[_-]?|this[_-]?)|(last[_-]?|yester[_-]?|next[_-]?))
            (?<chunk>morrow|day|week|biweek|fortnight|semimonth|bimonth|month|quarter|half|year)\z}xi
      rel = Regexp.last_match[:rel]
      chunk = Regexp.last_match[:chunk].to_sym
      if chunk.match?(/morrow/i)
        chunk = :day
        rel = 'next'
      end
      if chunk.match?(/fortnight/i)
        chunk = :biweek
      end
      start =
        if rel.match?(/this|to/i)
          ::Date.today
        elsif rel.match?(/next/i)
          ::Date.today.add_chunk(chunk, 1)
        else
          # rel.match?(/last|yester/i)
          ::Date.today.add_chunk(chunk, -1)
        end
      if spec_type == :from
        start.beginning_of_chunk(chunk)
      else
        start.end_of_chunk(chunk)
      end
    when /^forever/i
      if spec_type == :from
        ::Date::BOT
      else
        ::Date::EOT
      end
    when /^never/i
      nil
    else
      raise ArgumentError, "XXX bad date spec: '#{spec}'"
    end

  # Now, skip to the next or preceding skip day if skip_to is defined.
  if skip_to
    wday = dow_to_wday(skip_to)
    if result.wday == wday && skip_dir[1] == '='
      true # Do nothing
    elsif skip_dir[0] == '<'
      # Find prior date with dow
      result -= 1.day while result.wday != wday
    else
      # Find subsequent date with dow
      result += 1.day while result.wday != wday
    end
  end
  result
end