make_a_calendar_toolkit 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.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.rspec +1 -0
- data/.rubocop.yml +27 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +6 -0
- data/CODE_OF_CONDUCT.md +7 -0
- data/CONTRIBUTING.md +7 -0
- data/Gemfile +4 -0
- data/Gemfile.lock +21 -0
- data/LICENSE +21 -0
- data/README.md +154 -0
- data/Rakefile +11 -0
- data/SECURITY.md +5 -0
- data/docs/README.md +19 -0
- data/docs/advanced-examples.md +28 -0
- data/docs/api-reference.md +15 -0
- data/docs/architecture.md +5 -0
- data/docs/business-days.md +7 -0
- data/docs/calendar-rendering.md +5 -0
- data/docs/date-converters.md +5 -0
- data/docs/date-semantics.md +21 -0
- data/docs/fiscal-445.md +11 -0
- data/docs/getting-started.md +12 -0
- data/docs/holidays.md +7 -0
- data/docs/ics-export.md +5 -0
- data/docs/online-resources.md +12 -0
- data/docs/payroll-schedules.md +5 -0
- data/docs/printable-calendars.md +11 -0
- data/docs/recurring-dates.md +7 -0
- data/examples/01-monthly-printable.rb +3 -0
- data/examples/02-yearly-svg.rb +3 -0
- data/examples/03-federal-holidays.rb +4 -0
- data/examples/04-business-deadline.rb +4 -0
- data/examples/05-biweekly-payroll.rb +4 -0
- data/examples/06-rotating-shifts.rb +4 -0
- data/examples/07-fiscal-445.rb +4 -0
- data/examples/08-excel-serial.rb +3 -0
- data/examples/09-windows-filetime.rb +3 -0
- data/examples/10-holidays-ics.rb +4 -0
- data/examples/11-rails-style-service.rb +8 -0
- data/examples/12-ink-saver-planner.rb +3 -0
- data/exe/make-calendar +156 -0
- data/lib/make_a_calendar/calendar.rb +187 -0
- data/lib/make_a_calendar/converters.rb +90 -0
- data/lib/make_a_calendar/core/date_math.rb +86 -0
- data/lib/make_a_calendar/errors.rb +7 -0
- data/lib/make_a_calendar/export/ics.rb +52 -0
- data/lib/make_a_calendar/scheduling/business_days.rb +72 -0
- data/lib/make_a_calendar/scheduling/fiscal.rb +67 -0
- data/lib/make_a_calendar/scheduling/holidays.rb +60 -0
- data/lib/make_a_calendar/scheduling/recurring.rb +100 -0
- data/lib/make_a_calendar/version.rb +5 -0
- data/lib/make_a_calendar.rb +52 -0
- data/samples/calendar-2027.svg +982 -0
- data/samples/october-2027-ink-saver.svg +103 -0
- data/samples/october-2027.svg +103 -0
- data/samples/us-federal-holidays-2027.ics +83 -0
- data/samples/us-federal-holidays-2027.json +57 -0
- metadata +139 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module MakeACalendar
|
|
6
|
+
FiscalPeriod = Struct.new(:number, :start_date, :end_date, :quarter, :weeks, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{ number: number, start_date: start_date.iso8601, end_date: end_date.iso8601, quarter: quarter, weeks: weeks }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class FiscalCalendar
|
|
13
|
+
attr_reader :label_year, :start_date, :end_date, :pattern, :periods
|
|
14
|
+
|
|
15
|
+
def initialize(year:, pattern: :four_four_five, extra_week: :last_period, label: :ending_year)
|
|
16
|
+
@label_year = Integer(year)
|
|
17
|
+
@pattern = normalize_pattern(pattern).freeze
|
|
18
|
+
raise ArgumentError, "extra_week must be :last_period or :first_period" unless %i[last_period first_period].include?(extra_week.to_sym)
|
|
19
|
+
raise ArgumentError, "label must be :ending_year or :starting_year" unless %i[ending_year starting_year].include?(label.to_sym)
|
|
20
|
+
start_year = label.to_sym == :ending_year ? @label_year - 1 : @label_year
|
|
21
|
+
next_start_year = start_year + 1
|
|
22
|
+
@start_date = nearest_sunday(Date.new(start_year, 2, 1))
|
|
23
|
+
next_start = nearest_sunday(Date.new(next_start_year, 2, 1))
|
|
24
|
+
weeks = ((next_start - @start_date).to_i / 7)
|
|
25
|
+
raise Error, "retail fiscal year must have 52 or 53 weeks" unless [52, 53].include?(weeks)
|
|
26
|
+
@end_date = next_start - 1
|
|
27
|
+
lengths = @pattern * 4
|
|
28
|
+
if weeks == 53
|
|
29
|
+
extra_week.to_sym == :last_period ? lengths[-1] += 1 : lengths[0] += 1
|
|
30
|
+
end
|
|
31
|
+
cursor = @start_date
|
|
32
|
+
@periods = lengths.each_with_index.map do |period_weeks, index|
|
|
33
|
+
period_end = cursor + (period_weeks * 7) - 1
|
|
34
|
+
period = FiscalPeriod.new(number: index + 1, start_date: cursor, end_date: period_end,
|
|
35
|
+
quarter: index / 3 + 1, weeks: period_weeks).freeze
|
|
36
|
+
cursor = period_end + 1
|
|
37
|
+
period
|
|
38
|
+
end.freeze
|
|
39
|
+
freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def period_for(date)
|
|
43
|
+
date = DateMath.date(date)
|
|
44
|
+
periods.find { |period| date >= period.start_date && date <= period.end_date }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def to_h
|
|
48
|
+
{ label_year: label_year, start_date: start_date.iso8601, end_date: end_date.iso8601,
|
|
49
|
+
weeks: ((end_date - start_date + 1).to_i / 7), pattern: pattern, periods: periods.map(&:to_h) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def normalize_pattern(value)
|
|
55
|
+
key = value.to_s.downcase.delete("_").delete("-")
|
|
56
|
+
patterns = { "445" => [4, 4, 5], "454" => [4, 5, 4], "544" => [5, 4, 4],
|
|
57
|
+
"fourfourfive" => [4, 4, 5], "fourfivefour" => [4, 5, 4], "fivefourfour" => [5, 4, 4] }
|
|
58
|
+
patterns.fetch(key) { raise ArgumentError, "pattern must be 445, 454, or 544" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def nearest_sunday(date)
|
|
62
|
+
before = date - date.wday
|
|
63
|
+
after = before + 7
|
|
64
|
+
(date - before).to_i <= (after - date).to_i ? before : after
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module MakeACalendar
|
|
6
|
+
Holiday = Struct.new(:name, :date, :observed_date, keyword_init: true) do
|
|
7
|
+
def to_h
|
|
8
|
+
{ name: name, date: date.iso8601, observed_date: observed_date.iso8601 }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module Holidays
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def us_federal(year)
|
|
16
|
+
year = Integer(year)
|
|
17
|
+
holidays = []
|
|
18
|
+
add = lambda do |name, day|
|
|
19
|
+
observed = day.saturday? ? day - 1 : (day.sunday? ? day + 1 : day)
|
|
20
|
+
holidays << Holiday.new(name: name, date: day, observed_date: observed).freeze
|
|
21
|
+
end
|
|
22
|
+
add.call("New Year's Day", Date.new(year, 1, 1)) if year >= 1870
|
|
23
|
+
add.call("Birthday of Martin Luther King, Jr.", nth_weekday(year, 1, 1, 3)) if year >= 1986
|
|
24
|
+
washington = year >= 1971 ? nth_weekday(year, 2, 1, 3) : Date.new(year, 2, 22)
|
|
25
|
+
add.call("Washington's Birthday", washington) if year >= 1879
|
|
26
|
+
memorial = year >= 1971 ? last_weekday(year, 5, 1) : Date.new(year, 5, 30)
|
|
27
|
+
add.call("Memorial Day", memorial) if year >= 1888
|
|
28
|
+
add.call("Juneteenth National Independence Day", Date.new(year, 6, 19)) if year >= 2021
|
|
29
|
+
add.call("Independence Day", Date.new(year, 7, 4)) if year >= 1870
|
|
30
|
+
add.call("Labor Day", nth_weekday(year, 9, 1, 1)) if year >= 1894
|
|
31
|
+
add.call("Columbus Day", nth_weekday(year, 10, 1, 2)) if year >= 1971
|
|
32
|
+
veterans = (1971..1977).cover?(year) ? nth_weekday(year, 10, 1, 4) : Date.new(year, 11, 11)
|
|
33
|
+
add.call("Veterans Day", veterans) if year >= 1938
|
|
34
|
+
thanksgiving = year >= 1939 ? nth_weekday(year, 11, 4, 4) : last_weekday(year, 11, 4)
|
|
35
|
+
add.call("Thanksgiving Day", thanksgiving) if year >= 1863
|
|
36
|
+
add.call("Christmas Day", Date.new(year, 12, 25)) if year >= 1870
|
|
37
|
+
holidays.freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def for_years(first_year, last_year = first_year)
|
|
41
|
+
(Integer(first_year)..Integer(last_year)).flat_map { |year| us_federal(year) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def observed_dates(first_year, last_year = first_year)
|
|
45
|
+
# Include adjacent legal years because a Saturday New Year's Day is observed on Dec 31.
|
|
46
|
+
for_years(Integer(first_year) - 1, Integer(last_year) + 1).map(&:observed_date).uniq.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def nth_weekday(year, month, wday, occurrence)
|
|
50
|
+
first = Date.new(year, month, 1)
|
|
51
|
+
first + ((wday - first.wday) % 7) + 7 * (occurrence - 1)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def last_weekday(year, month, wday)
|
|
55
|
+
last = (Date.new(year, month, 1) >> 1) - 1
|
|
56
|
+
last - ((last.wday - wday) % 7)
|
|
57
|
+
end
|
|
58
|
+
private_class_method :nth_weekday, :last_weekday
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module MakeACalendar
|
|
6
|
+
module Scheduling
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def pay_dates(start_date:, end_date:, frequency:, anchor: nil, adjust: :none, weekend: [0, 6], holidays: [])
|
|
10
|
+
first = DateMath.date(start_date)
|
|
11
|
+
last = DateMath.date(end_date)
|
|
12
|
+
raise ArgumentError, "schedule range is limited to 100 years" if (last - first).abs > BusinessDays::MAX_RANGE_DAYS
|
|
13
|
+
dates = case frequency.to_sym
|
|
14
|
+
when :weekly, :biweekly
|
|
15
|
+
interval = frequency.to_sym == :weekly ? 7 : 14
|
|
16
|
+
cursor = DateMath.date(anchor || first)
|
|
17
|
+
cursor += interval while cursor < first
|
|
18
|
+
list = []
|
|
19
|
+
while cursor <= last
|
|
20
|
+
list << cursor
|
|
21
|
+
cursor += interval
|
|
22
|
+
end
|
|
23
|
+
list
|
|
24
|
+
when :semi_monthly
|
|
25
|
+
semi_monthly_dates(first, last, anchor)
|
|
26
|
+
when :monthly
|
|
27
|
+
monthly_dates(first, last, anchor)
|
|
28
|
+
else
|
|
29
|
+
raise ArgumentError, "frequency must be weekly, biweekly, semi_monthly, or monthly"
|
|
30
|
+
end
|
|
31
|
+
dates.map do |date|
|
|
32
|
+
case adjust.to_sym
|
|
33
|
+
when :none then date
|
|
34
|
+
when :following then move_to_workday(date, 1, weekend, holidays)
|
|
35
|
+
when :preceding then move_to_workday(date, -1, weekend, holidays)
|
|
36
|
+
when :modified_following
|
|
37
|
+
moved = move_to_workday(date, 1, weekend, holidays)
|
|
38
|
+
moved.month == date.month ? moved : move_to_workday(date, -1, weekend, holidays)
|
|
39
|
+
else raise ArgumentError, "adjust must be none, following, preceding, or modified_following"
|
|
40
|
+
end
|
|
41
|
+
end.uniq.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def shift_dates(start_date:, end_date:, pattern:, anchor: nil)
|
|
45
|
+
first = DateMath.date(start_date)
|
|
46
|
+
last = DateMath.date(end_date)
|
|
47
|
+
raise ArgumentError, "schedule range is limited to 100 years" if (last - first).abs > BusinessDays::MAX_RANGE_DAYS
|
|
48
|
+
cycle = pattern.is_a?(String) ? pattern.split("-").map { |part| Integer(part) } : pattern.map { |part| Integer(part) }
|
|
49
|
+
raise ArgumentError, "pattern must contain positive on/off lengths" if cycle.empty? || cycle.any? { |part| part <= 0 }
|
|
50
|
+
anchor_date = DateMath.date(anchor || first)
|
|
51
|
+
DateMath.each_date(first, last).map do |date|
|
|
52
|
+
offset = (date - anchor_date).to_i % cycle.sum
|
|
53
|
+
segment_start = 0
|
|
54
|
+
segment_index = cycle.index do |segment_length|
|
|
55
|
+
covered = offset < segment_start + segment_length
|
|
56
|
+
segment_start += segment_length unless covered
|
|
57
|
+
covered
|
|
58
|
+
end
|
|
59
|
+
on_duty = segment_index.even?
|
|
60
|
+
{ date: date, on_duty: on_duty, cycle_day: offset + 1 }.freeze
|
|
61
|
+
end.freeze
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def semi_monthly_dates(first, last, anchor)
|
|
65
|
+
day = anchor ? DateMath.date(anchor).day : 15
|
|
66
|
+
dates = []
|
|
67
|
+
cursor = Date.new(first.year, first.month, 1)
|
|
68
|
+
while cursor <= last
|
|
69
|
+
month_end = (cursor >> 1) - 1
|
|
70
|
+
[Date.new(cursor.year, cursor.month, [day, month_end.day].min), month_end].each do |date|
|
|
71
|
+
dates << date if date >= first && date <= last
|
|
72
|
+
end
|
|
73
|
+
cursor = cursor >> 1
|
|
74
|
+
end
|
|
75
|
+
dates.uniq.sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def monthly_dates(first, last, anchor)
|
|
79
|
+
requested_day = anchor ? DateMath.date(anchor).day : 1
|
|
80
|
+
dates = []
|
|
81
|
+
cursor = Date.new(first.year, first.month, 1)
|
|
82
|
+
while cursor <= last
|
|
83
|
+
month_end = (cursor >> 1) - 1
|
|
84
|
+
date = Date.new(cursor.year, cursor.month, [requested_day, month_end.day].min)
|
|
85
|
+
dates << date if date >= first && date <= last
|
|
86
|
+
cursor = cursor >> 1
|
|
87
|
+
end
|
|
88
|
+
dates
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def move_to_workday(date, direction, weekend, holidays)
|
|
92
|
+
current = date
|
|
93
|
+
weekend_days = weekend.map { |day| DateMath.weekday_index(day) }
|
|
94
|
+
holiday_dates = holidays.map { |holiday| holiday.respond_to?(:observed_date) ? holiday.observed_date : DateMath.date(holiday) }
|
|
95
|
+
current += direction while weekend_days.include?(current.wday) || holiday_dates.include?(current)
|
|
96
|
+
current
|
|
97
|
+
end
|
|
98
|
+
private_class_method :semi_monthly_dates, :monthly_dates, :move_to_workday
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require_relative "make_a_calendar/version"
|
|
5
|
+
require_relative "make_a_calendar/errors"
|
|
6
|
+
require_relative "make_a_calendar/core/date_math"
|
|
7
|
+
require_relative "make_a_calendar/scheduling/holidays"
|
|
8
|
+
require_relative "make_a_calendar/scheduling/business_days"
|
|
9
|
+
require_relative "make_a_calendar/scheduling/recurring"
|
|
10
|
+
require_relative "make_a_calendar/scheduling/fiscal"
|
|
11
|
+
require_relative "make_a_calendar/converters"
|
|
12
|
+
require_relative "make_a_calendar/calendar"
|
|
13
|
+
require_relative "make_a_calendar/export/ics"
|
|
14
|
+
|
|
15
|
+
module MakeACalendar
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Build an immutable month grid. Weekdays use Sunday=0 through Saturday=6.
|
|
19
|
+
def month(year:, month:, week_start: :sunday, fixed_rows: nil, holidays: [])
|
|
20
|
+
Calendar.new(year: year, month: month, week_start: week_start, fixed_rows: fixed_rows, holidays: holidays)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Build a twelve-month printable calendar.
|
|
24
|
+
def year(year:, week_start: :sunday, holidays: [])
|
|
25
|
+
YearCalendar.new(year: year, week_start: week_start, holidays: holidays)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Count workdays in an inclusive date range by default.
|
|
29
|
+
def business_days_between(from, to, **options)
|
|
30
|
+
BusinessDays.between(from, to, **options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Add business days; the start date is not counted when days is nonzero.
|
|
34
|
+
def add_business_days(date, days, **options)
|
|
35
|
+
BusinessDays.add(date, days, **options)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Return the US federal holiday rules for a calendar year.
|
|
39
|
+
def federal_holidays(year)
|
|
40
|
+
Holidays.us_federal(year)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Create an NRF-style retail fiscal calendar named for its ending year.
|
|
44
|
+
def fiscal_calendar(year:, pattern: :four_four_five, extra_week: :last_period, label: :ending_year)
|
|
45
|
+
FiscalCalendar.new(year: year, pattern: pattern, extra_week: extra_week, label: label)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build date-only events as an RFC 5545 iCalendar string.
|
|
49
|
+
def to_ics(events, name: "Make a Calendar Toolkit")
|
|
50
|
+
ICS.calendar(events, name: name)
|
|
51
|
+
end
|
|
52
|
+
end
|