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
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,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
|