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
data/exe/make-calendar ADDED
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ begin
7
+ require "make_a_calendar"
8
+ rescue LoadError
9
+ require_relative "../lib/make_a_calendar"
10
+ end
11
+
12
+ class MakeCalendarCLI
13
+ def run(argv)
14
+ command = argv.shift
15
+ return help if command.nil? || %w[-h --help help].include?(command)
16
+ options = { format: "json" }
17
+ options[:converter] = argv.shift if command == "convert"
18
+ parser = OptionParser.new do |opts|
19
+ opts.banner = "Usage: make-calendar COMMAND [options]"
20
+ opts.on("--year YEAR", Integer) { |value| options[:year] = value }
21
+ opts.on("--month MONTH", Integer) { |value| options[:month] = value }
22
+ opts.on("--week-start DAY") { |value| options[:week_start] = value.to_sym }
23
+ opts.on("--format FORMAT", %w[json csv html svg ics]) { |value| options[:format] = value }
24
+ opts.on("--from DATE") { |value| options[:from] = value }
25
+ opts.on("--to DATE") { |value| options[:to] = value }
26
+ opts.on("--date DATE") { |value| options[:date] = value }
27
+ opts.on("--days COUNT", Integer) { |value| options[:days] = value }
28
+ opts.on("--country COUNTRY") { |value| options[:country] = value }
29
+ opts.on("--observed") { options[:observed] = true }
30
+ opts.on("--pattern PATTERN") { |value| options[:pattern] = value }
31
+ opts.on("--start DATE") { |value| options[:start] = value }
32
+ opts.on("--output PATH") { |value| options[:output] = value }
33
+ opts.on("--input PATH") { |value| options[:input] = value }
34
+ opts.on("--force") { options[:force] = true }
35
+ opts.on("--serial VALUE") { |value| options[:serial] = value }
36
+ opts.on("--system SYSTEM") { |value| options[:system] = value }
37
+ end
38
+ parser.parse!(argv)
39
+ result = execute(command, options)
40
+ write_result(result, options)
41
+ 0
42
+ rescue OptionParser::ParseError, ArgumentError, MakeACalendar::Error, KeyError => error
43
+ warn "make-calendar: #{error.message}"
44
+ 2
45
+ end
46
+
47
+ private
48
+
49
+ def execute(command, options)
50
+ case command
51
+ when "month"
52
+ require_options(options, :year, :month)
53
+ calendar = MakeACalendar.month(year: options[:year], month: options[:month], week_start: options.fetch(:week_start, :sunday))
54
+ render(calendar, options)
55
+ when "year"
56
+ require_options(options, :year)
57
+ calendar = MakeACalendar.year(year: options[:year], week_start: options.fetch(:week_start, :sunday))
58
+ render(calendar, options)
59
+ when "business-days"
60
+ require_options(options, :from, :to)
61
+ count = MakeACalendar.business_days_between(options[:from], options[:to], holidays: options[:observed] ? MakeACalendar::Holidays.observed_dates(DateMath.date(options[:from]).year, DateMath.date(options[:to]).year) : [])
62
+ "#{count}\n"
63
+ when "add-business-days"
64
+ require_options(options, :date, :days)
65
+ MakeACalendar.add_business_days(options[:date], options[:days]).iso8601 + "\n"
66
+ when "holidays"
67
+ require_options(options, :year)
68
+ raise ArgumentError, "only --country us is supported" unless options.fetch(:country, "us").downcase == "us"
69
+ holidays = MakeACalendar.federal_holidays(options[:year])
70
+ holidays = holidays.map { |holiday| { name: holiday.name, date: (options[:observed] ? holiday.observed_date : holiday.date).iso8601 } }
71
+ JSON.pretty_generate(holidays) + "\n"
72
+ when "fiscal"
73
+ require_options(options, :year)
74
+ fiscal = MakeACalendar.fiscal_calendar(year: options[:year], pattern: options.fetch(:pattern, "445"))
75
+ JSON.pretty_generate(fiscal.to_h) + "\n"
76
+ when "shifts"
77
+ require_options(options, :start, :to, :pattern)
78
+ pattern = options[:pattern].split("-").map(&:to_i)
79
+ JSON.pretty_generate(MakeACalendar::Scheduling.shift_dates(start_date: options[:start], end_date: options[:to], pattern: pattern).map { |item| { date: item[:date].iso8601, on_duty: item[:on_duty], cycle_day: item[:cycle_day] } }) + "\n"
80
+ when "convert"
81
+ convert(options)
82
+ when "export-ics"
83
+ require_options(options, :input)
84
+ events = JSON.parse(File.read(options[:input]), symbolize_names: true)
85
+ MakeACalendar.to_ics(events)
86
+ else
87
+ raise ArgumentError, "unknown command #{command.inspect}; run make-calendar --help"
88
+ end
89
+ end
90
+
91
+ def render(calendar, options)
92
+ case options[:format]
93
+ when "json" then calendar.to_json + "\n"
94
+ when "csv" then calendar.respond_to?(:to_csv) ? calendar.to_csv : raise(ArgumentError, "CSV is supported for month calendars")
95
+ when "html" then calendar.to_html + "\n"
96
+ when "svg" then calendar.to_svg + "\n"
97
+ else raise ArgumentError, "format must be json, csv, html, or svg for calendars"
98
+ end
99
+ end
100
+
101
+ def convert(options)
102
+ case options.fetch(:converter) { raise ArgumentError, "use convert excel --serial VALUE [--system 1900|1904]" }
103
+ when "excel"
104
+ require_options(options, :serial)
105
+ MakeACalendar::Converters.excel(options[:serial], system: options.fetch(:system, "1900")).to_s + "\n"
106
+ when "ordinal"
107
+ require_options(options, :serial)
108
+ MakeACalendar::Converters.ordinal(options[:serial]).iso8601 + "\n"
109
+ when "unix"
110
+ require_options(options, :serial)
111
+ MakeACalendar::Converters.unix(options[:serial], unit: options.fetch(:system, "seconds").to_sym).iso8601 + "\n"
112
+ else
113
+ raise ArgumentError, "converter must be excel, ordinal, or unix"
114
+ end
115
+ end
116
+
117
+ def require_options(options, *keys)
118
+ missing = keys.reject { |key| options.key?(key) }
119
+ raise ArgumentError, "missing required option(s): #{missing.map { |key| "--#{key.to_s.tr('_', '-')}" }.join(', ')}" unless missing.empty?
120
+ end
121
+
122
+ def write_result(result, options)
123
+ if options[:output]
124
+ path = File.expand_path(options[:output])
125
+ raise ArgumentError, "refusing to overwrite #{path}; pass --force to replace it" if File.exist?(path) && !options[:force]
126
+ File.write(path, result)
127
+ else
128
+ print result
129
+ end
130
+ end
131
+
132
+ def help
133
+ puts <<~HELP
134
+ Usage: make-calendar COMMAND [options]
135
+
136
+ Commands:
137
+ month --year YEAR --month MONTH [--week-start sunday|monday] [--format json|csv|html|svg]
138
+ year --year YEAR [--format json|html|svg]
139
+ holidays --year YEAR [--country us] [--observed]
140
+ business-days --from DATE --to DATE [--observed]
141
+ add-business-days --date DATE --days N
142
+ fiscal --year YEAR [--pattern 445|454|544]
143
+ shifts --start DATE --to DATE --pattern 2-2-3
144
+ convert excel --serial VALUE [--system 1900|1904]
145
+ convert ordinal --serial YYYY-DDD
146
+ convert unix --serial VALUE [--system seconds|milliseconds|microseconds|nanoseconds]
147
+ export-ics --input events.json [--output FILE]
148
+
149
+ Output goes to stdout by default. --output refuses existing files unless --force is set.
150
+ Business-day ranges include both endpoints; adding days does not count the start date.
151
+ HELP
152
+ 0
153
+ end
154
+ end
155
+
156
+ exit MakeCalendarCLI.new.run(ARGV)
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "csv"
5
+ require "cgi"
6
+
7
+ module MakeACalendar
8
+ class Calendar
9
+ attr_reader :year, :month, :week_start, :grid, :holidays
10
+
11
+ def initialize(year:, month:, week_start: :sunday, fixed_rows: nil, holidays: [])
12
+ @year = Integer(year)
13
+ @month = Integer(month)
14
+ @week_start = week_start.to_sym
15
+ @grid = DateMath.month_grid(@year, @month, week_start: @week_start, fixed_rows: fixed_rows)
16
+ @holidays = holidays.freeze
17
+ freeze
18
+ end
19
+
20
+ def to_h
21
+ { year: year, month: month, week_start: week_start,
22
+ weekdays: weekday_names, weeks: grid.map { |row| row.map { |cell| cell[:date]&.iso8601 } } }
23
+ end
24
+
25
+ def to_json(*_args)
26
+ JSON.pretty_generate(to_h)
27
+ end
28
+
29
+ def to_csv
30
+ CSV.generate do |csv|
31
+ csv << weekday_names
32
+ grid.each { |row| csv << row.map { |cell| cell[:date]&.day } }
33
+ end
34
+ end
35
+
36
+ def to_html(title: nil, theme: :editorial, page_size: :letter)
37
+ title ||= Date::MONTHNAMES[month] + " #{year}"
38
+ cells = grid.flatten.map do |cell|
39
+ date = cell[:date]
40
+ label = date ? date.day.to_s : ""
41
+ holiday = date && holidays.find { |item| item.observed_date == date }
42
+ %(<td class="#{cell[:in_month] ? "in-month" : "adjacent"}">#{label}#{holiday ? "<small>#{escape(holiday.name)}</small>" : ""}</td>)
43
+ end.each_slice(7).map { |row| "<tr>#{row.join}</tr>" }.join
44
+ headers = weekday_names.map { |name| "<th scope=\"col\">#{name}</th>" }.join
45
+ %(<!doctype html><html lang="en"><meta charset="utf-8"><title>#{escape(title)}</title><style>#{html_css(theme, page_size)}</style><main><h1>#{escape(title)}</h1><table><thead><tr>#{headers}</tr></thead><tbody>#{cells}</tbody></table></main></html>)
46
+ end
47
+
48
+ def to_svg(title: nil, theme: :editorial, page_size: :letter)
49
+ title ||= Date::MONTHNAMES[month] + " #{year}"
50
+ width, height = page_dimensions(page_size)
51
+ pad = 40.0
52
+ header_h = 78.0
53
+ cell_w = (width - 2 * pad) / 7
54
+ cell_h = (height - 2 * pad - header_h) / (grid.length + 1)
55
+ colors = theme_colors(theme)
56
+ out = [%(<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc" viewBox="0 0 #{width} #{height}">),
57
+ %(<title id="title">#{escape(title)}</title><desc id="desc">Monthly calendar, #{escape(title)}</desc>),
58
+ %(<rect width="100%" height="100%" fill="#{colors[:background]}"/>),
59
+ %(<text x="#{pad}" y="#{pad + 32}" font-family="Arial,sans-serif" font-size="28" font-weight="700" fill="#{colors[:ink]}">#{escape(title)}</text>)]
60
+ weekday_names.each_with_index do |name, index|
61
+ x = pad + index * cell_w
62
+ y = pad + header_h
63
+ out << %(<rect x="#{x}" y="#{y}" width="#{cell_w}" height="#{cell_h}" fill="#{colors[:header]}" stroke="#{colors[:grid]}"/> )
64
+ out << %(<text x="#{x + cell_w / 2}" y="#{y + cell_h / 2 + 5}" text-anchor="middle" font-family="Arial,sans-serif" font-size="12" font-weight="700" fill="#{colors[:ink]}">#{name}</text>)
65
+ end
66
+ grid.each_with_index do |row, row_index|
67
+ row.each_with_index do |cell, col_index|
68
+ x = pad + col_index * cell_w
69
+ y = pad + header_h + (row_index + 1) * cell_h
70
+ date = cell[:date]
71
+ fill = cell[:in_month] ? colors[:cell] : colors[:muted]
72
+ out << %(<rect x="#{x}" y="#{y}" width="#{cell_w}" height="#{cell_h}" fill="#{fill}" stroke="#{colors[:grid]}"/> )
73
+ next unless date
74
+ out << %(<text x="#{x + 9}" y="#{y + 20}" font-family="Arial,sans-serif" font-size="13" fill="#{colors[:ink]}">#{date.day}</text>)
75
+ holiday = holidays.find { |item| item.observed_date == date }
76
+ out << %(<text x="#{x + 9}" y="#{y + 38}" font-family="Arial,sans-serif" font-size="8" fill="#{colors[:ink]}">#{escape(holiday.name[0, 18])}</text>) if holiday
77
+ end
78
+ end
79
+ out << "</svg>"
80
+ out.join("\n")
81
+ end
82
+
83
+ private
84
+
85
+ def weekday_names
86
+ names = DateMath::WEEKDAYS.map { |name| name.to_s[0, 2].capitalize }
87
+ offset = DateMath.weekday_index(week_start)
88
+ names.rotate(offset)
89
+ end
90
+
91
+ def escape(text)
92
+ CGI.escapeHTML(text.to_s)
93
+ end
94
+
95
+ def page_dimensions(size)
96
+ case size.to_sym
97
+ when :letter then [612, 792]
98
+ when :letter_landscape then [792, 612]
99
+ when :a4 then [595, 842]
100
+ when :a4_landscape then [842, 595]
101
+ else raise ArgumentError, "page_size must be :letter or :a4"
102
+ end
103
+ end
104
+
105
+ def theme_colors(theme)
106
+ themes = {
107
+ editorial: { background: "#ffffff", ink: "#20252b", grid: "#c7cdd2", header: "#edf1f4", cell: "#ffffff", muted: "#f7f8f9" },
108
+ business: { background: "#ffffff", ink: "#17324d", grid: "#aebdca", header: "#e5eef5", cell: "#ffffff", muted: "#f5f7f9" },
109
+ academic: { background: "#ffffff", ink: "#312d44", grid: "#cbc5dc", header: "#f0edf7", cell: "#ffffff", muted: "#faf9fc" },
110
+ ink_saver: { background: "#ffffff", ink: "#000000", grid: "#555555", header: "#ffffff", cell: "#ffffff", muted: "#f1f1f1" }
111
+ }
112
+ themes.fetch(theme.to_sym) { raise ArgumentError, "theme must be editorial, business, academic, or ink_saver" }
113
+ end
114
+
115
+ def html_css(theme, size)
116
+ colors = theme_colors(theme)
117
+ width, height = page_dimensions(size)
118
+ paper = size.to_s.start_with?("a4") ? "A4" : "Letter"
119
+ orientation = size.to_s.end_with?("landscape") ? " landscape" : ""
120
+ %(@page{size:#{paper}#{orientation};margin:12mm}body{font-family:Arial,sans-serif;color:#{colors[:ink]};max-width:#{width}px;margin:0 auto}table{border-collapse:collapse;width:100%;height:#{height - 130}px;table-layout:fixed}th,td{border:1px solid #{colors[:grid]};vertical-align:top;padding:6px}th{background:#{colors[:header]}}td{height:18%;font-size:14px}td.adjacent{color:#999;background:#{colors[:muted]}}small{display:block;font-size:9px;margin-top:5px}@media print{body{max-width:none}h1{font-size:18pt}})
121
+ end
122
+ end
123
+
124
+ class YearCalendar
125
+ attr_reader :year, :week_start, :holidays
126
+
127
+ def initialize(year:, week_start: :sunday, holidays: [])
128
+ @year = Integer(year)
129
+ @week_start = week_start.to_sym
130
+ @holidays = holidays.freeze
131
+ freeze
132
+ end
133
+
134
+ def months
135
+ (1..12).map { |month| Calendar.new(year: year, month: month, week_start: week_start, holidays: holidays) }.freeze
136
+ end
137
+
138
+ def to_json(*_args)
139
+ JSON.pretty_generate(year: year, months: months.map(&:to_h))
140
+ end
141
+
142
+ def to_html(title: "#{year} Calendar", theme: :editorial, page_size: :letter)
143
+ cards = months.map do |calendar|
144
+ html = calendar.to_html(theme: theme, page_size: page_size)
145
+ table = html[/<table>.*?<\/table>/m]
146
+ %(<section><h2>#{Date::MONTHNAMES[calendar.month]}</h2>#{table}</section>)
147
+ end
148
+ %(<html lang="en"><meta charset="utf-8"><title>#{CGI.escapeHTML(title)}</title><style>@page{size:#{page_size == :a4 ? "A4" : "Letter"};margin:8mm}body{font-family:Arial,sans-serif}h1{text-align:center}.year{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}section{break-inside:avoid}h2{font-size:12px;margin:4px 0}table{border-collapse:collapse;width:100%;table-layout:fixed;font-size:8px}td,th{border:1px solid #aaa;padding:2px;text-align:center}@media print{.year{gap:6px}}</style><h1>#{CGI.escapeHTML(title)}</h1><div class="year">#{cards.join}</div></html>)
149
+ end
150
+
151
+ def to_svg(title: "#{year} Calendar", theme: :editorial)
152
+ width = 900
153
+ height = 1160
154
+ margin = 24
155
+ card_w = (width - margin * 2) / 3.0
156
+ card_h = (height - 100) / 4.0
157
+ ink = theme.to_sym == :ink_saver ? "#000000" : "#25313b"
158
+ out = [%(<svg xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc" viewBox="0 0 #{width} #{height}">),
159
+ %(<title id="title">#{CGI.escapeHTML(title)}</title><desc id="desc">Twelve month calendar for #{year}</desc>),
160
+ %(<rect width="100%" height="100%" fill="#fff"/><text x="#{width / 2}" y="38" text-anchor="middle" font-family="Arial" font-size="26" font-weight="bold" fill="#{ink}">#{CGI.escapeHTML(title)}</text>)]
161
+ months.each_with_index do |calendar, index|
162
+ x0 = margin + (index % 3) * card_w
163
+ y0 = 60 + (index / 3) * card_h
164
+ out << %(<text x="#{x0 + card_w / 2}" y="#{y0 + 18}" text-anchor="middle" font-family="Arial" font-size="15" font-weight="bold" fill="#{ink}">#{Date::MONTHNAMES[index + 1]}</text>)
165
+ cell_w = (card_w - 20) / 7.0
166
+ cell_h = 19
167
+ names = calendar.send(:weekday_names)
168
+ names.each_with_index do |name, column|
169
+ x = x0 + 10 + column * cell_w
170
+ y = y0 + 27
171
+ out << %(<text x="#{x + cell_w / 2}" y="#{y + 12}" text-anchor="middle" font-family="Arial" font-size="8" font-weight="bold" fill="#{ink}">#{name}</text>)
172
+ end
173
+ calendar.grid.each_with_index do |row, row_index|
174
+ row.each_with_index do |cell, column|
175
+ x = x0 + 10 + column * cell_w
176
+ y = y0 + 42 + row_index * cell_h
177
+ fill = cell[:in_month] ? "#fff" : "#f4f5f6"
178
+ out << %(<rect x="#{x}" y="#{y}" width="#{cell_w}" height="#{cell_h}" fill="#{fill}" stroke="#c7cdd2"/>)
179
+ out << %(<text x="#{x + cell_w / 2}" y="#{y + 13}" text-anchor="middle" font-family="Arial" font-size="9" fill="#{ink}">#{cell[:in_month] ? cell[:day] : ""}</text>)
180
+ end
181
+ end
182
+ end
183
+ out << "</svg>"
184
+ out.join("\n")
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module MakeACalendar
7
+ module Converters
8
+ EXCEL_EPOCH_1900 = Date.new(1899, 12, 31).freeze
9
+ EXCEL_EPOCH_1904 = Date.new(1904, 1, 1).freeze
10
+ FILETIME_EPOCH = Time.utc(1601, 1, 1).freeze
11
+ module_function
12
+
13
+ def unix(value, unit: :seconds)
14
+ unit_divisors = { seconds: 1, milliseconds: 1_000, microseconds: 1_000_000, nanoseconds: 1_000_000_000 }
15
+ divisor = unit_divisors[unit.to_sym] or raise ArgumentError, "unsupported Unix timestamp unit"
16
+ seconds = Rational(value.to_s) / divisor
17
+ Time.at(seconds).utc
18
+ rescue ArgumentError, TypeError, ZeroDivisionError
19
+ raise ArgumentError, "invalid Unix timestamp or unit"
20
+ end
21
+
22
+ def to_unix(time, unit: :seconds)
23
+ time = parse_time(time)
24
+ factors = { seconds: 1, milliseconds: 1_000, microseconds: 1_000_000, nanoseconds: 1_000_000_000 }
25
+ factor = factors[unit.to_sym] or raise ArgumentError, "unsupported Unix timestamp unit"
26
+ (time.to_r * factor).to_i
27
+ end
28
+
29
+ def excel(serial, system: "1900")
30
+ value = Rational(serial.to_s)
31
+ days = value.floor
32
+ fraction = value - days
33
+ mode = system.to_s
34
+ case mode
35
+ when "1900"
36
+ raise ArgumentError, "Excel serial 60 is the fictitious 1900-02-29" if days == 60
37
+ date = EXCEL_EPOCH_1900 + days - (days > 60 ? 1 : 0)
38
+ when "1904"
39
+ date = EXCEL_EPOCH_1904 + days
40
+ else
41
+ raise ArgumentError, "system must be :1900 or :1904"
42
+ end
43
+ return date if fraction.zero?
44
+ seconds = (fraction * 86_400).round
45
+ Time.utc(date.year, date.month, date.day) + seconds
46
+ rescue TypeError, ArgumentError, ZeroDivisionError
47
+ raise unless $!.message.include?("fictitious") || $!.message.start_with?("system must")
48
+ raise
49
+ end
50
+
51
+ def filetime(ticks)
52
+ FILETIME_EPOCH + Rational(Integer(ticks), 10_000_000)
53
+ rescue ArgumentError, TypeError
54
+ raise ArgumentError, "FILETIME must be an integer count of 100-nanosecond intervals"
55
+ end
56
+
57
+ def to_filetime(time)
58
+ ((parse_time(time).to_r - FILETIME_EPOCH.to_r) * 10_000_000).to_i
59
+ end
60
+
61
+ def iso8601(value)
62
+ value.is_a?(DateTime) ? value.iso8601 : (value.is_a?(Date) ? value.iso8601 : parse_time(value).iso8601)
63
+ end
64
+
65
+ def parse_time(value)
66
+ return value.getutc if value.is_a?(Time)
67
+ return value.to_time.getutc if value.is_a?(DateTime)
68
+ text = value.to_s
69
+ raise ArgumentError unless text.match?(/(?:Z|[+-]\d{2}:?\d{2})\z/i)
70
+ Time.iso8601(text).getutc
71
+ rescue ArgumentError, TypeError
72
+ raise ArgumentError, "expected an ISO 8601 timestamp with an explicit offset"
73
+ end
74
+
75
+ def ordinal(value)
76
+ text = value.to_s
77
+ if text.match?(/\A\d{4}-\d{3}\z/)
78
+ year, day = text.split("-").map(&:to_i)
79
+ elsif text.match?(/\A\d{7}\z/)
80
+ year = text[0, 4].to_i
81
+ day = text[4, 3].to_i
82
+ else
83
+ raise ArgumentError, "ordinal date must use YYYY-DDD or YYYYDDD"
84
+ end
85
+ Date.ordinal(year, day)
86
+ rescue Date::Error
87
+ raise ArgumentError, "ordinal day is outside the year"
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module MakeACalendar
6
+ module DateMath
7
+ WEEKDAYS = %i[sunday monday tuesday wednesday thursday friday saturday].freeze
8
+ module_function
9
+
10
+ def date(value)
11
+ raise InvalidDate, "expected a date-only value, not a timestamp" if value.is_a?(DateTime)
12
+ if value.is_a?(Date)
13
+ result = value
14
+ else
15
+ text = value.to_s
16
+ raise InvalidDate, "expected an ISO YYYY-MM-DD date" unless text.match?(/\A\d{4}-\d{2}-\d{2}\z/)
17
+ result = Date.iso8601(text)
18
+ end
19
+ raise InvalidDate, "expected a Gregorian date" unless result.gregorian?
20
+ result
21
+ rescue ArgumentError, TypeError
22
+ raise InvalidDate, "invalid date: #{value.inspect}"
23
+ end
24
+
25
+ def month_start(year, month)
26
+ Date.new(Integer(year), Integer(month), 1)
27
+ rescue ArgumentError, TypeError
28
+ raise InvalidDate, "invalid year or month: #{year.inspect}-#{month.inspect}"
29
+ end
30
+
31
+ def month_end(year, month)
32
+ (month_start(year, month) >> 1) - 1
33
+ end
34
+
35
+ def quarter(date)
36
+ date = self.date(date)
37
+ first_month = ((date.month - 1) / 3) * 3 + 1
38
+ quarter_start = Date.new(date.year, first_month, 1)
39
+ [quarter_start, (quarter_start >> 3) - 1]
40
+ end
41
+
42
+ def month_grid(year, month, week_start: :sunday, fixed_rows: nil, undated: false)
43
+ first = month_start(year, month)
44
+ start_index = weekday_index(week_start)
45
+ offset = (first.wday - start_index) % 7
46
+ grid_start = first - offset
47
+ rows = fixed_rows ? Integer(fixed_rows) : ((offset + ((first >> 1) - 1).day + 6) / 7)
48
+ raise ArgumentError, "fixed_rows must be 4, 5, or 6" unless (4..6).cover?(rows)
49
+ (0...(rows * 7)).map do |index|
50
+ current = grid_start + index
51
+ in_month = current.month == first.month
52
+ { date: undated && !in_month ? nil : current, day: undated && !in_month ? nil : current.day,
53
+ in_month: in_month, weekday: WEEKDAYS[(start_index + index % 7) % 7],
54
+ week: current.cweek, week_year: current.cwyear }.freeze
55
+ end.each_slice(7).map(&:freeze).freeze
56
+ end
57
+
58
+ def weekday_index(value)
59
+ key = value.is_a?(Integer) ? value : WEEKDAYS.index(value.to_sym)
60
+ raise ArgumentError, "week_start must be a weekday name or 0..6" unless key && (0..6).cover?(key)
61
+ key
62
+ end
63
+
64
+ def each_date(from, to)
65
+ return enum_for(:each_date, from, to) unless block_given?
66
+ first = date(from)
67
+ last = date(to)
68
+ step = first <= last ? 1 : -1
69
+ current = first
70
+ while step == 1 ? current <= last : current >= last
71
+ yield current
72
+ current += step
73
+ end
74
+ end
75
+
76
+ def ordinal(date)
77
+ date = self.date(date)
78
+ [date.year, date.yday]
79
+ end
80
+
81
+ def iso_week(date)
82
+ date = self.date(date)
83
+ [date.cwyear, date.cweek, date.cwday]
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MakeACalendar
4
+ class Error < StandardError; end
5
+ class InvalidDate < Error; end
6
+ class UnsupportedConversion < Error; end
7
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module MakeACalendar
6
+ module ICS
7
+ module_function
8
+
9
+ def calendar(events, name: "Make a Calendar Toolkit")
10
+ raise ArgumentError, "at most 10,000 events may be exported at once" if events.length > 10_000
11
+ lines = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Make a Calendar Toolkit//EN", "CALSCALE:GREGORIAN", "X-WR-CALNAME:#{escape(name)}"]
12
+ events.each_with_index do |event, index|
13
+ date = DateMath.date(event.fetch(:date))
14
+ uid = event[:uid] || "#{date.strftime('%Y%m%d')}-#{index + 1}@make-a-calendar-toolkit"
15
+ summary = event.fetch(:summary)
16
+ lines.concat(["BEGIN:VEVENT", "UID:#{escape(uid)}", "DTSTAMP:#{Time.now.utc.strftime('%Y%m%dT%H%M%SZ')}",
17
+ "DTSTART;VALUE=DATE:#{date.strftime('%Y%m%d')}", "DTEND;VALUE=DATE:#{(date + 1).strftime('%Y%m%d')}",
18
+ "SUMMARY:#{escape(summary)}"])
19
+ lines << "DESCRIPTION:#{escape(event[:description])}" if event[:description]
20
+ lines << "LOCATION:#{escape(event[:location])}" if event[:location]
21
+ lines << "END:VEVENT"
22
+ end
23
+ lines << "END:VCALENDAR"
24
+ lines.map { |line| fold(line) }.join("\r\n") + "\r\n"
25
+ end
26
+
27
+ def holiday_calendar(year)
28
+ calendar(Holidays.us_federal(year).map { |holiday| { date: holiday.observed_date, summary: holiday.name } }, name: "US Federal Holidays #{year}")
29
+ end
30
+
31
+ def escape(value)
32
+ value.to_s.gsub(/\\/, "\\\\").gsub(/\r?\n/, "\\n").gsub(/;/, "\\;").gsub(/,/, "\\,")
33
+ end
34
+
35
+ def fold(line)
36
+ # RFC 5545 folds at 75 octets; UTF-8 code points are kept intact.
37
+ result = +""
38
+ count = 0
39
+ line.each_char do |character|
40
+ length = character.bytesize
41
+ if count + length > 75
42
+ result << "\r\n "
43
+ count = 1
44
+ end
45
+ result << character
46
+ count += length
47
+ end
48
+ result
49
+ end
50
+ private_class_method :fold
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module MakeACalendar
6
+ module BusinessDays
7
+ MAX_RANGE_DAYS = 36_600
8
+ module_function
9
+
10
+ def between(from, to, weekend: [0, 6], holidays: [], inclusive: true)
11
+ first = DateMath.date(from)
12
+ last = DateMath.date(to)
13
+ ensure_reasonable_range(first, last)
14
+ weekend_days = weekend.map { |day| DateMath.weekday_index(day) }
15
+ holiday_dates = holidays.map { |item| item.respond_to?(:observed_date) ? item.observed_date : DateMath.date(item) }
16
+ DateMath.each_date(first, last).count do |date|
17
+ included = inclusive || (date != first && date != last)
18
+ included && !weekend_days.include?(date.wday) && !holiday_dates.include?(date)
19
+ end
20
+ end
21
+
22
+ def each_workday(from, to, weekend: [0, 6], holidays: [])
23
+ return enum_for(:each_workday, from, to, weekend: weekend, holidays: holidays) unless block_given?
24
+ first = DateMath.date(from)
25
+ last = DateMath.date(to)
26
+ ensure_reasonable_range(first, last)
27
+ weekend_days = weekend.map { |day| DateMath.weekday_index(day) }
28
+ holiday_dates = holidays.map { |item| item.respond_to?(:observed_date) ? item.observed_date : DateMath.date(item) }
29
+ DateMath.each_date(first, last) do |date|
30
+ yield date unless weekend_days.include?(date.wday) || holiday_dates.include?(date)
31
+ end
32
+ end
33
+
34
+ def add(date, days, weekend: [0, 6], holidays: [])
35
+ current = DateMath.date(date)
36
+ count = Integer(days)
37
+ return current if count.zero?
38
+ step = count.positive? ? 1 : -1
39
+ remaining = count.abs
40
+ weekend_days = weekend.map { |day| DateMath.weekday_index(day) }
41
+ holiday_dates = holidays.map { |item| item.respond_to?(:observed_date) ? item.observed_date : DateMath.date(item) }
42
+ while remaining.positive?
43
+ current += step
44
+ next if weekend_days.include?(current.wday) || holiday_dates.include?(current)
45
+ remaining -= 1
46
+ end
47
+ current
48
+ end
49
+
50
+ def next(date, **options)
51
+ add(date, 1, **options)
52
+ end
53
+
54
+ def previous(date, **options)
55
+ add(date, -1, **options)
56
+ end
57
+
58
+ def in_month(year, month, **options)
59
+ first = Date.new(Integer(year), Integer(month), 1)
60
+ between(first, (first >> 1) - 1, **options)
61
+ end
62
+
63
+ def in_year(year, **options)
64
+ between(Date.new(Integer(year), 1, 1), Date.new(Integer(year), 12, 31), **options)
65
+ end
66
+
67
+ def ensure_reasonable_range(first, last)
68
+ raise ArgumentError, "business-day range is limited to 100 years" if (last - first).abs > MAX_RANGE_DAYS
69
+ end
70
+ private_class_method :ensure_reasonable_range
71
+ end
72
+ end