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