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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +5 -0
  3. data/.rspec +1 -0
  4. data/.rubocop.yml +27 -0
  5. data/.ruby-version +1 -0
  6. data/CHANGELOG.md +6 -0
  7. data/CODE_OF_CONDUCT.md +7 -0
  8. data/CONTRIBUTING.md +7 -0
  9. data/Gemfile +4 -0
  10. data/Gemfile.lock +21 -0
  11. data/LICENSE +21 -0
  12. data/README.md +154 -0
  13. data/Rakefile +11 -0
  14. data/SECURITY.md +5 -0
  15. data/docs/README.md +19 -0
  16. data/docs/advanced-examples.md +28 -0
  17. data/docs/api-reference.md +15 -0
  18. data/docs/architecture.md +5 -0
  19. data/docs/business-days.md +7 -0
  20. data/docs/calendar-rendering.md +5 -0
  21. data/docs/date-converters.md +5 -0
  22. data/docs/date-semantics.md +21 -0
  23. data/docs/fiscal-445.md +11 -0
  24. data/docs/getting-started.md +12 -0
  25. data/docs/holidays.md +7 -0
  26. data/docs/ics-export.md +5 -0
  27. data/docs/online-resources.md +12 -0
  28. data/docs/payroll-schedules.md +5 -0
  29. data/docs/printable-calendars.md +11 -0
  30. data/docs/recurring-dates.md +7 -0
  31. data/examples/01-monthly-printable.rb +3 -0
  32. data/examples/02-yearly-svg.rb +3 -0
  33. data/examples/03-federal-holidays.rb +4 -0
  34. data/examples/04-business-deadline.rb +4 -0
  35. data/examples/05-biweekly-payroll.rb +4 -0
  36. data/examples/06-rotating-shifts.rb +4 -0
  37. data/examples/07-fiscal-445.rb +4 -0
  38. data/examples/08-excel-serial.rb +3 -0
  39. data/examples/09-windows-filetime.rb +3 -0
  40. data/examples/10-holidays-ics.rb +4 -0
  41. data/examples/11-rails-style-service.rb +8 -0
  42. data/examples/12-ink-saver-planner.rb +3 -0
  43. data/exe/make-calendar +156 -0
  44. data/lib/make_a_calendar/calendar.rb +187 -0
  45. data/lib/make_a_calendar/converters.rb +90 -0
  46. data/lib/make_a_calendar/core/date_math.rb +86 -0
  47. data/lib/make_a_calendar/errors.rb +7 -0
  48. data/lib/make_a_calendar/export/ics.rb +52 -0
  49. data/lib/make_a_calendar/scheduling/business_days.rb +72 -0
  50. data/lib/make_a_calendar/scheduling/fiscal.rb +67 -0
  51. data/lib/make_a_calendar/scheduling/holidays.rb +60 -0
  52. data/lib/make_a_calendar/scheduling/recurring.rb +100 -0
  53. data/lib/make_a_calendar/version.rb +5 -0
  54. data/lib/make_a_calendar.rb +52 -0
  55. data/samples/calendar-2027.svg +982 -0
  56. data/samples/october-2027-ink-saver.svg +103 -0
  57. data/samples/october-2027.svg +103 -0
  58. data/samples/us-federal-holidays-2027.ics +83 -0
  59. data/samples/us-federal-holidays-2027.json +57 -0
  60. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MakeACalendar
4
+ VERSION = "0.1.0".freeze
5
+ 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