fat_core 5.1.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/fat_core/date.rb CHANGED
@@ -9,49 +9,46 @@ require 'active_support/core_ext/integer/time'
9
9
  require 'fat_core/string'
10
10
  require 'fat_core/patches'
11
11
 
12
- # ## FatCore Date Extensions
13
- #
14
- # The FatCore extensions to the Date class add the notion of several additional
15
- # calendar periods besides years, months, and weeks to those provided for in the
16
- # Date class and the active_support extensions to Date. In particular, there
17
- # are several additional calendar subdivisions (called "chunks" in this
18
- # documentation) supported by FatCore's extension to the Date class:
19
- #
20
- # * year,
21
- # * half,
22
- # * quarter,
23
- # * bimonth,
24
- # * month,
25
- # * semimonth,
26
- # * biweek,
27
- # * week, and
28
- # * day
29
- #
30
- # For each of those chunks, there are methods for finding the beginning and end
31
- # of the chunk, for advancing or retreating a Date by the chunk, and for testing
32
- # whether a Date is at the beginning or end of each of the chunk.
33
- #
34
- # FatCore's Date extension defines a few convenience formatting methods, such as
35
- # Date#iso and Date#org for formatting Dates as ISO strings and as Emacs
36
- # org-mode inactive timestamps respectively. It also has a few utility methods
37
- # for determining the date of Easter, the number of days in any given month, and
38
- # the Date of the nth workday in a given month (say the third Thursday in
39
- # October, 2014).
40
- #
41
- # The Date extension defines a couple of class methods for parsing strings into
42
- # Dates, especially Date.parse_spec, which allows Dates to be specified in a
43
- # lazy way, either absolutely or relative to the computer's clock.
44
- #
45
- # Finally FatCore's Date extensions provide thorough methods for determining if
46
- # a Date is a United States federal holiday or workday based on US law,
47
- # including executive orders. It does the same for the New York Stock Exchange,
48
- # based on the rules of the New York Stock Exchange, including dates on which
49
- # the NYSE was closed for special reasons, such as the 9-11 attacks in 2001.
50
12
  module FatCore
13
+ # ## FatCore Date Extensions
14
+ #
15
+ # The FatCore extensions to the Date class add the notion of several additional
16
+ # calendar periods besides years, months, and weeks to those provided for in the
17
+ # Date class and the active_support extensions to Date. In particular, there
18
+ # are several additional calendar subdivisions (called "chunks" in this
19
+ # documentation) supported by FatCore's extension to the Date class:
20
+ #
21
+ # * year,
22
+ # * half,
23
+ # * quarter,
24
+ # * bimonth,
25
+ # * month,
26
+ # * semimonth,
27
+ # * biweek,
28
+ # * week, and
29
+ # * day
30
+ #
31
+ # For each of those chunks, there are methods for finding the beginning and end
32
+ # of the chunk, for advancing or retreating a Date by the chunk, and for testing
33
+ # whether a Date is at the beginning or end of each of the chunk.
34
+ #
35
+ # FatCore's Date extension defines a few convenience formatting methods, such as
36
+ # Date#iso and Date#org for formatting Dates as ISO strings and as Emacs
37
+ # org-mode inactive timestamps respectively. It also has a few utility methods
38
+ # for determining the date of Easter, the number of days in any given month, and
39
+ # the Date of the nth workday in a given month (say the third Thursday in
40
+ # October, 2014).
41
+ #
42
+ # The Date extension defines a couple of class methods for parsing strings into
43
+ # Dates, especially Date.parse_spec, which allows Dates to be specified in a
44
+ # lazy way, either absolutely or relative to the computer's clock.
45
+ #
46
+ # Finally FatCore's Date extensions provide thorough methods for determining if
47
+ # a Date is a United States federal holiday or workday based on US law,
48
+ # including executive orders. It does the same for the New York Stock Exchange,
49
+ # based on the rules of the New York Stock Exchange, including dates on which
50
+ # the NYSE was closed for special reasons, such as the 9-11 attacks in 2001.
51
51
  module Date
52
- # Set the default beginning of week to Monday for commercial weeks.
53
- # ::Date.beginning_of_week = :monday
54
-
55
52
  # Constant for Beginning of Time (BOT) outside the range of what we would ever
56
53
  # want to find in commercial situations.
57
54
  BOT = ::Date.parse('1900-01-01')
@@ -369,7 +366,7 @@ module FatCore
369
366
  # @param from_date [::Date] the middle of the six-month range
370
367
  # @return [Boolean]
371
368
  def within_6mos_of?(from_date)
372
- from_date = Date.parse(from_date) unless from_date.is_a?(Date)
369
+ from_date = ::Date.parse(from_date) unless from_date.is_a?(Date)
373
370
  from_day = from_date.day
374
371
  if [28, 29, 30, 31].include?(from_day)
375
372
  # Near the end of the month, we need to make sure that when we go
@@ -550,6 +547,8 @@ module FatCore
550
547
  end
551
548
  end
552
549
 
550
+ # NOTE: Date#end_of_week and Date#beginning_of_week is defined in ActiveSupport
551
+
553
552
  # Return the date that is +n+ calendar halves after this date, where a
554
553
  # calendar half is a period of 6 months.
555
554
  #
@@ -653,8 +652,10 @@ module FatCore
653
652
  if factor.positive?
654
653
  [beginning_of_month + 16.days + (day - 1).days, end_of_month].min
655
654
  else
656
- [prior_month.beginning_of_month + 16.days + (day - 1).days,
657
- prior_month.end_of_month].min
655
+ [
656
+ prior_month.beginning_of_month + 16.days + (day - 1).days,
657
+ prior_month.end_of_month
658
+ ].min
658
659
  end
659
660
  elsif factor.positive?
660
661
  # In the second half of the month (17th to the 31st), go as many
@@ -1199,11 +1200,12 @@ module FatCore
1199
1200
  # later trading day.
1200
1201
  #
1201
1202
  # @return [::Date]
1202
- def next_until_trading_day
1203
+ def next_until_nyse_workday
1203
1204
  date = dup
1204
1205
  date += 1 until date.trading_day?
1205
1206
  date
1206
1207
  end
1208
+ alias_method :next_until_trading_day, :next_until_nyse_workday
1207
1209
 
1208
1210
  # Return this date if its a trading day, otherwise skip back to the first prior
1209
1211
  # trading day.
@@ -1330,7 +1332,7 @@ module FatCore
1330
1332
  #
1331
1333
  # @return [Boolean]
1332
1334
  def nyse_special_holiday?
1333
- return false unless self > ::Date.parse('1960-01-01')
1335
+ return false if self <= ::Date.parse('1960-01-01')
1334
1336
 
1335
1337
  return true if PRESIDENTIAL_FUNERALS.include?(self)
1336
1338
 
@@ -1427,6 +1429,7 @@ module FatCore
1427
1429
  # `::Date.parse_spec` returns the first date in the period if `spec_type` is
1428
1430
  # `:from` and the last date in the period if `spec_type` is `:to`:
1429
1431
  #
1432
+ # * `YYYY-MM-DD` a particular date, so `:from` and `:to` return the same
1430
1433
  # * `YYYY` is the whole year `YYYY`,
1431
1434
  # * `YYYY-1H` or `YYYY-H1` is the first calendar half in year `YYYY`,
1432
1435
  # * `H2` or `2H` is the second calendar half of the current year,
@@ -1437,8 +1440,19 @@ module FatCore
1437
1440
  # * `4` or `04` is April in the current year,
1438
1441
  # * `YYYY-W32` or `YYYY-32W` is the 32nd week in year YYYY,
1439
1442
  # * `W32` or `32W` is the 32nd week in the current year,
1440
- # * `YYYY-MM-DD` a particular date, so `:from` and `:to` return the same
1441
- # date,
1443
+ # * `W32-4` or `32W-4` is the 4th day of the 32nd week in the current year,
1444
+ # * `YYYY-MM-I` or `YYYY-MM-II` is the first or second half of the given month,
1445
+ # * `YYYY-MM-i` or `YYYY-MM-v` is the first or fifth week of the given month,
1446
+ # * `MM-i` or `MM-v` is the first or fifth week of the current month,
1447
+ # * `YYYY-MM-3Tu` or `YYYY-MM-4Mo` is the third Tuesdsay and fourth Monday of the given month,
1448
+ # * `MM-3Tu` or `MM-4Mo` is the third Tuesdsay and fourth Monday of the given month in the current year,
1449
+ # * `3Tu` or `4Mo` is the third Tuesdsay and fourth Monday of the current month,
1450
+ # * `YYYY-E` is Easter of the given year YYYY,
1451
+ # * `E` is Easter of the current year YYYY,
1452
+ # * `YYYY-E+50` and `YYYY-E-40` is 50 days after and 40 days before Easter of the given year,
1453
+ # * `E+50` and `E-40` is 50 days after and 40 days before Easter of the current year,
1454
+ # * `YYYY-001` and `YYYY-182` is first and 182nd day of the given year,
1455
+ # * `001` and `182` is first and 182nd day of the current year,
1442
1456
  # * `this_<chunk>` where `<chunk>` is one of `year`, `half`, `quarter`,
1443
1457
  # `bimonth`, `month`, `semimonth`, `biweek`, `week`, or `day`, the
1444
1458
  # corresponding calendar period in which the current date falls,
@@ -1470,43 +1484,64 @@ module FatCore
1470
1484
  # @return [::Date] date that is the first (:from) or last (:to) in the period
1471
1485
  # designated by spec
1472
1486
  def parse_spec(spec, spec_type = :from)
1473
- spec = spec.to_s.strip
1487
+ spec = spec.to_s.strip.clean
1474
1488
  unless %i[from to].include?(spec_type)
1475
1489
  raise ArgumentError, "invalid date spec type: '#{spec_type}'"
1476
1490
  end
1477
1491
 
1478
1492
  today = ::Date.current
1479
- case spec.clean
1480
- when %r{\A(?<yr>\d\d\d\d)[-/](?<mo>\d\d?)[-/](?<dy>\d\d?)\z}
1481
- # A specified date
1482
- ::Date.new(Regexp.last_match[:yr].to_i, Regexp.last_match[:mo].to_i,
1483
- Regexp.last_match[:dy].to_i)
1484
- when /\AW(?<wk>\d\d?)\z/, /\A(?<wk>\d\d?)W\z/
1485
- week_num = Regexp.last_match[:wk].to_i
1486
- if week_num < 1 || week_num > 53
1487
- raise ArgumentError, "invalid week number (1-53): '#{spec}'"
1493
+ case spec
1494
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<doy>\d\d\d)\z}
1495
+ # With 3-digit YYYY-ddd, return the day-of-year
1496
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1497
+ doy = Regexp.last_match[:doy].to_i
1498
+ max_doy = ::Date.gregorian_leap?(year) ? 366 : 365
1499
+ if doy > max_doy
1500
+ raise ArgumentError, "invalid day-of-year '#{doy}' (1..#{max_doy}) in '#{spec}'"
1488
1501
  end
1489
1502
 
1490
- if spec_type == :from
1491
- ::Date.commercial(today.year, week_num).beginning_of_week
1503
+ ::Date.new(year, 1, 1) + doy - 1
1504
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<mo>\d\d?)([-/](?<dy>\d\d?))?\z}
1505
+ # MM, YYYY-MM, MM-DD
1506
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1507
+ month = Regexp.last_match[:mo].to_i
1508
+ day = Regexp.last_match[:dy]&.to_i
1509
+ unless [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].include?(month)
1510
+ raise ArgumentError, "invalid month number (1-12): '#{spec}'"
1511
+ end
1512
+
1513
+ if day
1514
+ ::Date.new(year, month, day)
1515
+ elsif spec_type == :from
1516
+ ::Date.new(year, month, 1)
1492
1517
  else
1493
- ::Date.commercial(today.year, week_num).end_of_week
1518
+ ::Date.new(year, month, 1).end_of_month
1494
1519
  end
1495
- when %r{\A(?<yr>\d\d\d\d)[-/]W(?<wk>\d\d?)\z}, %r{\A(?<yr>\d\d\d\d)[-/](?<wk>\d\d?)W\z}
1496
- year = Regexp.last_match[:yr].to_i
1520
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<wk>\d\d?)W(-(?<dy>\d))?\z}xi,
1521
+ %r{\A((?<yr>\d\d\d\d)[-/])?W(?<wk>\d\d?)(-(?<dy>\d))?\z}xi
1522
+ # Commercial week numbers. The first commercial week of the year is
1523
+ # the one that includes the first Thursday of that year. In the
1524
+ # Gregorian calendar, this is equivalent to the week which includes
1525
+ # January 4. This appears to be the equivalent of ISO 8601 week
1526
+ # number as described at https://en.wikipedia.org/wiki/ISO_week_date
1527
+ year = Regexp.last_match[:yr]&.to_i
1497
1528
  week_num = Regexp.last_match[:wk].to_i
1498
- if week_num < 1 || week_num > 53
1529
+ day = Regexp.last_match[:dy]&.to_i
1530
+ unless (1..53).cover?(week_num)
1499
1531
  raise ArgumentError, "invalid week number (1-53): '#{spec}'"
1500
1532
  end
1533
+ if day && !(1..7).cover?(day)
1534
+ raise ArgumentError, "invalid ISO day number (1-7): '#{spec}'"
1535
+ end
1501
1536
 
1502
1537
  if spec_type == :from
1503
- ::Date.commercial(year, week_num).beginning_of_week
1538
+ ::Date.commercial(year ? year : today.year, week_num, day ? day : 1)
1504
1539
  else
1505
- ::Date.commercial(year, week_num).end_of_week
1540
+ ::Date.commercial(year ? year : today.year, week_num, day ? day : 7)
1506
1541
  end
1507
- when %r{^(?<yr>\d\d\d\d)[-/](?<qt>\d)[Qq]$}, %r{^(?<yr>\d\d\d\d)[-/][Qq](?<qt>\d)$}
1542
+ when %r{^((?<yr>\d\d\d\d)[-/])?(?<qt>\d)[Qq]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Qq](?<qt>\d)$}
1508
1543
  # Year-Quarter
1509
- year = Regexp.last_match[:yr].to_i
1544
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1510
1545
  quarter = Regexp.last_match[:qt].to_i
1511
1546
  unless [1, 2, 3, 4].include?(quarter)
1512
1547
  raise ArgumentError, "invalid quarter number (1-4): '#{spec}'"
@@ -1518,26 +1553,12 @@ module FatCore
1518
1553
  else
1519
1554
  ::Date.new(year, month, 1).end_of_quarter
1520
1555
  end
1521
- when /^(?<qt>[1234])[qQ]$/, /^[qQ](?<qt>[1234])$/
1522
- # Quarter only
1523
- this_year = today.year
1524
- quarter = Regexp.last_match[:qt].to_i
1525
- unless [1, 2, 3, 4].include?(quarter)
1526
- raise ArgumentError, "invalid quarter number (1-4): '#{spec}'"
1527
- end
1528
-
1529
- date = ::Date.new(this_year, quarter * 3, 15)
1530
- if spec_type == :from
1531
- date.beginning_of_quarter
1532
- else
1533
- date.end_of_quarter
1534
- end
1535
- when %r{^(?<yr>\d\d\d\d)[-/](?<hf>\d)[Hh]$}, %r{^(?<yr>\d\d\d\d)[-/][Hh](?<hf>\d)$}
1556
+ when %r{^((?<yr>\d\d\d\d)[-/])?(?<hf>\d)[Hh]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Hh](?<hf>\d)$}
1536
1557
  # Year-Half
1537
- year = Regexp.last_match[:yr].to_i
1558
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1538
1559
  half = Regexp.last_match[:hf].to_i
1539
1560
  msg = "invalid half number: '#{spec}'"
1540
- raise ArgumentError, msg unless [1, 2].include?(half)
1561
+ raise ArgumentError, msg unless [1, 2].include?(half)
1541
1562
 
1542
1563
  month = half * 6
1543
1564
  if spec_type == :from
@@ -1545,58 +1566,7 @@ module FatCore
1545
1566
  else
1546
1567
  ::Date.new(year, month, 1).end_of_half
1547
1568
  end
1548
- when /^(?<hf>[12])[hH]$/, /^[hH](?<hf>[12])$/
1549
- # Half only
1550
- this_year = today.year
1551
- half = Regexp.last_match[:hf].to_i
1552
- msg = "invalid half number: '#{spec}'"
1553
- raise ArgumentError, msg unless [1, 2].include?(half)
1554
-
1555
- date = ::Date.new(this_year, half * 6, 15)
1556
- if spec_type == :from
1557
- date.beginning_of_half
1558
- else
1559
- date.end_of_half
1560
- end
1561
- when %r{^(?<yr>\d\d\d\d)[-/](?<mo>\d\d?)*$}
1562
- # Year-Month only
1563
- year = Regexp.last_match[:yr].to_i
1564
- month = Regexp.last_match[:mo].to_i
1565
- unless [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].include?(month)
1566
- raise ArgumentError, "invalid month number (1-12): '#{spec}'"
1567
- end
1568
-
1569
- if spec_type == :from
1570
- ::Date.new(year, month, 1)
1571
- else
1572
- ::Date.new(year, month, 1).end_of_month
1573
- end
1574
- when %r{^(?<mo>\d\d?)[-/](?<dy>\d\d?)*$}
1575
- # Month-Day only
1576
- month = Regexp.last_match[:mo].to_i
1577
- day = Regexp.last_match[:dy].to_i
1578
- unless [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].include?(month)
1579
- raise ArgumentError, "invalid month number (1-12): '#{spec}'"
1580
- end
1581
-
1582
- if spec_type == :from
1583
- ::Date.new(today.year, month, day)
1584
- else
1585
- ::Date.new(today.year, month, day).end_of_month
1586
- end
1587
- when /\A(?<mo>\d\d?)\z/
1588
- # Month only
1589
- month = Regexp.last_match[:mo].to_i
1590
- unless [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].include?(month)
1591
- raise ArgumentError, "invalid month number (1-12): '#{spec}'"
1592
- end
1593
-
1594
- if spec_type == :from
1595
- ::Date.new(today.year, month, 1)
1596
- else
1597
- ::Date.new(today.year, month, 1).end_of_month
1598
- end
1599
- when /^(?<yr>\d\d\d\d)$/
1569
+ when /\A(?<yr>\d\d\d\d)\z/
1600
1570
  # Year only
1601
1571
  year = Regexp.last_match[:yr].to_i
1602
1572
  if spec_type == :from
@@ -1604,105 +1574,88 @@ module FatCore
1604
1574
  else
1605
1575
  ::Date.new(year, 12, 31)
1606
1576
  end
1607
- when /^(to|this_?)?day/
1608
- today
1609
- when /^(yester|last_?)?day/
1610
- today - 1.day
1611
- when /^(this_?)?week/
1612
- spec_type == :from ? today.beginning_of_week : today.end_of_week
1613
- when /last_?week/
1614
- if spec_type == :from
1615
- (today - 1.week).beginning_of_week
1616
- else
1617
- (today - 1.week).end_of_week
1618
- end
1619
- when /^(this_?)?biweek/
1620
- if spec_type == :from
1621
- today.beginning_of_biweek
1622
- else
1623
- today.end_of_biweek
1624
- end
1625
- when /last_?biweek/
1626
- if spec_type == :from
1627
- (today - 2.week).beginning_of_biweek
1628
- else
1629
- (today - 2.week).end_of_biweek
1630
- end
1631
- when /^(this_?)?semimonth/
1632
- spec_type == :from ? today.beginning_of_semimonth : today.end_of_semimonth
1633
- when /^last_?semimonth/
1634
- if spec_type == :from
1635
- (today - 15.days).beginning_of_semimonth
1636
- else
1637
- (today - 15.days).end_of_semimonth
1638
- end
1639
- when /^(this_?)?month/
1640
- if spec_type == :from
1641
- today.beginning_of_month
1642
- else
1643
- today.end_of_month
1644
- end
1645
- when /^last_?month/
1646
- if spec_type == :from
1647
- (today - 1.month).beginning_of_month
1648
- else
1649
- (today - 1.month).end_of_month
1650
- end
1651
- when /^(this_?)?bimonth/
1652
- if spec_type == :from
1653
- today.beginning_of_bimonth
1654
- else
1655
- today.end_of_bimonth
1656
- end
1657
- when /^last_?bimonth/
1658
- if spec_type == :from
1659
- (today - 2.month).beginning_of_bimonth
1660
- else
1661
- (today - 2.month).end_of_bimonth
1662
- end
1663
- when /^(this_?)?quarter/
1664
- if spec_type == :from
1665
- today.beginning_of_quarter
1577
+ when %r{\A(?<yr>\d\d\d\d)[-/](?<mo>\d\d?)[-/](?<hf_mo>(I|II))\z}
1578
+ # Year, month, half-month, designated with uppercase Roman
1579
+ year = Regexp.last_match[:yr].to_i
1580
+ month = Regexp.last_match[:mo].to_i
1581
+ hf_mo = Regexp.last_match[:hf_mo]
1582
+ if hf_mo == "I"
1583
+ spec_type == :from ? ::Date.new(year, month, 1) : ::Date.new(year, month, 15)
1666
1584
  else
1667
- today.end_of_quarter
1585
+ # hf_mo == "II"
1586
+ spec_type == :from ? ::Date.new(year, month, 16) : ::Date.new(year, month, 16).end_of_month
1668
1587
  end
1669
- when /^last_?quarter/
1670
- if spec_type == :from
1671
- (today - 3.months).beginning_of_quarter
1588
+ when %r{\A((?<yr>\d\d\d\d)[-/])?((?<mo>\d\d?)[-/])?(?<wk>(i|ii|iii|iv|v|vi))\z}
1589
+ # Year, month, week-of-month, partial-or-whole, designated with lowercase Roman
1590
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1591
+ month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
1592
+ wk = ['i', 'ii', 'iii', 'iv', 'v', 'vi'].index(Regexp.last_match[:wk]) + 1
1593
+ result =
1594
+ if spec_type == :from
1595
+ ::Date.new(year, month, 1).beginning_of_week + (wk - 1).weeks
1596
+ else
1597
+ ::Date.new(year, month, 1).end_of_week + (wk - 1).weeks
1598
+ end
1599
+ # If beginning of week of the 1st is in prior month, return the 1st
1600
+ result = [result, ::Date.new(year, month, 1)].max
1601
+ # If the whole week of the result is in the next month, there was no such week
1602
+ if result.beginning_of_week.month > month
1603
+ msg = sprintf("no week number #{wk} in %04d-%02d", year, month)
1604
+ raise ArgumentError, msg
1672
1605
  else
1673
- (today - 3.months).end_of_quarter
1606
+ # But if part of the result week is in this month, return end of month
1607
+ [result, ::Date.new(year, month, 1).end_of_month].min
1674
1608
  end
1675
- when /^(this_?)?half/
1676
- if spec_type == :from
1677
- today.beginning_of_half
1678
- else
1679
- today.end_of_half
1609
+ when %r{\A((?<yr>\d\d\d\d)[-/])?((?<mo>\d\d?)[-/])?((?<ndow>\d+)(?<dow>Su|Mo|Tu|We|Th|Fr|Sa))\z}
1610
+ # Year, month, week-of-month, partial-or-whole, designated with lowercase Roman
1611
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1612
+ month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
1613
+ ndow = Regexp.last_match[:ndow].to_i
1614
+ dow = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].index(Regexp.last_match[:dow]) ||
1615
+ ['su', 'mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'].index(Regexp.last_match[:dow])
1616
+ unless (1..12).cover?(month)
1617
+ raise ArgumentError, "invalid month number (1-12): '#{month}' in '#{spec}'"
1680
1618
  end
1681
- when /^last_?half/
1682
- if spec_type == :from
1683
- (today - 6.months).beginning_of_half
1684
- else
1685
- (today - 6.months).end_of_half
1619
+ unless (1..5).cover?(ndow)
1620
+ raise ArgumentError, "invalid ordinal day number (1-5): '#{ndow}' in '#{spec}'"
1686
1621
  end
1687
- when /^(this_?)?year/
1688
- if spec_type == :from
1689
- today.beginning_of_year
1690
- else
1691
- today.end_of_year
1622
+
1623
+ ::Date.nth_wday_in_year_month(ndow, dow, year, month)
1624
+ when %r{\A(?<yr>\d\d\d\d[-/])?E(?<off>[+-]\d+)?\z}i
1625
+ # Easter for the given year, current year (if no year component),
1626
+ # optionally plus or minus a day offset
1627
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1628
+ offset = Regexp.last_match[:off]&.to_i || 0
1629
+ ::Date.easter(year) + offset
1630
+ when %r{\A(?<rel>(to[_-]?|this[_-]?)|(last[_-]?|yester[_-]?|next[_-]?))
1631
+ (?<chunk>morrow|day|week|biweek|semimonth|bimonth|month|quarter|half|year)\z}xi
1632
+ rel = Regexp.last_match[:rel]
1633
+ chunk = Regexp.last_match[:chunk].to_sym
1634
+ if chunk.match?(/morrow/i)
1635
+ chunk = :day
1636
+ rel = 'next'
1692
1637
  end
1693
- when /^last_?year/
1638
+ start =
1639
+ if rel.match?(/this|to/i)
1640
+ ::Date.today
1641
+ elsif rel.match?(/next/i)
1642
+ ::Date.today.add_chunk(chunk, 1)
1643
+ else
1644
+ # rel.match?(/last|yester/i)
1645
+ ::Date.today.add_chunk(chunk, -1)
1646
+ end
1694
1647
  if spec_type == :from
1695
- (today - 1.year).beginning_of_year
1648
+ start.beginning_of_chunk(chunk)
1696
1649
  else
1697
- (today - 1.year).end_of_year
1650
+ start.end_of_chunk(chunk)
1698
1651
  end
1699
- when /^forever/
1652
+ when /^forever/i
1700
1653
  if spec_type == :from
1701
1654
  ::Date::BOT
1702
1655
  else
1703
1656
  ::Date::EOT
1704
1657
  end
1705
- when /^never/
1658
+ when /^never/i
1706
1659
  nil
1707
1660
  else
1708
1661
  raise ArgumentError, "bad date spec: '#{spec}''"
@@ -1713,8 +1666,21 @@ module FatCore
1713
1666
 
1714
1667
  # An Array of the number of days in each month indexed by month number,
1715
1668
  # starting with January = 1, etc.
1716
- COMMON_YEAR_DAYS_IN_MONTH = [31, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31,
1717
- 30, 31].freeze
1669
+ COMMON_YEAR_DAYS_IN_MONTH = [
1670
+ 31,
1671
+ 31,
1672
+ 28,
1673
+ 31,
1674
+ 30,
1675
+ 31,
1676
+ 30,
1677
+ 31,
1678
+ 31,
1679
+ 30,
1680
+ 31,
1681
+ 30,
1682
+ 31
1683
+ ].freeze
1718
1684
  def days_in_month(year, month)
1719
1685
  raise ArgumentError, 'illegal month number' if month < 1 || month > 12
1720
1686
 
@@ -1758,8 +1724,6 @@ module FatCore
1758
1724
  11
1759
1725
  when /\Adec/i
1760
1726
  12
1761
- else
1762
- nil
1763
1727
  end
1764
1728
  end
1765
1729
 
@@ -1767,7 +1731,7 @@ module FatCore
1767
1731
  # last day of month.
1768
1732
  #
1769
1733
  # @param nth [Integer] the ordinal number for the weekday
1770
- # @param wday [Integer] the weekday of interest with Monday 0 to Sunday 6
1734
+ # @param wday [Integer] the weekday of interest with Sunday 0 to Saturday 6
1771
1735
  # @param year [Integer] the year of interest
1772
1736
  # @param month [Integer] the month of interest with January 1 to December 12
1773
1737
  def nth_wday_in_year_month(nth, wday, year, month)
@@ -1778,32 +1742,44 @@ module FatCore
1778
1742
  raise ArgumentError, 'illegal month number' if month < 1 || month > 12
1779
1743
 
1780
1744
  nth = nth.to_i
1781
- if nth.positive?
1782
- # Set d to the 1st wday in month
1783
- d = ::Date.new(year, month, 1)
1784
- d += 1 while d.wday != wday
1785
- # Set d to the nth wday in month
1786
- nd = 1
1787
- while nd != nth
1788
- d += 7
1789
- nd += 1
1790
- end
1791
- d
1792
- elsif nth.negative?
1793
- nth = -nth
1794
- # Set d to the last wday in month
1795
- d = ::Date.new(year, month, 1).end_of_month
1796
- d -= 1 while d.wday != wday
1797
- # Set d to the nth wday in month
1798
- nd = 1
1799
- while nd != nth
1800
- d -= 7
1801
- nd += 1
1745
+ if nth.abs > 5
1746
+ raise ArgumentError, "#{nth.abs} out of range: can be no more than 5 of any day of the week in any month"
1747
+ end
1748
+
1749
+ result =
1750
+ if nth.positive?
1751
+ # Set d to the 1st wday in month
1752
+ d = ::Date.new(year, month, 1)
1753
+ d += 1 while d.wday != wday
1754
+ # Set d to the nth wday in month
1755
+ nd = 1
1756
+ while nd != nth
1757
+ d += 7
1758
+ nd += 1
1759
+ end
1760
+ d
1761
+ elsif nth.negative?
1762
+ nth = -nth
1763
+ # Set d to the last wday in month
1764
+ d = ::Date.new(year, month, 1).end_of_month
1765
+ d -= 1 while d.wday != wday
1766
+ # Set d to the nth wday in month
1767
+ nd = 1
1768
+ while nd != nth
1769
+ d -= 7
1770
+ nd += 1
1771
+ end
1772
+ d
1773
+ else
1774
+ raise ArgumentError, 'Argument nth cannot be zero'
1802
1775
  end
1803
- d
1804
- else
1805
- raise ArgumentError, 'Argument nth cannot be zero'
1776
+
1777
+ dow = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][wday]
1778
+ if result.month != month
1779
+ raise ArgumentError, "There is no #{nth}th #{dow} in #{year}-#{month}"
1806
1780
  end
1781
+
1782
+ result
1807
1783
  end
1808
1784
 
1809
1785
  # Return the date of Easter for the Western Church in the given year.
@@ -1826,23 +1802,33 @@ module FatCore
1826
1802
  end
1827
1803
 
1828
1804
  # Ensure that date is of class Date based either on a string or Date
1829
- # object.
1805
+ # object or an object that responds to #to_date or #to_datetime. If the
1806
+ # given object is a String, use Date.parse to try to convert it.
1807
+ #
1808
+ # If given a DateTime, it returns the unrounded DateTime; if given a
1809
+ # Time, it converts it to a DateTime.
1830
1810
  #
1831
1811
  # @param dat [String, Date, Time] the object to be converted to Date
1832
1812
  # @return [Date, DateTime]
1833
1813
  def ensure_date(dat)
1834
- case dat
1835
- when String
1836
- ::Date.parse(dat)
1837
- when Date, DateTime
1814
+ if dat.is_a?(Date) || dat.is_a?(DateTime)
1838
1815
  dat
1839
- when Time
1816
+ elsif dat.is_a?(Time)
1817
+ dat.to_datetime
1818
+ elsif dat.respond_to?(:to_datetime)
1819
+ dat.to_datetime
1820
+ elsif dat.respond_to?(:to_date)
1840
1821
  dat.to_date
1822
+ elsif dat.is_a?(String)
1823
+ begin
1824
+ ::Date.parse(dat)
1825
+ rescue ::Date::Error
1826
+ raise ArgumentError, "cannot convert string '#{dat}' to a Date or DateTime"
1827
+ end
1841
1828
  else
1842
- raise ArgumentError, 'requires String, Date, DateTime, or Time'
1829
+ raise ArgumentError, "cannot convert class '#{dat.class}' to a Date or DateTime"
1843
1830
  end
1844
1831
  end
1845
- alias ensure ensure_date
1846
1832
  end
1847
1833
 
1848
1834
  def self.included(base)
@@ -1853,6 +1839,9 @@ end
1853
1839
 
1854
1840
  class Date
1855
1841
  include FatCore::Date
1842
+ def self.ensure(dat)
1843
+ ensure_date(dat)
1844
+ end
1856
1845
  # @!parse include FatCore::Date
1857
1846
  # @!parse extend FatCore::Date::ClassMethods
1858
1847
  end