fat_date 0.1.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.
@@ -0,0 +1,2051 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FatDate
4
+ # ## FatDate Extensions
5
+ #
6
+ # The FatDate extensions to the Date class add the notion of several additional
7
+ # calendar periods besides years, months, and weeks to those provided for in the
8
+ # Date class and the active_support extensions to Date. In particular, there
9
+ # are several additional calendar subdivisions (called "chunks" in this
10
+ # documentation) supported by FatDate's extension to the Date class:
11
+ #
12
+ # * year,
13
+ # * half,
14
+ # * quarter,
15
+ # * bimonth,
16
+ # * month,
17
+ # * semimonth,
18
+ # * biweek,
19
+ # * week, and
20
+ # * day
21
+ #
22
+ # For each of those chunks, there are methods for finding the beginning and end
23
+ # of the chunk, for advancing or retreating a Date by the chunk, and for testing
24
+ # whether a Date is at the beginning or end of each of the chunk.
25
+ #
26
+ # FatDate's Date extension defines a few convenience formatting methods, such as
27
+ # Date#iso and Date#org for formatting Dates as ISO strings and as Emacs
28
+ # org-mode inactive timestamps respectively. It also has a few utility methods
29
+ # for determining the date of Easter, the number of days in any given month, and
30
+ # the Date of the nth workday in a given month (say the third Thursday in
31
+ # October, 2014).
32
+ #
33
+ # The Date extension defines a couple of class methods for parsing strings into
34
+ # Dates, especially Date.spec, which allows Dates to be specified in a
35
+ # lazy way, either absolutely or relative to the computer's clock.
36
+ #
37
+ # Finally FatDate's Date extensions provide thorough methods for determining if
38
+ # a Date is a United States federal holiday or workday based on US law,
39
+ # including executive orders. It does the same for the New York Stock Exchange,
40
+ # based on the rules of the New York Stock Exchange, including dates on which
41
+ # the NYSE was closed for special reasons, such as the 9-11 attacks in 2001.
42
+ module Date
43
+ # Constant for Beginning of Time (BOT) outside the range of what we would ever
44
+ # want to find in commercial situations.
45
+ BOT = ::Date.parse('1900-01-01')
46
+
47
+ # Constant for End of Time (EOT) outside the range of what we would ever want
48
+ # to find in commercial situations.
49
+ EOT = ::Date.parse('3000-12-31')
50
+
51
+ # Symbols for each of the days of the week, e.g., :sunday, :monday, etc.
52
+ DAYSYMS = ::Date::DAYNAMES.map { |name| name.downcase.to_sym }
53
+
54
+ # :category: Formatting
55
+ # @group Formatting
56
+
57
+ # Format as an ISO string of the form `YYYY-MM-DD`.
58
+ # @return [String]
59
+ def iso
60
+ strftime('%Y-%m-%d')
61
+ end
62
+
63
+ # :category: Formatting
64
+
65
+ # Format date to TeX documents as ISO strings but with en-dashes
66
+ # @return [String]
67
+ def tex_quote
68
+ strftime('%Y--%m--%d').tex_quote
69
+ end
70
+
71
+ # :category: Formatting
72
+
73
+ # Format as an all-numeric string of the form `YYYYMMDD`
74
+ # @return [String]
75
+ def num
76
+ strftime('%Y%m%d')
77
+ end
78
+
79
+ # :category: Formatting
80
+
81
+ # Format as an inactive Org date timestamp of the form `[YYYY-MM-DD <dow>]`
82
+ # (see Emacs org-mode)
83
+ # @return [String]
84
+ def org(active: false)
85
+ if active
86
+ strftime('<%Y-%m-%d %a>')
87
+ else
88
+ strftime('[%Y-%m-%d %a]')
89
+ end
90
+ end
91
+
92
+ # :category: Formatting
93
+
94
+ # Format as an English string, like `'January 12, 2016'`
95
+ # @return [String]
96
+ def eng
97
+ strftime('%B %-d, %Y')
98
+ end
99
+
100
+ # :category: Formatting
101
+
102
+ # Format date in `MM/DD/YYYY` form, as typical for the short American
103
+ # form.
104
+ # @return [String]
105
+ def american
106
+ strftime('%-m/%-d/%Y')
107
+ end
108
+
109
+ # :category: Queries
110
+ # @group Queries
111
+
112
+ # :category: Queries
113
+
114
+ # Number of days in self's month
115
+ # @return [Integer]
116
+ def days_in_month
117
+ self.class.days_in_month(year, month)
118
+ end
119
+
120
+ # :category: Queries
121
+ # @group Queries
122
+
123
+ # :category: Queries
124
+
125
+ # Self's calendar "half" by analogy to calendar quarters: 1 or 2, depending
126
+ # on whether the date falls in the first or second half of the calendar
127
+ # year.
128
+ # @return [Integer]
129
+ def half
130
+ case month
131
+ when (1..6)
132
+ 1
133
+ when (7..12)
134
+ 2
135
+ end
136
+ end
137
+
138
+ # :category: Queries
139
+
140
+ # Self's calendar quarter: 1, 2, 3, or 4, depending on which calendar quarter
141
+ # the date falls in.
142
+ # @return [Integer]
143
+ def quarter
144
+ case month
145
+ when (1..3)
146
+ 1
147
+ when (4..6)
148
+ 2
149
+ when (7..9)
150
+ 3
151
+ when (10..12)
152
+ 4
153
+ end
154
+ end
155
+
156
+ # Self's calendar bimonth: 1, 2, 3, 4, 5, or 6 depending on which calendar
157
+ # bimonth the date falls in.
158
+ # @return [Integer]
159
+ def bimonth
160
+ case month
161
+ when (1..2)
162
+ 1
163
+ when (3..4)
164
+ 2
165
+ when (5..6)
166
+ 3
167
+ when (7..8)
168
+ 4
169
+ when (9..10)
170
+ 5
171
+ when (11..12)
172
+ 6
173
+ end
174
+ end
175
+
176
+ # Self's calendar semimonth: 1, through 24 depending on which calendar
177
+ # semimonth the date falls in.
178
+ # @return [Integer]
179
+ def semimonth
180
+ ((month - 1) * 2) + (day <= 15 ? 1 : 2)
181
+ end
182
+
183
+ # Self's calendar biweek: 1, through 24 depending on which calendar
184
+ # semimonth the date falls in.
185
+ # @return [Integer]
186
+ def biweek
187
+ if cweek.odd?
188
+ (cweek + 1) / 2
189
+ else
190
+ cweek / 2
191
+ end
192
+ end
193
+
194
+ # Self's calendar week: just calls cweek.
195
+ # @return [Integer]
196
+ def week
197
+ cweek
198
+ end
199
+
200
+ # :category: Queries
201
+
202
+ # Return whether the date falls on the first day of a year.
203
+ # @return [Boolean]
204
+ def beginning_of_year?
205
+ beginning_of_year == self
206
+ end
207
+
208
+ # :category: Queries
209
+
210
+ # Return whether the date falls on the last day of a year.
211
+ # @return [Boolean]
212
+ def end_of_year?
213
+ end_of_year == self
214
+ end
215
+
216
+ # :category: Queries
217
+
218
+ # Return whether the date falls on the first day of a half-year.
219
+ # @return [Boolean]
220
+ def beginning_of_half?
221
+ beginning_of_half == self
222
+ end
223
+
224
+ # :category: Queries
225
+
226
+ # Return whether the date falls on the last day of a half-year.
227
+ # @return [Boolean]
228
+ def end_of_half?
229
+ end_of_half == self
230
+ end
231
+
232
+ # :category: Queries
233
+
234
+ # Return whether the date falls on the first day of a calendar quarter.
235
+ # @return [Boolean]
236
+ def beginning_of_quarter?
237
+ beginning_of_quarter == self
238
+ end
239
+
240
+ # :category: Queries
241
+
242
+ # Return whether the date falls on the last day of a calendar quarter.
243
+ # @return [Boolean]
244
+ def end_of_quarter?
245
+ end_of_quarter == self
246
+ end
247
+
248
+ # :category: Queries
249
+
250
+ # Return whether the date falls on the first day of a calendar bi-monthly
251
+ # period, i.e., the beginning of an odd-numbered month.
252
+ # @return [Boolean]
253
+ def beginning_of_bimonth?
254
+ month.odd? && beginning_of_month == self
255
+ end
256
+
257
+ # :category: Queries
258
+
259
+ # Return whether the date falls on the last day of a calendar bi-monthly
260
+ # period, i.e., the end of an even-numbered month.
261
+ # @return [Boolean]
262
+ def end_of_bimonth?
263
+ month.even? && end_of_month == self
264
+ end
265
+
266
+ # :category: Queries
267
+
268
+ # Return whether the date falls on the first day of a calendar month.
269
+ # @return [Boolean]
270
+ def beginning_of_month?
271
+ beginning_of_month == self
272
+ end
273
+
274
+ # :category: Queries
275
+
276
+ # Return whether the date falls on the last day of a calendar month.
277
+ # @return [Boolean]
278
+ def end_of_month?
279
+ end_of_month == self
280
+ end
281
+
282
+ # :category: Queries
283
+
284
+ # Return whether the date falls on the first day of a calendar semi-monthly
285
+ # period, i.e., on the 1st or 15th of a month.
286
+ # @return [Boolean]
287
+ def beginning_of_semimonth?
288
+ beginning_of_semimonth == self
289
+ end
290
+
291
+ # :category: Queries
292
+
293
+ # Return whether the date falls on the last day of a calendar semi-monthly
294
+ # period, i.e., on the 14th or the last day of a month.
295
+ # @return [Boolean]
296
+ def end_of_semimonth?
297
+ end_of_semimonth == self
298
+ end
299
+
300
+ # :category: Queries
301
+
302
+ # Return whether the date falls on the first day of a commercial bi-week,
303
+ # i.e., on /Monday/ in a commercial week that is an odd-numbered week. From
304
+ # ::Date: "The calendar week is a seven day period within a calendar year,
305
+ # starting on a Monday and identified by its ordinal number within the year;
306
+ # the first calendar week of the year is the one that includes the first
307
+ # Thursday of that year. In the Gregorian calendar, this is equivalent to
308
+ # the week which includes January 4."
309
+ # @return [Boolean]
310
+ def beginning_of_biweek?
311
+ beginning_of_biweek == self
312
+ end
313
+
314
+ # :category: Queries
315
+
316
+ # Return whether the date falls on the last day of a commercial bi-week,
317
+ # i.e., on /Sunday/ in a commercial week that is an even-numbered week. From
318
+ # ::Date: "The calendar week is a seven day period within a calendar year,
319
+ # starting on a Monday and identified by its ordinal number within the year;
320
+ # the first calendar week of the year is the one that includes the first
321
+ # Thursday of that year. In the Gregorian calendar, this is equivalent to
322
+ # the week which includes January 4."
323
+ # @return [Boolean]
324
+ def end_of_biweek?
325
+ end_of_biweek == self
326
+ end
327
+
328
+ # :category: Queries
329
+
330
+ # Return whether the date falls on the first day of a commercial week, i.e.,
331
+ # on /Monday/ in a commercial week. From ::Date: "The calendar week is a seven
332
+ # day period within a calendar year, starting on a Monday and identified by
333
+ # its ordinal number within the year; the first calendar week of the year is
334
+ # the one that includes the first Thursday of that year. In the Gregorian
335
+ # calendar, this is equivalent to the week which includes January 4."
336
+ # @return [Boolean]
337
+ def beginning_of_week?
338
+ beginning_of_week == self
339
+ end
340
+
341
+ # :category: Queries
342
+
343
+ # Return whether the date falls on the first day of a commercial week, i.e.,
344
+ # on /Sunday/ in a commercial week. From ::Date: "The calendar week is a seven
345
+ # day period within a calendar year, starting on a Monday and identified by
346
+ # its ordinal number within the year; the first calendar week of the year is
347
+ # the one that includes the first Thursday of that year. In the Gregorian
348
+ # calendar, this is equivalent to the week which includes January 4."
349
+ # @return [Boolean]
350
+ def end_of_week?
351
+ end_of_week == self
352
+ end
353
+
354
+ # Return whether this date falls within a period of *less* than six months
355
+ # from the date `d` using the *Stella v. Graham Page Motors* convention that
356
+ # "less" than six months is true only if this date falls within the range of
357
+ # dates 2 days after date six months before and 2 days before the date six
358
+ # months after the date `d`.
359
+ #
360
+ # @param from_date [::Date] the middle of the six-month range
361
+ # @return [Boolean]
362
+ def within_6mos_of?(from_date)
363
+ from_date = ::Date.parse(from_date) unless from_date.is_a?(Date)
364
+ from_day = from_date.day
365
+ if [28, 29, 30, 31].include?(from_day)
366
+ # Near the end of the month, we need to make sure that when we go
367
+ # forward or backwards 6 months, we do not go past the end of the
368
+ # destination month when finding the "corresponding" day in that
369
+ # month, per Stella v. Graham Page Motors. This refinement was
370
+ # endorsed in the Jammies International case. After we find the
371
+ # corresponding day in the target month, then add two days (for the
372
+ # month six months before the from_date) or subtract two days (for the
373
+ # month six months after the from_date) to get the first and last days
374
+ # of the "within a period of less than six months" date range.
375
+ start_month = from_date.beginning_of_month - 6.months
376
+ start_days = start_month.days_in_month
377
+ start_date = ::Date.new(start_month.year, start_month.month, [start_days, from_day].min) + 2.days
378
+ end_month = from_date.beginning_of_month + 6.months
379
+ end_days = end_month.days_in_month
380
+ end_date = ::Date.new(end_month.year, end_month.month, [end_days, from_day].min) - 2.days
381
+ else
382
+ # ::Date 6 calendar months before self
383
+ start_date = from_date - 6.months + 2.days
384
+ end_date = from_date + 6.months - 2.days
385
+ end
386
+ (start_date..end_date).cover?(self)
387
+ end
388
+
389
+ # Return whether this date is the `n`th weekday `wday` of the given `month` in
390
+ # this date's year.
391
+ #
392
+ # @param nth [Integer] number of wday in month, if negative count from end of
393
+ # the month
394
+ # @param wday [Integer] day of week, 0 is Sunday, 1 Monday, etc.
395
+ # @param month [Integer] the month number, 1 is January, 2 is February, etc.
396
+ # @return [Boolean]
397
+ def nth_wday_in_month?(nth, wday, month)
398
+ # Is self the nth weekday in the given month of its year?
399
+ # If nth is negative, count from last day of month
400
+ self == ::Date.nth_wday_in_year_month(nth, wday, year, month)
401
+ end
402
+
403
+ # :category: Relative ::Dates
404
+ # @group Relative ::Dates
405
+
406
+ # Predecessor of self, opposite of `#succ`.
407
+ # @return [::Date]
408
+ def pred
409
+ self - 1.day
410
+ end
411
+
412
+ # NOTE: the ::Date class already has a #succ method.
413
+
414
+ # The date that is the first day of the half-year in which self falls.
415
+ # @return [::Date]
416
+ def beginning_of_half
417
+ if month > 9
418
+ (beginning_of_quarter - 15).beginning_of_quarter
419
+ elsif month > 6
420
+ beginning_of_quarter
421
+ else
422
+ beginning_of_year
423
+ end
424
+ end
425
+
426
+ # :category: Relative ::Dates
427
+
428
+ # The date that is the last day of the half-year in which self falls.
429
+ # @return [::Date]
430
+ def end_of_half
431
+ if month < 4
432
+ (end_of_quarter + 15).end_of_quarter
433
+ elsif month < 7
434
+ end_of_quarter
435
+ else
436
+ end_of_year
437
+ end
438
+ end
439
+
440
+ # :category: Relative ::Dates
441
+
442
+ # The date that is the first day of the bimonth in which self
443
+ # falls. A 'bimonth' is a two-month calendar period beginning on the
444
+ # first day of the odd-numbered months. E.g., 2014-01-01 to
445
+ # 2014-02-28 is the first bimonth of 2014.
446
+ # @return [::Date]
447
+ def beginning_of_bimonth
448
+ if month.odd?
449
+ beginning_of_month
450
+ else
451
+ (self - 1.month).beginning_of_month
452
+ end
453
+ end
454
+
455
+ # :category: Relative ::Dates
456
+
457
+ # The date that is the last day of the bimonth in which self falls.
458
+ # A 'bimonth' is a two-month calendar period beginning on the first
459
+ # day of the odd-numbered months. E.g., 2014-01-01 to 2014-02-28 is
460
+ # the first bimonth of 2014.
461
+ # @return [::Date]
462
+ def end_of_bimonth
463
+ if month.odd?
464
+ (self + 1.month).end_of_month
465
+ else
466
+ end_of_month
467
+ end
468
+ end
469
+
470
+ # :category: Relative ::Dates
471
+
472
+ # The date that is the first day of the semimonth in which self
473
+ # falls. A semimonth is a calendar period beginning on the 1st or
474
+ # 16th of each month and ending on the 15th or last day of the month
475
+ # respectively. So each year has exactly 24 semimonths.
476
+ # @return [::Date]
477
+ def beginning_of_semimonth
478
+ if day >= 16
479
+ ::Date.new(year, month, 16)
480
+ else
481
+ beginning_of_month
482
+ end
483
+ end
484
+
485
+ # :category: Relative ::Dates
486
+
487
+ # The date that is the last day of the semimonth in which self
488
+ # falls. A semimonth is a calendar period beginning on the 1st or
489
+ # 16th of each month and ending on the 15th or last day of the month
490
+ # respectively. So each year has exactly 24 semimonths.
491
+ # @return [::Date]
492
+ def end_of_semimonth
493
+ if day <= 15
494
+ ::Date.new(year, month, 15)
495
+ else
496
+ end_of_month
497
+ end
498
+ end
499
+
500
+ # :category: Relative ::Dates
501
+
502
+ # In order to accomodate biweeks, we adopt the convention that weeks are
503
+ # numbered so that whatever week contains the year's first
504
+ # Date.beginning_of_week (usually Sunday or Monday) is week number 1.
505
+ # Biweeks are then pairs of weeks: an odd numbered week first, followed by
506
+ # an even numbered week last.
507
+ def week_number
508
+ start_of_weeks = ::Date.new(year, 1, 1)
509
+ bow_wday = DAYSYMS.index(::Date.beginning_of_week)
510
+ start_of_weeks += 1 until start_of_weeks.wday == bow_wday
511
+ if yday >= start_of_weeks.yday
512
+ ((yday - start_of_weeks.yday) / 7) + 1
513
+ else
514
+ # One of the days before the start of the year's first week, so it
515
+ # belongs to the last week of the prior year.
516
+ ::Date.new(year - 1, 12, 31).week_number
517
+ end
518
+ end
519
+
520
+ # Return the date that is the first day of the biweek in which self
521
+ # falls, or the first day of the year, whichever is later.
522
+ # @return [::Date]
523
+ def beginning_of_biweek
524
+ if week_number.odd?
525
+ beginning_of_week
526
+ else
527
+ (self - 1.week).beginning_of_week
528
+ end
529
+ end
530
+
531
+ # :category: Relative ::Dates
532
+
533
+ # Return the date that is the last day of the biweek in which
534
+ # self falls, or the last day of the year, whichever if earlier.
535
+ # @return [::Date]
536
+ def end_of_biweek
537
+ if week_number.even?
538
+ end_of_week
539
+ else
540
+ (self + 1.week).end_of_week
541
+ end
542
+ end
543
+
544
+ # NOTE: Date#end_of_week and Date#beginning_of_week is defined in ActiveSupport
545
+
546
+ # Return the date that is the first day of the commercial biweek in which
547
+ # self falls. A biweek is a period of two commercial weeks starting with an
548
+ # odd-numbered week and with each week starting in Monday and ending on
549
+ # Sunday.
550
+ # @return [::Date]
551
+ def beginning_of_bicweek
552
+ if cweek.odd?
553
+ beginning_of_week(::Date.beginning_of_week)
554
+ else
555
+ (self - 1.week).beginning_of_week(::Date.beginning_of_week)
556
+ end
557
+ end
558
+
559
+ # :category: Relative ::Dates
560
+
561
+ # Return the date that is the last day of the commercial biweek in which
562
+ # self falls. A biweek is a period of two commercial weeks starting with
563
+ # an odd-numbered week and with each week starting on Monday and ending on
564
+ # Sunday. So this will always return a Sunday in an even-numbered week.
565
+ # In the last week of the year (if it is not part of next year's first
566
+ # week) the end of the biweek will not extend beyond self's week, so that
567
+ # week 1 of the following year will start a new biweek. @return [::Date]
568
+ def end_of_bicweek
569
+ if cweek >= 52 && end_of_week(::Date.beginning_of_week).year > year
570
+ end_of_week(::Date.beginning_of_week)
571
+ elsif cweek.odd?
572
+ (self + 1.week).end_of_week(::Date.beginning_of_week)
573
+ else
574
+ end_of_week(::Date.beginning_of_week)
575
+ end
576
+ end
577
+
578
+ # NOTE: Date#end_of_week and Date#beginning_of_week is defined in ActiveSupport
579
+
580
+ # Return the date that is +n+ calendar halves after this date, where a
581
+ # calendar half is a period of 6 months.
582
+ #
583
+ # @param num [Integer] number of halves to advance, can be negative
584
+ # @return [::Date] new date n halves after this date
585
+ def next_half(num = 1)
586
+ num = num.floor
587
+ return self if num.zero?
588
+
589
+ next_month(num * 6)
590
+ end
591
+
592
+ # Return the date that is +n+ calendar halves before this date, where a
593
+ # calendar half is a period of 6 months.
594
+ #
595
+ # @param num [Integer] number of halves to retreat, can be negative
596
+ # @return [::Date] new date n halves before this date
597
+ def prior_half(num = 1)
598
+ next_half(-num)
599
+ end
600
+
601
+ # Return the date that is +n+ calendar quarters after this date, where a
602
+ # calendar quarter is a period of 3 months.
603
+ #
604
+ # @param num [Integer] number of quarters to advance, can be negative
605
+ # @return [::Date] new date n quarters after this date
606
+ def next_quarter(num = 1)
607
+ num = num.floor
608
+ return self if num.zero?
609
+
610
+ next_month(num * 3)
611
+ end
612
+
613
+ # Return the date that is +n+ calendar quarters before this date, where a
614
+ # calendar quarter is a period of 3 months.
615
+ #
616
+ # @param num [Integer] number of quarters to retreat, can be negative
617
+ # @return [::Date] new date n quarters after this date
618
+ def prior_quarter(num = 1)
619
+ next_quarter(-num)
620
+ end
621
+
622
+ # Return the date that is +n+ calendar bimonths after this date, where a
623
+ # calendar bimonth is a period of 2 months.
624
+ #
625
+ # @param num [Integer] number of bimonths to advance, can be negative
626
+ # @return [::Date] new date n bimonths after this date
627
+ def next_bimonth(num = 1)
628
+ num = num.floor
629
+ return self if num.zero?
630
+
631
+ next_month(num * 2)
632
+ end
633
+
634
+ # Return the date that is +n+ calendar bimonths before this date, where a
635
+ # calendar bimonth is a period of 2 months.
636
+ #
637
+ # @param num [Integer] number of bimonths to retreat, can be negative
638
+ # @return [::Date] new date n bimonths before this date
639
+ def prior_bimonth(num = 1)
640
+ next_bimonth(-num)
641
+ end
642
+
643
+ # Return the date that is +n+ semimonths after this date. Each semimonth begins
644
+ # on the 1st or 16th of the month, and advancing one semimonth from the first
645
+ # half of a month means to go as far past the 16th as the current date is past
646
+ # the 1st; advancing one semimonth from the second half of a month means to go
647
+ # as far into the next month past the 1st as the current date is past the
648
+ # 16th, but never past the 15th of the next month.
649
+ #
650
+ # @param num [Integer] number of semimonths to advance, can be negative
651
+ # @return [::Date] new date n semimonths after this date
652
+ def next_semimonth(num = 1)
653
+ num = num.floor
654
+ return self if num.zero?
655
+
656
+ factor = num.negative? ? -1 : 1
657
+ num = num.abs
658
+ if num.even?
659
+ next_month(num / 2)
660
+ else
661
+ # Advance or retreat one semimonth
662
+ next_sm =
663
+ if day == 1
664
+ if factor.positive?
665
+ beginning_of_month + 16.days
666
+ else
667
+ prior_month.beginning_of_month + 16.days
668
+ end
669
+ elsif day == 16
670
+ if factor.positive?
671
+ next_month.beginning_of_month
672
+ else
673
+ beginning_of_month
674
+ end
675
+ elsif day < 16
676
+ # In the first half of the month (the 2nd to the 15th), go as far past
677
+ # the 16th as the date is past the 1st. Thus, as many as 14 days past
678
+ # the 16th, so at most to the 30th of the month unless there are less
679
+ # than 30 days in the month, then go to the end of the month.
680
+ if factor.positive?
681
+ [beginning_of_month + 16.days + (day - 1).days, end_of_month].min
682
+ else
683
+ [
684
+ prior_month.beginning_of_month + 16.days + (day - 1).days,
685
+ prior_month.end_of_month
686
+ ].min
687
+ end
688
+ elsif factor.positive?
689
+ # In the second half of the month (17th to the 31st), go as many
690
+ # days into the next month as we are past the 16th. Thus, as many as
691
+ # 15 days. But we don't want to go past the first half of the next
692
+ # month, so we only go so far as the 15th of the next month.
693
+ # ::Date.parse('2015-02-18').next_semimonth should be the 3rd of the
694
+ # following month.
695
+ next_month.beginning_of_month + [(day - 16), 15].min
696
+ else
697
+ beginning_of_month + [(day - 16), 15].min
698
+ end
699
+ num -= 1
700
+ # Now that n is even, advance (or retreat) n / 2 months unless we're done.
701
+ if num >= 2
702
+ next_sm.next_month(factor * num / 2)
703
+ else
704
+ next_sm
705
+ end
706
+ end
707
+ end
708
+
709
+ # Return the date that is +n+ semimonths before this date. Each semimonth
710
+ # begins on the 1st or 15th of the month, and retreating one semimonth from
711
+ # the first half of a month means to go as far past the 15th of the prior
712
+ # month as the current date is past the 1st; retreating one semimonth from the
713
+ # second half of a month means to go as far past the 1st of the current month
714
+ # as the current date is past the 15th, but never past the 14th of the the
715
+ # current month.
716
+ #
717
+ # @param num [Integer] number of semimonths to retreat, can be negative
718
+ # @return [::Date] new date n semimonths before this date
719
+ def prior_semimonth(num = 1)
720
+ next_semimonth(-num)
721
+ end
722
+
723
+ # Return the date that is +n+ biweeks after this date where each biweek is 14
724
+ # days.
725
+ #
726
+ # @param num [Integer] number of biweeks to advance, can be negative
727
+ # @return [::Date] new date n biweeks after this date
728
+ def next_biweek(num = 1)
729
+ num = num.floor
730
+ return self if num.zero?
731
+
732
+ self + (14 * num)
733
+ end
734
+
735
+ # Return the date that is +n+ biweeks before this date where each biweek is 14
736
+ # days.
737
+ #
738
+ # @param num [Integer] number of biweeks to retreat, can be negative
739
+ # @return [::Date] new date n biweeks before this date
740
+ def prior_biweek(num = 1)
741
+ next_biweek(-num)
742
+ end
743
+
744
+ # Return the date that is +n+ weeks after this date where each week is 7 days.
745
+ # This is different from the #next_week method in active_support, which
746
+ # goes to the first day of the week in the next week and does not take an
747
+ # argument +n+ to go multiple weeks.
748
+ #
749
+ # @param num [Integer] number of weeks to advance
750
+ # @return [::Date] new date n weeks after this date
751
+ def next_week(num = 1)
752
+ num = num.floor
753
+ return self if num.zero?
754
+
755
+ self + (7 * num)
756
+ end
757
+
758
+ # Return the date that is +n+ weeks before this date where each week is 7
759
+ # days.
760
+ #
761
+ # @param num [Integer] number of weeks to retreat
762
+ # @return [::Date] new date n weeks from this date
763
+ def prior_week(num)
764
+ next_week(-num)
765
+ end
766
+
767
+ # NOTE: #next_day is defined in active_support.
768
+
769
+ # Return the date that is +n+ weeks before this date where each week is 7
770
+ # days.
771
+ #
772
+ # @param num [Integer] number of days to retreat
773
+ # @return [::Date] new date n days before this date
774
+ def prior_day(num)
775
+ next_day(-num)
776
+ end
777
+
778
+ # :category: Relative ::Dates
779
+
780
+ # Return the date that is n chunks later than self.
781
+ #
782
+ # @param chunk [Symbol] one of +:year+, +:half+, +:quarter+, +:bimonth+,
783
+ # +:month+, +:semimonth+, +:biweek+, +:week+, or +:day+.
784
+ # @param num [Integer] the number of chunks to add, can be negative
785
+ # @return [::Date] the date n chunks from this date
786
+ def add_chunk(chunk, num = 1)
787
+ case chunk
788
+ when :year
789
+ next_year(num)
790
+ when :half
791
+ next_month(6 * num)
792
+ when :quarter
793
+ next_month(3 * num)
794
+ when :bimonth
795
+ next_month(2 * num)
796
+ when :month
797
+ next_month(num)
798
+ when :semimonth
799
+ next_semimonth(num)
800
+ when :biweek
801
+ next_biweek(num)
802
+ when :week
803
+ next_week(num)
804
+ when :day
805
+ next_day(num)
806
+ else
807
+ raise ArgumentError, "add_chunk unknown chunk: '#{chunk}'"
808
+ end
809
+ end
810
+
811
+ # Return the date that is the beginning of the +chunk+ in which this date
812
+ # falls.
813
+ #
814
+ # @param chunk [Symbol] one of +:year+, +:half+, +:quarter+, +:bimonth+,
815
+ # +:month+, +:semimonth+, +:biweek+, +:week+, or +:day+.
816
+ # @return [::Date] the first date in the chunk-sized period in which this date
817
+ # falls
818
+ def beginning_of_chunk(chunk)
819
+ case chunk
820
+ when :year
821
+ beginning_of_year
822
+ when :half
823
+ beginning_of_half
824
+ when :quarter
825
+ beginning_of_quarter
826
+ when :bimonth
827
+ beginning_of_bimonth
828
+ when :month
829
+ beginning_of_month
830
+ when :semimonth
831
+ beginning_of_semimonth
832
+ when :biweek
833
+ beginning_of_biweek
834
+ when :week
835
+ beginning_of_week
836
+ when :day
837
+ self
838
+ else
839
+ raise ArgumentError, "unknown chunk sym: '#{chunk}'"
840
+ end
841
+ end
842
+
843
+ # Return the date that is the end of the +chunk+ in which this date
844
+ # falls.
845
+ #
846
+ # @param chunk [Symbol] one of +:year+, +:half+, +:quarter+, +:bimonth+,
847
+ # +:month+, +:semimonth+, +:biweek+, +:week+, or +:day+.
848
+ # @return [::Date] the first date in the chunk-sized period in which this date
849
+ # falls
850
+ def end_of_chunk(chunk)
851
+ case chunk
852
+ when :year
853
+ end_of_year
854
+ when :half
855
+ end_of_half
856
+ when :quarter
857
+ end_of_quarter
858
+ when :bimonth
859
+ end_of_bimonth
860
+ when :month
861
+ end_of_month
862
+ when :semimonth
863
+ end_of_semimonth
864
+ when :biweek
865
+ end_of_biweek
866
+ when :week
867
+ end_of_week
868
+ when :day
869
+ self
870
+ else
871
+ raise ArgumentError, "unknown chunk: '#{chunk}'"
872
+ end
873
+ end
874
+
875
+ # Return whether the date that is the beginning of the +chunk+
876
+ #
877
+ # @param chunk [Symbol] one of +:year+, +:half+, +:quarter+, +:bimonth+,
878
+ # +:month+, +:semimonth+, +:biweek+, +:week+, or +:day+.
879
+ # @return [::Boolean] whether this date begins a chunk
880
+ def beginning_of_chunk?(chunk)
881
+ self == beginning_of_chunk(chunk)
882
+ end
883
+
884
+ # Return whether the date that is the end of the +chunk+
885
+ #
886
+ # @param chunk [Symbol] one of +:year+, +:half+, +:quarter+, +:bimonth+,
887
+ # +:month+, +:semimonth+, +:biweek+, +:week+, or +:day+.
888
+ # @return [::Boolean] whether this date ends a chunk
889
+ def end_of_chunk?(chunk)
890
+ self == end_of_chunk(chunk)
891
+ end
892
+
893
+ # @group Holidays and Workdays
894
+
895
+ # Does self fall on a weekend?
896
+ # @return [Boolean]
897
+ def weekend?
898
+ saturday? || sunday?
899
+ end
900
+
901
+ # :category: Queries
902
+
903
+ # Does self fall on a weekday?
904
+ # @return [Boolean]
905
+ def weekday?
906
+ !weekend?
907
+ end
908
+
909
+ # Return the date for Easter in the Western Church for the year in which
910
+ # this date falls. A few holidays key off this date as "moveable feasts."
911
+ #
912
+ # @return [::Date]
913
+ def easter_this_year(reform_year: 1582)
914
+ # Return the date of Easter in self's year
915
+ ::Date.easter(year, reform_year: reform_year)
916
+ end
917
+
918
+ # Return whether this date is Easter Sunday for the year in which it falls
919
+ # according to the Western Church.
920
+ #
921
+ # @return [Boolean]
922
+ def easter?(reform_year: 1582)
923
+ # Am I Easter?
924
+ self == easter_this_year(reform_year: reform_year)
925
+ end
926
+
927
+ # Holidays decreed by Presidential proclamation
928
+ FED_DECREED_HOLIDAYS =
929
+ [
930
+ # Obama decree extra day before Christmas See
931
+ # http://www.whitehouse.gov/the-press-office/2012/12/21
932
+ ::Date.parse('2012-12-24'),
933
+ # And Trump
934
+ ::Date.parse('2018-12-24'),
935
+ ::Date.parse('2019-12-24'),
936
+ ::Date.parse('2020-12-24'),
937
+ # Biden
938
+ ::Date.parse('2024-12-24'),
939
+ ]
940
+
941
+ # Presidential funeral since JFK
942
+ PRESIDENTIAL_FUNERALS = [
943
+ # JKF Funeral
944
+ ::Date.parse('1963-11-25'),
945
+ # DWE Funeral
946
+ ::Date.parse('1969-03-31'),
947
+ # HST Funeral
948
+ ::Date.parse('1972-12-28'),
949
+ # LBJ Funeral
950
+ ::Date.parse('1973-01-25'),
951
+ # RMN Funeral
952
+ ::Date.parse('1994-04-27'),
953
+ # RWR Funeral
954
+ ::Date.parse('2004-06-11'),
955
+ # GTF Funeral
956
+ ::Date.parse('2007-01-02'),
957
+ # GHWBFuneral
958
+ ::Date.parse('2018-12-05'),
959
+ # JEC Funeral
960
+ ::Date.parse('2025-01-09'),
961
+ ]
962
+
963
+ # Return whether this date is a United States federal holiday.
964
+ #
965
+ # Calculations for Federal holidays are based on 5 USC 6103, include all
966
+ # weekends, Presidential funerals, and holidays decreed executive orders.
967
+ #
968
+ # @return [Boolean]
969
+ def fed_holiday?
970
+ # All Saturdays and Sundays are "holidays"
971
+ return true if weekend?
972
+
973
+ # Some days are holidays by executive decree
974
+ return true if FED_DECREED_HOLIDAYS.include?(self)
975
+
976
+ # Presidential funerals
977
+ return true if PRESIDENTIAL_FUNERALS.include?(self)
978
+
979
+ # Is self a fixed holiday
980
+ return true if fed_fixed_holiday? || fed_moveable_feast?
981
+
982
+ if friday? && month == 12 && day == 26
983
+ # If Christmas falls on a Thursday, apparently, the Friday after is
984
+ # treated as a holiday as well. See 2003, 2008, for example.
985
+ true
986
+ elsif friday?
987
+ # A Friday is a holiday if a fixed-date holiday
988
+ # would fall on the following Saturday
989
+ (self + 1).fed_fixed_holiday? || (self + 1).fed_moveable_feast?
990
+ elsif monday?
991
+ # A Monday is a holiday if a fixed-date holiday
992
+ # would fall on the preceding Sunday
993
+ (self - 1).fed_fixed_holiday? || (self - 1).fed_moveable_feast?
994
+ elsif (year % 4 == 1) && year > 1965 && mon == 1 && mday == 20
995
+ # Inauguration Day after 1965 is a federal holiday, but if it falls on a
996
+ # Sunday, the following Monday is observed, but if it falls on a
997
+ # Saturday, the prior Friday is /not/ observed. So, we can't just count
998
+ # this as a regular fixed holiday.
999
+ true
1000
+ elsif monday? && (year % 4 == 1) && year > 1965 && mon == 1 && mday == 21
1001
+ # Inauguration Day after 1965 is a federal holiday, but if it falls on a
1002
+ # Sunday, the following Monday is observed, but if it falls on a
1003
+ # Saturday, the prior Friday is /not/ observed.
1004
+ true
1005
+ else
1006
+ false
1007
+ end
1008
+ end
1009
+
1010
+ # Return whether this date is a date on which the US federal government is
1011
+ # open for business. It is the opposite of #fed_holiday?
1012
+ #
1013
+ # @return [Boolean]
1014
+ def fed_workday?
1015
+ !fed_holiday?
1016
+ end
1017
+
1018
+ # :category: Queries
1019
+
1020
+ # Return the date that is n federal workdays after or before (if n < 0) this
1021
+ # date.
1022
+ #
1023
+ # @param num [Integer] number of federal workdays to add to this date
1024
+ # @return [::Date]
1025
+ def add_fed_workdays(num)
1026
+ d = dup
1027
+ return d if num.zero?
1028
+
1029
+ incr = num.negative? ? -1 : 1
1030
+ num = num.abs
1031
+ while num.positive?
1032
+ d += incr
1033
+ num -= 1 if d.fed_workday?
1034
+ end
1035
+ d
1036
+ end
1037
+
1038
+ # Return the next federal workday after this date. The date returned is always
1039
+ # a date at least one day after this date, never this date.
1040
+ #
1041
+ # @return [::Date]
1042
+ def next_fed_workday
1043
+ add_fed_workdays(1)
1044
+ end
1045
+
1046
+ # Return the last federal workday before this date. The date returned is always
1047
+ # a date at least one day before this date, never this date.
1048
+ #
1049
+ # @return [::Date]
1050
+ def prior_fed_workday
1051
+ add_fed_workdays(-1)
1052
+ end
1053
+
1054
+ # Return this date if its a federal workday, otherwise skip forward to the
1055
+ # first later federal workday.
1056
+ #
1057
+ # @return [::Date]
1058
+ def next_until_fed_workday
1059
+ date = dup
1060
+ date += 1 until date.fed_workday?
1061
+ date
1062
+ end
1063
+
1064
+ # Return this if its a federal workday, otherwise skip back to the first prior
1065
+ # federal workday.
1066
+ def prior_until_fed_workday
1067
+ date = dup
1068
+ date -= 1 until date.fed_workday?
1069
+ date
1070
+ end
1071
+
1072
+ protected
1073
+
1074
+ def fed_fixed_holiday?
1075
+ # Fixed-date holidays on weekdays
1076
+ if mon == 1 && mday == 1
1077
+ # New Years (January 1),
1078
+ true
1079
+ elsif mon == 6 && mday == 19 && year >= 2021
1080
+ # Juneteenth,
1081
+ true
1082
+ elsif mon == 7 && mday == 4
1083
+ # Independence Day (July 4),
1084
+ true
1085
+ elsif mon == 11 && mday == 11
1086
+ # Veterans Day (November 11),
1087
+ true
1088
+ elsif mon == 12 && mday == 25
1089
+ # Christmas (December 25), and
1090
+ true
1091
+ else
1092
+ false
1093
+ end
1094
+ end
1095
+
1096
+ def fed_moveable_feast?
1097
+ # See if today is a "movable feast," all of which are
1098
+ # rigged to fall on Monday except Thanksgiving
1099
+
1100
+ # No moveable feasts in certain months
1101
+ if [3, 4, 6, 7, 8, 12].include?(month)
1102
+ false
1103
+ elsif monday?
1104
+ moveable_mondays = []
1105
+ # MLK's Birthday (Third Monday in Jan)
1106
+ moveable_mondays << nth_wday_in_month?(3, 1, 1)
1107
+ # Washington's Birthday (Third Monday in Feb)
1108
+ moveable_mondays << nth_wday_in_month?(3, 1, 2)
1109
+ # Memorial Day (Last Monday in May)
1110
+ moveable_mondays << nth_wday_in_month?(-1, 1, 5)
1111
+ # Labor Day (First Monday in Sep)
1112
+ moveable_mondays << nth_wday_in_month?(1, 1, 9)
1113
+ # Columbus Day (Second Monday in Oct)
1114
+ moveable_mondays << nth_wday_in_month?(2, 1, 10)
1115
+ # Other Mondays
1116
+ moveable_mondays.any?
1117
+ elsif thursday?
1118
+ # Thanksgiving Day (Fourth Thur in Nov)
1119
+ nth_wday_in_month?(4, 4, 11)
1120
+ else
1121
+ false
1122
+ end
1123
+ end
1124
+
1125
+ # @group NYSE Holidays and Workdays
1126
+
1127
+ # :category: Queries
1128
+
1129
+ public
1130
+
1131
+ # Returns whether this date is one on which the NYSE was or is expected to be
1132
+ # closed for business.
1133
+ #
1134
+ # Calculations for NYSE holidays are from Rule 51 and supplementary materials
1135
+ # for the Rules of the New York Stock Exchange, Inc.
1136
+ #
1137
+ # * General Rule 1: if a regular holiday falls on Saturday, observe it on
1138
+ # the preceding Friday.
1139
+ # * General Rule 2: if a regular holiday falls on Sunday, observe it on
1140
+ # the following Monday.
1141
+ #
1142
+ # These are the regular holidays:
1143
+ #
1144
+ # * New Year's Day, January 1.
1145
+ # * Birthday of Martin Luther King, Jr., the third Monday in January.
1146
+ # * Washington's Birthday, the third Monday in February.
1147
+ # * Good Friday Friday before Easter Sunday. NOTE: this is not a fed holiday
1148
+ # * Memorial Day, the last Monday in May.
1149
+ # * Independence Day, July 4.
1150
+ # * Labor Day, the first Monday in September.
1151
+ # * Thanksgiving Day, the fourth Thursday in November.
1152
+ # * Christmas Day, December 25.
1153
+ #
1154
+ # Columbus and Veterans days not observed.
1155
+ #
1156
+ # In addition, there have been several days on which the exchange has been
1157
+ # closed for special events such as Presidential funerals, the 9-11 attacks,
1158
+ # the paper-work crisis in the 1960's, hurricanes, etc. All of these are
1159
+ # considered holidays for purposes of this method.
1160
+ #
1161
+ # In addition, every weekend is considered a holiday.
1162
+ #
1163
+ # @return [Boolean]
1164
+ def nyse_holiday?
1165
+ # All Saturdays and Sundays are "holidays"
1166
+ return true if weekend?
1167
+
1168
+ # Presidential funerals, observed by NYSE as well.
1169
+ return true if PRESIDENTIAL_FUNERALS.include?(self)
1170
+
1171
+ # Is self a fixed holiday
1172
+ return true if nyse_fixed_holiday? || nyse_moveable_feast?
1173
+
1174
+ return true if nyse_special_holiday?
1175
+
1176
+ if friday? && (self >= ::Date.parse('1959-07-03'))
1177
+ # A Friday is a holiday if a holiday would fall on the following
1178
+ # Saturday. The rule does not apply if the Friday "ends a monthly or
1179
+ # yearly accounting period." Adopted July 3, 1959. E.g, December 31,
1180
+ # 2010, fell on a Friday, so New Years was on Saturday, but the NYSE
1181
+ # opened because it ended a yearly accounting period. I believe 12/31
1182
+ # is the only date to which the exception can apply since only New
1183
+ # Year's can fall on the first of the month.
1184
+ !end_of_quarter? &&
1185
+ ((self + 1).nyse_fixed_holiday? || (self + 1).nyse_moveable_feast?)
1186
+ elsif monday?
1187
+ # A Monday is a holiday if a holiday would fall on the
1188
+ # preceding Sunday. This has apparently always been the rule.
1189
+ (self - 1).nyse_fixed_holiday? || (self - 1).nyse_moveable_feast?
1190
+ else
1191
+ false
1192
+ end
1193
+ end
1194
+
1195
+ # Return whether the NYSE is open for trading on this date.
1196
+ #
1197
+ # @return [Boolean]
1198
+ def nyse_workday?
1199
+ !nyse_holiday?
1200
+ end
1201
+ alias_method :trading_day?, :nyse_workday?
1202
+
1203
+ # Return a new date that is n NYSE trading days after or before (if n < 0)
1204
+ # this date.
1205
+ #
1206
+ # @param num [Integer] number of NYSE trading days to add to this date
1207
+ # @return [::Date]
1208
+ def add_nyse_workdays(num)
1209
+ d = dup
1210
+ return d if num.zero?
1211
+
1212
+ incr = num.negative? ? -1 : 1
1213
+ num = num.abs
1214
+ while num.positive?
1215
+ d += incr
1216
+ num -= 1 if d.nyse_workday?
1217
+ end
1218
+ d
1219
+ end
1220
+ alias_method :add_trading_days, :add_nyse_workdays
1221
+
1222
+ # Return the next NYSE trading day after this date. The date returned is always
1223
+ # a date at least one day after this date, never this date.
1224
+ #
1225
+ # @return [::Date]
1226
+ def next_nyse_workday
1227
+ add_nyse_workdays(1)
1228
+ end
1229
+ alias_method :next_trading_day, :next_nyse_workday
1230
+
1231
+ # Return the last NYSE trading day before this date. The date returned is always
1232
+ # a date at least one day before this date, never this date.
1233
+ #
1234
+ # @return [::Date]
1235
+ def prior_nyse_workday
1236
+ add_nyse_workdays(-1)
1237
+ end
1238
+ alias_method :prior_trading_day, :prior_nyse_workday
1239
+
1240
+ # Return this date if its a trading day, otherwise skip forward to the first
1241
+ # later trading day.
1242
+ #
1243
+ # @return [::Date]
1244
+ def next_until_nyse_workday
1245
+ date = dup
1246
+ date += 1 until date.trading_day?
1247
+ date
1248
+ end
1249
+ alias_method :next_until_trading_day, :next_until_nyse_workday
1250
+
1251
+ # Return this date if its a trading day, otherwise skip back to the first prior
1252
+ # trading day.
1253
+ #
1254
+ # @return [::Date]
1255
+ def prior_until_trading_day
1256
+ date = dup
1257
+ date -= 1 until date.trading_day?
1258
+ date
1259
+ end
1260
+
1261
+ # Return whether this date is a fixed holiday for the NYSE, that is, a holiday
1262
+ # that falls on the same date each year.
1263
+ #
1264
+ # @return [Boolean]
1265
+ def nyse_fixed_holiday?
1266
+ # Fixed-date holidays
1267
+ if mon == 1 && mday == 1
1268
+ # New Years (January 1),
1269
+ true
1270
+ elsif mon == 7 && mday == 4
1271
+ # Independence Day (July 4),
1272
+ true
1273
+ elsif mon == 12 && mday == 25
1274
+ # Christmas (December 25), and
1275
+ true
1276
+ else
1277
+ false
1278
+ end
1279
+ end
1280
+
1281
+ # :category: Queries
1282
+
1283
+ # Return whether this date is a non-fixed holiday for the NYSE, that is, a holiday
1284
+ # that can fall on different dates each year, a so-called "moveable feast".
1285
+ #
1286
+ # @return [Boolean]
1287
+ def nyse_moveable_feast?
1288
+ # See if today is a "movable feast," all of which are
1289
+ # rigged to fall on Monday except Thanksgiving
1290
+
1291
+ # No moveable feasts in certain months
1292
+ return false if [6, 7, 8, 10, 12].include?(month)
1293
+
1294
+ case month
1295
+ when 1
1296
+ # MLK's Birthday (Third Monday in Jan) since 1998
1297
+ year >= 1998 && nth_wday_in_month?(3, 1, 1)
1298
+ when 2
1299
+ # Washington's Birthday was celebrated on February 22 until 1970. In
1300
+ # 1971 and later, it was moved to the third Monday in February. Note:
1301
+ # Lincoln's birthday is not an official holiday, but is sometimes
1302
+ # included with Washington's and called "Presidents' Day."
1303
+ if year <= 1970
1304
+ month == 2 && day == 22
1305
+ else
1306
+ nth_wday_in_month?(3, 1, 2)
1307
+ end
1308
+ when 3, 4
1309
+ # Good Friday
1310
+ if !friday?
1311
+ false
1312
+ elsif [1898, 1906, 1907].include?(year)
1313
+ # Good Friday, the Friday before Easter, except certain years
1314
+ false
1315
+ else
1316
+ (self + 2).easter?
1317
+ end
1318
+ when 5
1319
+ # Memorial Day (Last Monday in May)
1320
+ year <= 1970 ? (month == 5 && day == 30) : nth_wday_in_month?(-1, 1, 5)
1321
+ when 9
1322
+ # Labor Day (First Monday in Sep)
1323
+ nth_wday_in_month?(1, 1, 9)
1324
+ when 10
1325
+ # Columbus Day (Oct 12) 1909--1953
1326
+ year.between?(1909, 1953) && day == 12
1327
+ when 11
1328
+ if tuesday?
1329
+ # Election Day. Until 1968 all Election Days. From 1972 to 1980
1330
+ # Election Day in presidential years only. Election Day is the first
1331
+ # Tuesday after the first Monday in November.
1332
+ first_tuesday = ::Date.nth_wday_in_year_month(1, 1, year, 11) + 1
1333
+ is_election_day = (self == first_tuesday)
1334
+ if year <= 1968
1335
+ is_election_day
1336
+ elsif year <= 1980
1337
+ is_election_day && (year % 4).zero?
1338
+ else
1339
+ false
1340
+ end
1341
+ elsif thursday?
1342
+ # Historically Thanksgiving (NYSE closed all day) had been declared to
1343
+ # be the last Thursday in November until 1938; the next-to-last
1344
+ # Thursday in November from 1939 to 1941 (therefore the 3rd Thursday
1345
+ # in 1940 and 1941); the last Thursday in November in 1942; the fourth
1346
+ # Thursday in November since 1943;
1347
+ if year < 1938
1348
+ nth_wday_in_month?(-1, 4, 11)
1349
+ elsif year <= 1941
1350
+ nth_wday_in_month?(3, 4, 11)
1351
+ elsif year == 1942
1352
+ nth_wday_in_month?(-1, 4, 11)
1353
+ else
1354
+ nth_wday_in_month?(4, 4, 11)
1355
+ end
1356
+ elsif day == 11
1357
+ # Armistice or Veterans Day. 1918--1921; 1934--1953.
1358
+ year.between?(1918, 1921) || year.between?(1934, 1953)
1359
+ else
1360
+ false
1361
+ end
1362
+ else
1363
+ false
1364
+ end
1365
+ end
1366
+
1367
+ # :category: Queries
1368
+
1369
+ # They NYSE has closed on several occasions outside its normal holiday
1370
+ # rules. This detects those dates beginning in 1960. Closing for part of a
1371
+ # day is not counted. See http://www1.nyse.com/pdfs/closings.pdf. Return
1372
+ # whether this date is one of those special closings.
1373
+ #
1374
+ # @return [Boolean]
1375
+ def nyse_special_holiday?
1376
+ return false if self <= ::Date.parse('1960-01-01')
1377
+
1378
+ return true if PRESIDENTIAL_FUNERALS.include?(self)
1379
+
1380
+ case self
1381
+ when ::Date.parse('1961-05-29')
1382
+ # Day before Decoaration Day
1383
+ true
1384
+ when ::Date.parse('1963-11-25')
1385
+ # President Kennedy's funeral
1386
+ true
1387
+ when ::Date.parse('1965-12-24')
1388
+ # Christmas eve unscheduled for normal holiday
1389
+ true
1390
+ when ::Date.parse('1968-02-12')
1391
+ # Lincoln birthday
1392
+ true
1393
+ when ::Date.parse('1968-04-09')
1394
+ # Mourning MLK
1395
+ true
1396
+ when ::Date.parse('1968-07-05')
1397
+ # Day after Independence Day
1398
+ true
1399
+ when (::Date.parse('1968-06-12')..::Date.parse('1968-12-31'))
1400
+ # Paperwork crisis (closed on Wednesdays if no other holiday in week)
1401
+ wednesday? && (self - 2).nyse_workday? && (self - 1).nyse_workday? &&
1402
+ (self + 1).nyse_workday? && (self + 2).nyse_workday?
1403
+ when ::Date.parse('1969-02-10')
1404
+ # Heavy snow
1405
+ true
1406
+ when ::Date.parse('1969-07-21')
1407
+ # Moon landing
1408
+ true
1409
+ when ::Date.parse('1977-07-14')
1410
+ # Electrical blackout NYC
1411
+ true
1412
+ when ::Date.parse('1985-09-27')
1413
+ # Hurricane Gloria
1414
+ true
1415
+ when (::Date.parse('2001-09-11')..::Date.parse('2001-09-14'))
1416
+ # 9-11 Attacks
1417
+ true
1418
+ when ::Date.parse('2007-01-02')
1419
+ # Observance death of President Ford
1420
+ true
1421
+ when ::Date.parse('2012-10-29'), ::Date.parse('2012-10-30')
1422
+ # Hurricane Sandy
1423
+ true
1424
+ else
1425
+ false
1426
+ end
1427
+ end
1428
+
1429
+ module ClassMethods
1430
+ # @group Parsing
1431
+ #
1432
+ # Convert a string +str+ with an American style date into a ::Date object
1433
+ #
1434
+ # An American style date is of the form `MM/DD/YYYY`, that is it places the
1435
+ # month first, then the day of the month, and finally the year. The European
1436
+ # convention is typically to place the day of the month first, `DD/MM/YYYY`.
1437
+ # A date found in the wild can be ambiguous, e.g. 3/5/2014, but a date
1438
+ # string known to be using the American convention can be parsed using this
1439
+ # method. Both the month and the day can be a single digit. The year can be
1440
+ # either 2 or 4 digits, and if given as 2 digits, it adds 2000 to it to give
1441
+ # the year.
1442
+ #
1443
+ # @example
1444
+ # ::Date.parse_american('9/11/2001') #=> ::Date(2011, 9, 11)
1445
+ # ::Date.parse_american('9/11/01') #=> ::Date(2011, 9, 11)
1446
+ # ::Date.parse_american('9/11/1') #=> ArgumentError
1447
+ #
1448
+ # @param str [String, #to_s] a stringling of the form MM/DD/YYYY
1449
+ # @return [::Date] the date represented by the str paramenter.
1450
+ def parse_american(str)
1451
+ re = %r{\A\s*(\d\d?)\s*[-/]\s*(\d\d?)\s*[-/]\s*((\d\d)?\d\d)\s*\z}
1452
+ unless str.to_s =~ re
1453
+ raise ArgumentError, "date string must be of form 'MM?/DD?/YY(YY)?'"
1454
+ end
1455
+
1456
+ year = $3.to_i
1457
+ month = $1.to_i
1458
+ day = $2.to_i
1459
+ year += 2000 if year < 100
1460
+ ::Date.new(year, month, day)
1461
+ end
1462
+
1463
+ # Convert a 'period spec' `spec` to a ::Date. A date spec is a short-hand way of
1464
+ # specifying a calendar period either absolutely or relative to the computer
1465
+ # clock. This method returns the first date of that period, when `spec_type`
1466
+ # is set to `:from`, the default, and returns the last date of the period
1467
+ # when `spec_type` is `:to`.
1468
+ #
1469
+ # There are a number of forms the `spec` can take. In each case,
1470
+ # `::Date.spec` returns the first date in the period if `spec_type` is
1471
+ # `:from` and the last date in the period if `spec_type` is `:to`:
1472
+ #
1473
+ # - `YYYY-MM-DD` a particular date, so `:from` and `:to` return the same
1474
+ # - `YYYY` is the whole year `YYYY`,
1475
+ # - `YYYY-1H` or `YYYY-H1` is the first calendar half in year `YYYY`,
1476
+ # - `H2` or `2H` is the second calendar half of the current year,
1477
+ # - `YYYY-3Q` or `YYYY-Q3` is the third calendar quarter of year YYYY,
1478
+ # - `Q3` or `3Q` is the third calendar quarter in the current year,
1479
+ # - `YYYY-04` or `YYYY-4` is April, the fourth month of year `YYYY`,
1480
+ # - `4-12` or `04-12` is the 12th of April in the current year,
1481
+ # - `4` or `04` is April in the current year,
1482
+ # - `YYYY-W32` or `YYYY-32W` is the 32nd week in year YYYY,
1483
+ # - `W32` or `32W` is the 32nd week in the current year,
1484
+ # - `W32-4` or `32W-4` is the 4th day of the 32nd week in the current year,
1485
+ # - `YYYY-MM-A` or `YYYY-MM-B` is the first or second half of the given month,
1486
+ # - `YYYY-MM-i` or `YYYY-MM-v` is the first or fifth week of the given month,
1487
+ # - `YYYY-MM-I` or `YYYY-MM-V` is also the first or fifth week of the
1488
+ # given month, i.e., case does not matter,
1489
+ # - `MM-i` or `MM-v` is the first or fifth week of the current month,
1490
+ # - `YYYY-MM-3Tu` or `YYYY-MM-4Mo` or `YYYY-MM-3Tue` or `YYYY-MM-4Mon`
1491
+ # is the third Tuesdsay and fourth Monday of the given month,
1492
+ # - `MM-3Tu` or `MM-4Mo` (`MM-3Tue` or `MM-4Mon`) is the third Tuesdsay
1493
+ # and fourth Monday of the given month in the current year,
1494
+ # - `3Tu` or `4Mo` is the third Tuesdsay and fourth Monday of the current month,
1495
+ # - `YYYY-E` is Easter of the given year YYYY,
1496
+ # - `E` is Easter of the current year YYYY,
1497
+ # - `YYYY-E+50` and `YYYY-E-40` is 50 days after and 40 days before Easter of the given year,
1498
+ # - `E+50` and `E-40` is 50 days after and 40 days before Easter of the current year,
1499
+ # - `YYYY-001` and `YYYY-182` is first and 182nd day of the given year,
1500
+ # - `001` and `182` is first and 182nd day of the current year,
1501
+ # - `this_<chunk>` where `<chunk>` is one of `year`, `half`, `quarter`,
1502
+ # `bimonth`, `month`, `semimonth`, `biweek`, `week`, or `day`, the
1503
+ # corresponding calendar period in which the current date falls,
1504
+ # - `last_<chunk>` where `<chunk>` is one of `year`, `half`, `quarter`,
1505
+ # `bimonth`, `month`, `semimonth`, `biweek`, `week`, or `day`, the
1506
+ # corresponding calendar period immediately before the one in which the
1507
+ # current date falls,
1508
+ # - `today` is the same as `this_day`,
1509
+ # - `yesterday` is the same as `last_day`,
1510
+ # - `forever` is the period from ::Date::BOT to ::Date::EOT, essentially all
1511
+ # dates of commercial interest, and
1512
+ # - `never` causes the method to return nil.
1513
+ #
1514
+ # In all of the above example specs, the letter used for calendar
1515
+ # chunks, `W`, `Q`, and `H` can be written in lower case as well, as can
1516
+ # roman numeral week numbers. Also, you can use `/` to separate date
1517
+ # components instead of `-`. Likewise, days of the week can be any
1518
+ # string, upper or lower case, that starts with at least two letters of
1519
+ # the day-of-week name: 'Su', 'su', 'sund', 'sunday', 'surgery', all are
1520
+ # valid ways of writing 'Sunday'.
1521
+ #
1522
+ # Each of the foregoing specs may have a 'skip modifier' appended to it
1523
+ # for finding the following or preceding day-of-week from that date
1524
+ # given by the main spec. A skip modifier is a construction appended to
1525
+ # a date spec of the form '<Th', '>Th', '<=Th', or '>=Th' where 'Th'
1526
+ # could be the name of any day-of-the-week, as described in the
1527
+ # foregoing paragraph. It means that /starting from the date determined
1528
+ # by the date spec/, find the first Thursday before (<), on or before
1529
+ # (<=), after (>), or on or after (>=) the date. So Thanksgiving in
1530
+ # 2028 could be found with Date.spec('2028-11<=Th', :to), i.e.,
1531
+ # the last Thursday on or before the last day of November, 2028.
1532
+ #
1533
+ # @example
1534
+ # ::Date.spec('2012-W32').iso # => "2012-08-06"
1535
+ # ::Date.spec('2012-W32', :to).iso # => "2012-08-12"
1536
+ # ::Date.spec('W32').iso # => "2012-08-06" if executed in 2012
1537
+ # ::Date.spec('W32').iso # => "2012-08-04" if executed in 2014
1538
+ #
1539
+ # @param spec [String, #to_s] the spec to be interpreted as a calendar period
1540
+ #
1541
+ # @param spec_type [Symbol, :from, :to] return the first (:from) or last (:to)
1542
+ # date in the spec's period respectively
1543
+ #
1544
+ # @return [::Date] date that is the first (:from) or last (:to) in the period
1545
+ # designated by spec
1546
+ def spec(spec, spec_type = :from)
1547
+ spec = spec.to_s.strip.clean
1548
+ unless %i[from to].include?(spec_type)
1549
+ raise ArgumentError, "invalid date spec type: '#{spec_type}'"
1550
+ end
1551
+
1552
+ today = ::Date.today
1553
+ if (md = spec.match(/(?<dir>[<>]=?)(?<dow>Su|Mo|Tu|We|Th|Fr|Sa)[a-z]*\z/i))
1554
+ skip_to = md[:dow]
1555
+ skip_dir = md[:dir]
1556
+ spec = spec.sub(/[<>]=?(Su|Mo|Tu|We|Th|Fr|Sa)[a-z]*\z/i, '')
1557
+ else
1558
+ skip_to = nil
1559
+ skip_dir = nil
1560
+ end
1561
+ result =
1562
+ case spec
1563
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<doy>\d\d\d)\z}
1564
+ # With 3-digit YYYY-ddd, return the day-of-year
1565
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1566
+ doy = Regexp.last_match[:doy].to_i
1567
+ max_doy = ::Date.gregorian_leap?(year) ? 366 : 365
1568
+ if doy > max_doy
1569
+ raise ArgumentError, "invalid day-of-year '#{doy}' (1..#{max_doy}) in '#{spec}'"
1570
+ end
1571
+
1572
+ ::Date.new(year, 1, 1) + doy - 1
1573
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<mo>\d\d?)([-/](?<dy>\d\d?))?\z}
1574
+ # MM, YYYY-MM, MM-DD
1575
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
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 day
1583
+ ::Date.new(year, month, day)
1584
+ elsif spec_type == :from
1585
+ ::Date.new(year, month, 1)
1586
+ else
1587
+ ::Date.new(year, month, 1).end_of_month
1588
+ end
1589
+ when %r{\A((?<yr>\d\d\d\d)[-/])?(?<wk>\d\d?)W(-(?<dy>\d))?\z}xi,
1590
+ %r{\A((?<yr>\d\d\d\d)[-/])?W(?<wk>\d\d?)(-(?<dy>\d))?\z}xi
1591
+ # Commercial week numbers. The first commercial week of the year is
1592
+ # the one that includes the first Thursday of that year. In the
1593
+ # Gregorian calendar, this is equivalent to the week which includes
1594
+ # January 4. This appears to be the equivalent of ISO 8601 week
1595
+ # number as described at https://en.wikipedia.org/wiki/ISO_week_date
1596
+ year = Regexp.last_match[:yr]&.to_i
1597
+ week_num = Regexp.last_match[:wk].to_i
1598
+ day = Regexp.last_match[:dy]&.to_i
1599
+ unless (1..53).cover?(week_num)
1600
+ raise ArgumentError, "invalid week number (1-53): '#{spec}'"
1601
+ end
1602
+ if day && !(1..7).cover?(day)
1603
+ raise ArgumentError, "invalid ISO day number (1-7): '#{spec}'"
1604
+ end
1605
+
1606
+ if spec_type == :from
1607
+ ::Date.commercial(year ? year : today.year, week_num, day ? day : 1)
1608
+ else
1609
+ ::Date.commercial(year ? year : today.year, week_num, day ? day : 7)
1610
+ end
1611
+ when %r{^((?<yr>\d\d\d\d)[-/])?(?<qt>\d)[Qq]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Qq](?<qt>\d)$}
1612
+ # Year-Quarter
1613
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1614
+ quarter = Regexp.last_match[:qt].to_i
1615
+ unless [1, 2, 3, 4].include?(quarter)
1616
+ raise ArgumentError, "invalid quarter number (1-4): '#{spec}'"
1617
+ end
1618
+
1619
+ month = quarter * 3
1620
+ if spec_type == :from
1621
+ ::Date.new(year, month, 1).beginning_of_quarter
1622
+ else
1623
+ ::Date.new(year, month, 1).end_of_quarter
1624
+ end
1625
+ when %r{^((?<yr>\d\d\d\d)[-/])?(?<hf>\d)[Hh]$}, %r{^((?<yr>\d\d\d\d)[-/])?[Hh](?<hf>\d)$}
1626
+ # Year-Half
1627
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1628
+ half = Regexp.last_match[:hf].to_i
1629
+ msg = "invalid half number: '#{spec}'"
1630
+ raise ArgumentError, msg unless [1, 2].include?(half)
1631
+
1632
+ month = half * 6
1633
+ if spec_type == :from
1634
+ ::Date.new(year, month, 15).beginning_of_half
1635
+ else
1636
+ ::Date.new(year, month, 1).end_of_half
1637
+ end
1638
+ when /\A(?<yr>\d\d\d\d)\z/
1639
+ # Year only
1640
+ year = Regexp.last_match[:yr].to_i
1641
+ if spec_type == :from
1642
+ ::Date.new(year, 1, 1)
1643
+ else
1644
+ ::Date.new(year, 12, 31)
1645
+ end
1646
+ when %r{\A(((?<yr>\d\d\d\d)[-\/])?(?<mo>\d\d?)[-\/])?(?<hf_mo>(A|B))\z}i
1647
+ # Year, month, half-month, designated with A or B
1648
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1649
+ month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
1650
+ hf_mo = Regexp.last_match[:hf_mo]
1651
+ if hf_mo == "A"
1652
+ spec_type == :from ? ::Date.new(year, month, 1) : ::Date.new(year, month, 15)
1653
+ else
1654
+ # hf_mo == "B"
1655
+ spec_type == :from ? ::Date.new(year, month, 16) : ::Date.new(year, month, 16).end_of_month
1656
+ end
1657
+ when %r{\A((?<yr>\d\d\d\d)[-/])?((?<mo>\d\d?)[-/])?(?<wk>(i|ii|iii|iv|v|vi))\z}i
1658
+ # Year, month, week-of-month, partial-or-whole, designated with lowercase Roman
1659
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1660
+ month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
1661
+ wk = roman_to_week(Regexp.last_match[:wk])
1662
+ result =
1663
+ if spec_type == :from
1664
+ ::Date.new(year, month, 1).beginning_of_week + (wk - 1).weeks
1665
+ else
1666
+ ::Date.new(year, month, 1).end_of_week + (wk - 1).weeks
1667
+ end
1668
+ # If beginning of week of the 1st is in prior month, return the 1st
1669
+ result = [result, ::Date.new(year, month, 1)].max
1670
+ # If the whole week of the result is in the next month, there was no such week
1671
+ if result.beginning_of_week.month > month
1672
+ msg = sprintf("no week number #{wk} in %04d-%02d", year, month)
1673
+ raise ArgumentError, msg
1674
+ else
1675
+ # But if part of the result week is in this month, return end of month
1676
+ [result, ::Date.new(year, month, 1).end_of_month].min
1677
+ end
1678
+ 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
1679
+ # Year, month, ordinal dow name
1680
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1681
+ month = Regexp.last_match[:mo]&.to_i || ::Date.today.month
1682
+ nth = Regexp.last_match[:nth].to_i
1683
+ wday = dow_to_wday(Regexp.last_match[:dow])
1684
+ unless (1..12).cover?(month)
1685
+ raise ArgumentError, "invalid month number (1-12): '#{month}' in '#{spec}'"
1686
+ end
1687
+ unless (1..5).cover?(nth.abs)
1688
+ raise ArgumentError, "invalid ordinal day number (1-5): '#{nth}' in '#{spec}'"
1689
+ end
1690
+
1691
+ ::Date.nth_wday_in_year_month(nth, wday, year, month)
1692
+ when %r{\A(?<yr>\d\d\d\d[-/])?E(?<off>[+-]\d+)?\z}i
1693
+ # Easter for the given year, current year (if no year component),
1694
+ # optionally plus or minus a day offset
1695
+ year = Regexp.last_match[:yr]&.to_i || ::Date.today.year
1696
+ offset = Regexp.last_match[:off]&.to_i || 0
1697
+ ::Date.easter(year) + offset
1698
+ when %r{\A(?<rel>(to[_-]?|this[_-]?)|(last[_-]?|yester[_-]?|next[_-]?))
1699
+ (?<chunk>morrow|day|week|biweek|fortnight|semimonth|bimonth|month|quarter|half|year)\z}xi
1700
+ rel = Regexp.last_match[:rel]
1701
+ chunk = Regexp.last_match[:chunk].to_sym
1702
+ if chunk.match?(/morrow/i)
1703
+ chunk = :day
1704
+ rel = 'next'
1705
+ end
1706
+ if chunk.match?(/fortnight/i)
1707
+ chunk = :biweek
1708
+ end
1709
+ start =
1710
+ if rel.match?(/this|to/i)
1711
+ ::Date.today
1712
+ elsif rel.match?(/next/i)
1713
+ ::Date.today.add_chunk(chunk, 1)
1714
+ else
1715
+ # rel.match?(/last|yester/i)
1716
+ ::Date.today.add_chunk(chunk, -1)
1717
+ end
1718
+ if spec_type == :from
1719
+ start.beginning_of_chunk(chunk)
1720
+ else
1721
+ start.end_of_chunk(chunk)
1722
+ end
1723
+ when /^forever/i
1724
+ if spec_type == :from
1725
+ ::Date::BOT
1726
+ else
1727
+ ::Date::EOT
1728
+ end
1729
+ when /^never/i
1730
+ nil
1731
+ else
1732
+ raise ArgumentError, "XXX bad date spec: '#{spec}'"
1733
+ end
1734
+
1735
+ # Now, skip to the next or preceding skip day if skip_to is defined.
1736
+ if skip_to
1737
+ wday = dow_to_wday(skip_to)
1738
+ if result.wday == wday && skip_dir[1] == '='
1739
+ true # Do nothing
1740
+ elsif skip_dir[0] == '<'
1741
+ # Find prior date with dow
1742
+ result -= 1.day while result.wday != wday
1743
+ else
1744
+ # Find subsequent date with dow
1745
+ result += 1.day while result.wday != wday
1746
+ end
1747
+ end
1748
+ result
1749
+ end
1750
+
1751
+ # @group Utilities
1752
+
1753
+ # An Array of the number of days in each month indexed by month number,
1754
+ # starting with January = 1, etc.
1755
+ COMMON_YEAR_DAYS_IN_MONTH = [
1756
+ 31,
1757
+ 31,
1758
+ 28,
1759
+ 31,
1760
+ 30,
1761
+ 31,
1762
+ 30,
1763
+ 31,
1764
+ 31,
1765
+ 30,
1766
+ 31,
1767
+ 30,
1768
+ 31
1769
+ ].freeze
1770
+ def days_in_month(year, month)
1771
+ raise ArgumentError, 'illegal month number' if month < 1 || month > 12
1772
+
1773
+ days = COMMON_YEAR_DAYS_IN_MONTH[month]
1774
+ if month == 2
1775
+ ::Date.new(year, month, 1).leap? ? 29 : 28
1776
+ else
1777
+ days
1778
+ end
1779
+ end
1780
+
1781
+ # Return the 1-indexed integer that corresponds to a month name.
1782
+ #
1783
+ # @param name [String] a name of a month
1784
+ #
1785
+ # @return [Integer] the integer integer that corresponds to a month
1786
+ # name, or nil of no month recognized.
1787
+ def mo_name_to_num(name)
1788
+ case name.clean
1789
+ when /\Ajan/i
1790
+ 1
1791
+ when /\Afeb/i
1792
+ 2
1793
+ when /\Amar/i
1794
+ 3
1795
+ when /\Aapr/i
1796
+ 4
1797
+ when /\Amay/i
1798
+ 5
1799
+ when /\Ajun/i
1800
+ 6
1801
+ when /\Ajul/i
1802
+ 7
1803
+ when /\Aaug/i
1804
+ 8
1805
+ when /\Asep/i
1806
+ 9
1807
+ when /\Aoct/i
1808
+ 10
1809
+ when /\Anov/i
1810
+ 11
1811
+ when /\Adec/i
1812
+ 12
1813
+ end
1814
+ end
1815
+
1816
+ # Return the nth weekday in the given month. If n is negative, count from
1817
+ # last day of month.
1818
+ #
1819
+ # @param nth [Integer] the ordinal number for the weekday
1820
+ # @param wday [Integer] the weekday of interest with Sunday 0 to Saturday 6
1821
+ # @param year [Integer] the year of interest
1822
+ # @param month [Integer] the month of interest with January 1 to December 12
1823
+ def nth_wday_in_year_month(nth, wday, year, month)
1824
+ wday = wday.to_i
1825
+ raise ArgumentError, 'illegal weekday number' if wday.negative? || wday > 6
1826
+
1827
+ month = month.to_i
1828
+ raise ArgumentError, 'illegal month number' if month < 1 || month > 12
1829
+
1830
+ nth = nth.to_i
1831
+ if nth.abs > 5
1832
+ raise ArgumentError, "#{nth.abs} out of range: can be no more than 5 of any day of the week in any month"
1833
+ end
1834
+
1835
+ result =
1836
+ if nth.positive?
1837
+ # Set d to the 1st wday in month
1838
+ d = ::Date.new(year, month, 1)
1839
+ d += 1 while d.wday != wday
1840
+ # Set d to the nth wday in month
1841
+ nd = 1
1842
+ while nd != nth
1843
+ d += 7
1844
+ nd += 1
1845
+ end
1846
+ d
1847
+ elsif nth.negative?
1848
+ nth = -nth
1849
+ # Set d to the last wday in month
1850
+ d = ::Date.new(year, month, 1).end_of_month
1851
+ d -= 1 while d.wday != wday
1852
+ # Set d to the nth wday in month
1853
+ nd = 1
1854
+ while nd != nth
1855
+ d -= 7
1856
+ nd += 1
1857
+ end
1858
+ d
1859
+ else
1860
+ raise ArgumentError, 'Argument nth cannot be zero'
1861
+ end
1862
+
1863
+ dow = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][wday]
1864
+ if result.month != month
1865
+ raise ArgumentError, "There is no #{nth}th #{dow} in #{year}-#{month}"
1866
+ end
1867
+
1868
+ result
1869
+ end
1870
+
1871
+ # Return the wday number for the given DOW string allowing flexibility
1872
+ # on how the days are written
1873
+ def dow_to_wday(s)
1874
+ case s.clean
1875
+ when /\ASu/i
1876
+ 0
1877
+ when /\AMo/i
1878
+ 1
1879
+ when /\ATu/i
1880
+ 2
1881
+ when /\AWe/i
1882
+ 3
1883
+ when /\ATh/i
1884
+ 4
1885
+ when /\AFr/i
1886
+ 5
1887
+ when /\ASa/i
1888
+ 6
1889
+ else
1890
+ raise ArgumentError, "There is no weekday named #{s}"
1891
+ end
1892
+ end
1893
+
1894
+ # Return the week number indicated by roman numerals i to vi, regardless
1895
+ # of case.
1896
+ def roman_to_week(s)
1897
+ case s.clean
1898
+ when /\Ai\z/i
1899
+ 1
1900
+ when /\Aii\z/i
1901
+ 2
1902
+ when /\Aiii\z/i
1903
+ 3
1904
+ when /\Aiv\z/i
1905
+ 4
1906
+ when /\Av\z/i
1907
+ 5
1908
+ when /\Avi\z/i
1909
+ 6
1910
+ else
1911
+ raise ArgumentError, "There is no week number #{s}"
1912
+ end
1913
+ end
1914
+
1915
+ # # Return the date of Easter for the Western Church in the given year.
1916
+ # #
1917
+ # # @param year [Integer] the year of interest
1918
+ # # @return [::Date] the date of Easter for year
1919
+ # def easter(year)
1920
+ # year = year.to_i
1921
+ # y = year
1922
+ # a = y % 19
1923
+ # b, c = y.divmod(100)
1924
+ # d, e = b.divmod(4)
1925
+ # f = (b + 8) / 25
1926
+ # g = (b - f + 1) / 3
1927
+ # h = (19 * a + b - d - g + 15) % 30
1928
+ # i, k = c.divmod(4)
1929
+ # l = (32 + 2 * e + 2 * i - h - k) % 7
1930
+ # m = (a + 11 * h + 22 * l) / 451
1931
+ # n, p = (h + l - 7 * m + 114).divmod(31)
1932
+ # ::Date.new(y, n, p + 1)
1933
+ # end
1934
+
1935
+ # Map from "golden number" (y % 19) of year to month and day of Pachal
1936
+ # full moon. From
1937
+ # https://www.calendarbede.com/book/calculation-julian-easter-sunday
1938
+ GOLDEN_TO_FULLMOON = {
1939
+ 1 => [4, 5],
1940
+ 2 => [3, 25],
1941
+ 3 => [4, 13],
1942
+ 4 => [4, 2],
1943
+ 5 => [3, 22],
1944
+ 6 => [4, 10],
1945
+ 7 => [3, 30],
1946
+ 8 => [4, 18],
1947
+ 9 => [4, 7],
1948
+ 10 => [3, 27],
1949
+ 11 => [4, 15],
1950
+ 12 => [4, 4],
1951
+ 13 => [3, 24],
1952
+ 14 => [4, 12],
1953
+ 15 => [4, 1],
1954
+ 16 => [3, 21],
1955
+ 17 => [4, 9],
1956
+ 18 => [3, 29],
1957
+ 19 => [4, 17],
1958
+ }
1959
+
1960
+ # Return the date of Easter for the Western Church in the given year,
1961
+ # accounting for calendar reform.
1962
+ #
1963
+ # @param year [Integer] the year of interest
1964
+ # @param reform_year [Integer, nil] the year of Gregorian calendar adoption (default: 1582)
1965
+ # @return [::Date] the date of Easter for year
1966
+ def easter(year, reform_year: 1582)
1967
+ require 'date'
1968
+ year = year.to_i
1969
+ y = year
1970
+ # No Easter before the Resurrection!
1971
+ return if year < 30
1972
+
1973
+ # Note that since the reform took place in October 1582 in Catholic
1974
+ # Europe and in Spetember 1752 in England
1975
+ if y <= reform_year
1976
+ golden = y % 19 + 1
1977
+ p_full = ::Date.new(
1978
+ y,
1979
+ GOLDEN_TO_FULLMOON[golden][0],
1980
+ GOLDEN_TO_FULLMOON[golden][1],
1981
+ )
1982
+ if p_full.wday.zero?
1983
+ p_full + 7.days
1984
+ else
1985
+ easter = p_full
1986
+ easter += 1 until easter.wday.zero?
1987
+ easter
1988
+ end
1989
+ else
1990
+ # Gregorian calendar calculation (original code)
1991
+ a = y % 19
1992
+ b, c = y.divmod(100)
1993
+ d, e = b.divmod(4)
1994
+ f = (b + 8) / 25
1995
+ g = (b - f + 1) / 3
1996
+ h = (19 * a + b - d - g + 15) % 30
1997
+ i, k = c.divmod(4)
1998
+ l = (32 + 2 * e + 2 * i - h - k) % 7
1999
+ m = (a + 11 * h + 22 * l) / 451
2000
+ n, p = (h + l - 7 * m + 114).divmod(31)
2001
+ ::Date.new(y, n, p + 1)
2002
+ end
2003
+ end
2004
+
2005
+ # Ensure that date is of class Date based either on a string or Date
2006
+ # object or an object that responds to #to_date or #to_datetime. If the
2007
+ # given object is a String, use Date.parse to try to convert it.
2008
+ #
2009
+ # If given a DateTime, it returns the unrounded DateTime; if given a
2010
+ # Time, it converts it to a DateTime.
2011
+ #
2012
+ # @param dat [String, Date, Time] the object to be converted to Date
2013
+ # @return [Date, DateTime]
2014
+ def ensure_date(dat)
2015
+ if dat.is_a?(Date) || dat.is_a?(DateTime)
2016
+ dat
2017
+ elsif dat.is_a?(Time)
2018
+ dat.to_datetime
2019
+ elsif dat.respond_to?(:to_datetime)
2020
+ dat.to_datetime
2021
+ elsif dat.respond_to?(:to_date)
2022
+ dat.to_date
2023
+ elsif dat.is_a?(String)
2024
+ begin
2025
+ ::Date.parse(dat)
2026
+ rescue ::Date::Error
2027
+ raise ArgumentError, "cannot convert string '#{dat}' to a Date or DateTime"
2028
+ end
2029
+ else
2030
+ raise ArgumentError, "cannot convert class '#{dat.class}' to a Date or DateTime"
2031
+ end
2032
+ end
2033
+ end
2034
+
2035
+ def self.included(base)
2036
+ base.extend(ClassMethods)
2037
+ end
2038
+ end
2039
+ end
2040
+
2041
+ # Include the FatDate methods in Date and extend the Date class methods with
2042
+ # the FatDate::ClassMethods methods.
2043
+ class Date
2044
+ include FatDate::Date
2045
+
2046
+ def self.ensure(dat)
2047
+ ensure_date(dat)
2048
+ end
2049
+ # @!parse include FatDate::Date
2050
+ # @!parse extend FatDate::Date::ClassMethods
2051
+ end