jw_calendar 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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Boundary
5
+ # Immutable collection of machine-readable boundary events.
6
+ class Report
7
+ include Enumerable
8
+ attr_reader :events
9
+
10
+ def initialize(events)
11
+ @events = events.map(&:freeze).freeze
12
+ freeze
13
+ end
14
+
15
+ def each(&)
16
+ events.each(&)
17
+ end
18
+
19
+ def to_a
20
+ events
21
+ end
22
+
23
+ def to_h
24
+ { events: }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Calendars
5
+ # Proleptic Gregorian arithmetic using an integer Julian Day Number (JDN).
6
+ # The supported civil year domain begins at 1 CE.
7
+ module Gregorian
8
+ module_function
9
+
10
+ MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
11
+
12
+ # Whether the Gregorian year has 366 days.
13
+ def leap_year?(year)
14
+ valid_year!(year)
15
+ (year % 4).zero? && (!(year % 100).zero? || (year % 400).zero?)
16
+ end
17
+
18
+ # Number of days in the requested Gregorian month.
19
+ def days_in_month(year, month)
20
+ valid_year!(year)
21
+ valid_month!(month)
22
+ return 29 if month == 2 && leap_year?(year)
23
+
24
+ MONTH_LENGTHS.fetch(month - 1)
25
+ end
26
+
27
+ # Number of days in a validated positive Gregorian year.
28
+ def days_in_year(year)
29
+ leap_year?(year) ? 366 : 365
30
+ end
31
+
32
+ # Whether the components form a valid Gregorian date.
33
+ def valid_date?(year, month, day)
34
+ return false unless year.is_a?(Integer) && year.positive?
35
+ return false unless month.is_a?(Integer) && month.between?(1, 12)
36
+ return false unless day.is_a?(Integer) && day.positive?
37
+
38
+ day <= days_in_month(year, month)
39
+ rescue ArgumentError
40
+ false
41
+ end
42
+
43
+ # Raise {InvalidDateError} unless the components form a valid date.
44
+ def validate_date!(year, month, day)
45
+ return true if valid_date?(year, month, day)
46
+
47
+ raise InvalidDateError, "invalid Gregorian date: #{format_components(year, month, day)}"
48
+ end
49
+
50
+ # Convert a Gregorian date to its integer JDN at astronomical noon.
51
+ # Convert Gregorian components to an integer JDN.
52
+ def to_jdn(year, month, day)
53
+ validate_date!(year, month, day)
54
+ a = Arithmetic::FloorDivision.div(14 - month, 12)
55
+ y = year + 4_800 - a
56
+ m = month + (12 * a) - 3
57
+ day + Arithmetic::FloorDivision.div((153 * m) + 2, 5) + (365 * y) +
58
+ Arithmetic::FloorDivision.div(y, 4) - Arithmetic::FloorDivision.div(y, 100) +
59
+ Arithmetic::FloorDivision.div(y, 400) - 32_045
60
+ end
61
+
62
+ # Convert an integer JDN to `[year, month, day]` Gregorian components.
63
+ def from_jdn(jdn)
64
+ integer_jdn!(jdn)
65
+ a = jdn + 32_044
66
+ b = Arithmetic::FloorDivision.div((4 * a) + 3, 146_097)
67
+ c = a - Arithmetic::FloorDivision.div(146_097 * b, 4)
68
+ d = Arithmetic::FloorDivision.div((4 * c) + 3, 1_461)
69
+ e = c - Arithmetic::FloorDivision.div(1_461 * d, 4)
70
+ m = Arithmetic::FloorDivision.div((5 * e) + 2, 153)
71
+ day = e - Arithmetic::FloorDivision.div((153 * m) + 2, 5) + 1
72
+ month = m + 3 - (12 * Arithmetic::FloorDivision.div(m, 10))
73
+ year = (100 * b) + d - 4_800 + Arithmetic::FloorDivision.div(m, 10)
74
+ raise InvalidDateError, "JDN is earlier than the supported Gregorian domain" unless year.positive?
75
+
76
+ [year, month, day]
77
+ end
78
+
79
+ # Return the one-based ordinal day for a valid Gregorian date.
80
+ def day_of_year(year, month, day)
81
+ validate_date!(year, month, day)
82
+ prior_month_days = (1...month).inject(0) { |total, m| total + days_in_month(year, m) }
83
+ prior_month_days + day
84
+ end
85
+
86
+ def valid_year!(year)
87
+ return if year.is_a?(Integer) && year.positive?
88
+
89
+ raise ArgumentError,
90
+ "year must be a positive Integer (1 CE or later)"
91
+ end
92
+ private_class_method :valid_year!
93
+
94
+ def valid_month!(month)
95
+ return if month.is_a?(Integer) && month.between?(1, 12)
96
+
97
+ raise ArgumentError, "month must be an Integer in 1..12"
98
+ end
99
+ private_class_method :valid_month!
100
+
101
+ def integer_jdn!(jdn)
102
+ return if jdn.is_a?(Integer)
103
+
104
+ raise ArgumentError, "JDN must be an Integer"
105
+ end
106
+ private_class_method :integer_jdn!
107
+
108
+ def format_components(year, month, day)
109
+ [year, month, day].map(&:to_s).join("-")
110
+ end
111
+ private_class_method :format_components
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Calendars
5
+ # Julian civil-calendar arithmetic. This is distinct from a Julian Day Number.
6
+ module Julian
7
+ module_function
8
+
9
+ MONTH_LENGTHS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
10
+
11
+ # Julian leap years occur every four years.
12
+ def leap_year?(year)
13
+ valid_year!(year)
14
+ (year % 4).zero?
15
+ end
16
+
17
+ # Number of days in the requested Julian month.
18
+ def days_in_month(year, month)
19
+ valid_year!(year)
20
+ valid_month!(month)
21
+ return 29 if month == 2 && leap_year?(year)
22
+
23
+ MONTH_LENGTHS.fetch(month - 1)
24
+ end
25
+
26
+ def days_in_year(year)
27
+ leap_year?(year) ? 366 : 365
28
+ end
29
+
30
+ # Whether the components form a valid Julian date.
31
+ def valid_date?(year, month, day)
32
+ return false unless year.is_a?(Integer) && year.positive?
33
+ return false unless month.is_a?(Integer) && month.between?(1, 12)
34
+ return false unless day.is_a?(Integer) && day.positive?
35
+
36
+ day <= days_in_month(year, month)
37
+ rescue ArgumentError
38
+ false
39
+ end
40
+
41
+ # Raise {InvalidDateError} unless the components form a valid date.
42
+ def validate_date!(year, month, day)
43
+ return true if valid_date?(year, month, day)
44
+
45
+ raise InvalidDateError, "invalid Julian calendar date: #{[year, month, day].join('-')}"
46
+ end
47
+
48
+ # Convert Julian components to an integer JDN.
49
+ def to_jdn(year, month, day)
50
+ validate_date!(year, month, day)
51
+ a = Arithmetic::FloorDivision.div(14 - month, 12)
52
+ y = year + 4_800 - a
53
+ m = month + (12 * a) - 3
54
+ day + Arithmetic::FloorDivision.div((153 * m) + 2, 5) + (365 * y) +
55
+ Arithmetic::FloorDivision.div(y, 4) - 32_083
56
+ end
57
+
58
+ # Convert an integer JDN to `[year, month, day]` Julian components.
59
+ def from_jdn(jdn)
60
+ raise ArgumentError, "JDN must be an Integer" unless jdn.is_a?(Integer)
61
+
62
+ c = jdn + 32_082
63
+ d = Arithmetic::FloorDivision.div((4 * c) + 3, 1_461)
64
+ e = c - Arithmetic::FloorDivision.div(1_461 * d, 4)
65
+ m = Arithmetic::FloorDivision.div((5 * e) + 2, 153)
66
+ day = e - Arithmetic::FloorDivision.div((153 * m) + 2, 5) + 1
67
+ month = m + 3 - (12 * Arithmetic::FloorDivision.div(m, 10))
68
+ year = d - 4_800 + Arithmetic::FloorDivision.div(m, 10)
69
+ raise InvalidDateError, "JDN is earlier than the supported Julian domain" unless year.positive?
70
+
71
+ [year, month, day]
72
+ end
73
+
74
+ def valid_year!(year)
75
+ return if year.is_a?(Integer) && year.positive?
76
+
77
+ raise ArgumentError,
78
+ "year must be a positive Integer (1 CE or later)"
79
+ end
80
+ private_class_method :valid_year!
81
+
82
+ def valid_month!(month)
83
+ return if month.is_a?(Integer) && month.between?(1, 12)
84
+
85
+ raise ArgumentError, "month must be an Integer in 1..12"
86
+ end
87
+ private_class_method :valid_month!
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Calendars
5
+ # A local civil calendar switching from Julian to Gregorian labels on a
6
+ # caller-supplied pair of consecutive absolute days.
7
+ class ReformCalendar
8
+ attr_reader :last_julian_date, :first_gregorian_date
9
+
10
+ def self.papal
11
+ new(last_julian_date: CivilDate.julian(1582, 10, 4),
12
+ first_gregorian_date: CivilDate.gregorian(1582, 10, 15))
13
+ end
14
+
15
+ def self.british_empire
16
+ new(last_julian_date: CivilDate.julian(1752, 9, 2),
17
+ first_gregorian_date: CivilDate.gregorian(1752, 9, 14))
18
+ end
19
+
20
+ def initialize(last_julian_date:, first_gregorian_date:)
21
+ @last_julian_date = coerce_date(last_julian_date, :julian)
22
+ @first_gregorian_date = coerce_date(first_gregorian_date, :gregorian)
23
+ unless (label(@last_julian_date) <=> label(@first_gregorian_date)) == -1
24
+ raise ArgumentError, "last Julian label must precede first Gregorian label"
25
+ end
26
+ unless @first_gregorian_date.to_jdn == @last_julian_date.to_jdn + 1
27
+ raise ArgumentError, "cutover dates must be consecutive absolute days"
28
+ end
29
+
30
+ freeze
31
+ end
32
+
33
+ def valid_date?(year, month, day)
34
+ return false unless [year, month, day].all? { |part| part.is_a?(Integer) }
35
+
36
+ calendar_for_label(year, month, day)
37
+ true
38
+ rescue InvalidDateError, ReformGapError, ArgumentError
39
+ false
40
+ end
41
+
42
+ def date(year, month, day)
43
+ calendar = calendar_for_label(year, month, day)
44
+ CivilDate.new(year, month, day, calendar:)
45
+ end
46
+
47
+ def to_jdn(year, month, day)
48
+ date(year, month, day).to_jdn
49
+ end
50
+
51
+ def from_jdn(jdn)
52
+ raise ArgumentError, "JDN must be an Integer" unless jdn.is_a?(Integer)
53
+
54
+ return CivilDate.from_jdn(jdn, calendar: :julian) if jdn <= last_julian_date.to_jdn
55
+
56
+ CivilDate.from_jdn(jdn, calendar: :gregorian)
57
+ end
58
+
59
+ def skipped_labels
60
+ start_label = last_julian_date
61
+ end_label = first_gregorian_date
62
+ labels = []
63
+ cursor = start_label
64
+ loop do
65
+ cursor = cursor.next_day
66
+ break if (label(cursor) <=> label(end_label)) >= 0
67
+
68
+ labels << cursor.to_s
69
+ end
70
+ labels.freeze
71
+ end
72
+
73
+ private
74
+
75
+ def coerce_date(value, calendar)
76
+ return value if value.is_a?(CivilDate) && value.calendar == calendar
77
+ return CivilDate.new(*value, calendar:) if value.is_a?(Array) && value.length == 3
78
+
79
+ raise ArgumentError, "cutover date must be a #{calendar} CivilDate or [year, month, day]"
80
+ end
81
+
82
+ def calendar_for_label(year, month, day)
83
+ unless [year, month, day].all? { |part| part.is_a?(Integer) }
84
+ raise InvalidDateError, "year, month, and day must be integers"
85
+ end
86
+
87
+ tuple = [year, month, day]
88
+ if (tuple <=> label(last_julian_date)) <= 0
89
+ Julian.validate_date!(year, month, day)
90
+ :julian
91
+ elsif (tuple <=> label(first_gregorian_date)) >= 0
92
+ Gregorian.validate_date!(year, month, day)
93
+ :gregorian
94
+ else
95
+ raise ReformGapError, "#{year}-#{month}-#{day} was skipped by this calendar reform"
96
+ end
97
+ end
98
+
99
+ def label(date)
100
+ [date.year, date.month, date.day]
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ # Immutable civil date. It is a calendar label, not an instant or a UTC midnight.
5
+ # Years start at 1 CE; supported calendars are proleptic Gregorian and Julian.
6
+ class CivilDate
7
+ include Comparable
8
+
9
+ CALENDARS = %i[gregorian julian].freeze
10
+ attr_reader :year, :month, :day, :calendar
11
+
12
+ # Build a validated date label.
13
+ # @param year [Integer] positive civil year
14
+ # @param month [Integer] month from 1 to 12
15
+ # @param day [Integer] valid day in the selected month
16
+ # @param calendar [Symbol] `:gregorian` or `:julian`
17
+ def initialize(year, month, day, calendar: :gregorian)
18
+ raise InvalidCalendarError, "calendar must be :gregorian or :julian" unless CALENDARS.include?(calendar)
19
+
20
+ validator = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
21
+ validator.validate_date!(year, month, day)
22
+ @year = year
23
+ @month = month
24
+ @day = day
25
+ @calendar = calendar
26
+ freeze
27
+ end
28
+
29
+ # Create a proleptic Gregorian date.
30
+ def self.gregorian(year, month, day)
31
+ new(year, month, day, calendar: :gregorian)
32
+ end
33
+
34
+ # Create a proleptic Julian date.
35
+ def self.julian(year, month, day)
36
+ new(year, month, day, calendar: :julian)
37
+ end
38
+
39
+ # Parse an extended `YYYY-MM-DD` civil date.
40
+ def self.parse(value, calendar: :gregorian)
41
+ match = /\A(\d{4,})-(\d{2})-(\d{2})\z/.match(value.to_s)
42
+ raise InvalidDateError, "expected YYYY-MM-DD, got #{value.inspect}" unless match
43
+
44
+ new(match[1].to_i, match[2].to_i, match[3].to_i, calendar:)
45
+ end
46
+
47
+ # Compare by absolute day, independent of the calendar label.
48
+ def <=>(other)
49
+ return nil unless other.is_a?(CivilDate)
50
+
51
+ to_jdn <=> other.to_jdn
52
+ end
53
+
54
+ # Equality includes the year, month, day, and calendar system.
55
+ def ==(other)
56
+ other.is_a?(CivilDate) && [year, month, day, calendar] == [other.year, other.month, other.day, other.calendar]
57
+ end
58
+ alias eql? ==
59
+
60
+ def hash
61
+ [year, month, day, calendar].hash
62
+ end
63
+
64
+ # Return this date's integer Julian Day Number.
65
+ def to_jdn
66
+ converter = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
67
+ converter.to_jdn(year, month, day)
68
+ end
69
+
70
+ # Return ISO weekday number, Monday=1 through Sunday=7.
71
+ def weekday
72
+ (to_jdn % 7) + 1 # ISO weekday: Monday=1 … Sunday=7.
73
+ end
74
+
75
+ def weekday_name
76
+ ISO::WeekDate::WEEKDAY_NAMES.fetch(weekday - 1)
77
+ end
78
+
79
+ # Return the one-based day of year in this date's calendar.
80
+ def ordinal_day
81
+ first_day = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
82
+ to_jdn - first_day.to_jdn(year, 1, 1) + 1
83
+ end
84
+
85
+ # Return a new date a fixed number of civil days from this date.
86
+ def add_days(amount)
87
+ raise ArgumentError, "amount must be an Integer" unless amount.is_a?(Integer)
88
+
89
+ self.class.from_jdn(to_jdn + amount, calendar:)
90
+ end
91
+
92
+ def subtract_days(amount)
93
+ add_days(-amount)
94
+ end
95
+
96
+ def next_day
97
+ add_days(1)
98
+ end
99
+
100
+ def previous_day
101
+ add_days(-1)
102
+ end
103
+
104
+ def days_until(other)
105
+ raise ArgumentError, "other must be a CivilDate" unless other.is_a?(CivilDate)
106
+
107
+ other.to_jdn - to_jdn
108
+ end
109
+
110
+ # Return the ISO week date for this absolute day.
111
+ def iso_week
112
+ ISO::WeekDate.for(self)
113
+ end
114
+
115
+ def to_s
116
+ format("%<year>04d-%<month>02d-%<day>02d", year:, month:, day:)
117
+ end
118
+
119
+ def to_iso8601
120
+ to_s
121
+ end
122
+
123
+ def inspect
124
+ "#<#{self.class} #{self} calendar=#{calendar}>"
125
+ end
126
+
127
+ # Convert an integer JDN to a date label in the selected calendar.
128
+ def self.from_jdn(jdn, calendar: :gregorian)
129
+ raise InvalidCalendarError, "calendar must be :gregorian or :julian" unless CALENDARS.include?(calendar)
130
+
131
+ converter = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
132
+ new(*converter.from_jdn(jdn), calendar:)
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module JWCalendar
7
+ module CLI
8
+ # Standard-library command-line interface used by the jwcalendar executable.
9
+ class Runner
10
+ def initialize(stdout: $stdout, stderr: $stderr)
11
+ @stdout = stdout
12
+ @stderr = stderr
13
+ end
14
+
15
+ def run(argv)
16
+ args = argv.dup
17
+ command = args.shift
18
+ return help if command.nil? || %w[-h --help help].include?(command)
19
+ return version if %w[-v --version version].include?(command)
20
+
21
+ case command
22
+ when "inspect" then inspect_date(args)
23
+ when "iso-week" then iso_week(args)
24
+ when "jdn" then jdn(args)
25
+ when "convert" then convert(args)
26
+ when "grid" then grid(args)
27
+ when "boundary" then boundary(args)
28
+ else
29
+ raise ArgumentError, "unknown command #{command.inspect}; run jwcalendar --help"
30
+ end
31
+ rescue OptionParser::ParseError, ArgumentError, Error => e
32
+ @stderr.puts("jwcalendar: #{e.message}")
33
+ 2
34
+ end
35
+
36
+ private
37
+
38
+ def inspect_date(args)
39
+ options = { json: false, calendar: :gregorian }
40
+ parser = OptionParser.new do |opts|
41
+ opts.on("--json") { options[:json] = true }
42
+ opts.on("--calendar NAME", %w[gregorian julian]) { |value| options[:calendar] = value.to_sym }
43
+ end
44
+ parser.parse!(args)
45
+ date = parse_single_date(args, options[:calendar])
46
+ iso = date.iso_week
47
+ output = {
48
+ date: date.to_s, calendar: date.calendar, weekday: date.weekday_name,
49
+ weekday_number: date.weekday, ordinal_day: date.ordinal_day,
50
+ jdn: date.to_jdn, iso_week_year: iso.week_year,
51
+ iso_week: iso.week, iso_weekday: iso.weekday
52
+ }
53
+ if options[:json]
54
+ @stdout.puts(JSON.pretty_generate(output))
55
+ else
56
+ output.each { |key, value| @stdout.puts("#{key.to_s.tr('_', ' ')}: #{value}") }
57
+ end
58
+ 0
59
+ end
60
+
61
+ def iso_week(args)
62
+ json = false
63
+ calendar = :gregorian
64
+ parser = OptionParser.new do |opts|
65
+ opts.on("--json") { json = true }
66
+ opts.on("--calendar NAME", %w[gregorian julian]) { |value| calendar = value.to_sym }
67
+ end
68
+ parser.parse!(args)
69
+ date = parse_single_date(args, calendar)
70
+ week = date.iso_week
71
+ output = { date: date.to_s, week_year: week.week_year, week: week.week,
72
+ weekday: week.weekday, weekday_name: week.weekday_name,
73
+ iso_date: week.to_s }
74
+ @stdout.puts(json ? JSON.pretty_generate(output) : "#{week} (#{week.weekday_name})")
75
+ 0
76
+ end
77
+
78
+ def jdn(args)
79
+ json = false
80
+ calendar = :gregorian
81
+ parser = OptionParser.new do |opts|
82
+ opts.on("--json") { json = true }
83
+ opts.on("--calendar NAME", %w[gregorian julian]) { |value| calendar = value.to_sym }
84
+ end
85
+ parser.parse!(args)
86
+ date = parse_single_date(args, calendar)
87
+ result = { date: date.to_s, calendar:, jdn: date.to_jdn,
88
+ jd_at_midnight: Conversion::JulianDayNumber.jd(date).to_s,
89
+ mjd_at_midnight: Conversion::JulianDayNumber.mjd(date).to_s }
90
+ @stdout.puts(json ? JSON.pretty_generate(result) : result.map { |key, value| "#{key}: #{value}" }.join("\n"))
91
+ 0
92
+ end
93
+
94
+ def convert(args)
95
+ options = { from: :gregorian, to: :julian, json: false }
96
+ parser = OptionParser.new do |opts|
97
+ opts.on("--from NAME", %w[gregorian julian]) { |value| options[:from] = value.to_sym }
98
+ opts.on("--to NAME", %w[gregorian julian]) { |value| options[:to] = value.to_sym }
99
+ opts.on("--json") { options[:json] = true }
100
+ end
101
+ parser.parse!(args)
102
+ date = parse_single_date(args, options[:from])
103
+ converted = Conversion::CalendarConverter.convert(date, to: options[:to])
104
+ result = { input: date.to_s, from: options[:from], result: converted.to_s, to: options[:to], jdn: date.to_jdn }
105
+ human_result = "#{date} (#{options[:from]}) = #{converted} (#{options[:to]})"
106
+ @stdout.puts(options[:json] ? JSON.pretty_generate(result) : human_result)
107
+ 0
108
+ end
109
+
110
+ def grid(args)
111
+ options = { week_start: :monday, fixed_weeks: nil, json: false, adjacent: true }
112
+ parser = OptionParser.new do |opts|
113
+ opts.on("--week-start DAY", %w[monday sunday]) { |value| options[:week_start] = value.to_sym }
114
+ opts.on("--fixed-weeks N", Integer) { |value| options[:fixed_weeks] = value }
115
+ opts.on("--no-adjacent") { options[:adjacent] = false }
116
+ opts.on("--json") { options[:json] = true }
117
+ end
118
+ parser.parse!(args)
119
+ unless args.length == 1 && (match = /\A(\d{4,})-(\d{2})\z/.match(args.first))
120
+ raise ArgumentError,
121
+ "expected one YYYY-MM month"
122
+ end
123
+
124
+ layout = Grid::MonthGrid.new(year: match[1].to_i, month: match[2].to_i,
125
+ week_start: options[:week_start], fixed_weeks: options[:fixed_weeks],
126
+ include_adjacent: options[:adjacent])
127
+ @stdout.puts(options[:json] ? JSON.pretty_generate(layout.to_h) : render_grid(layout))
128
+ 0
129
+ end
130
+
131
+ def boundary(args)
132
+ json = false
133
+ parser = OptionParser.new { |opts| opts.on("--json") { json = true } }
134
+ parser.parse!(args)
135
+ raise ArgumentError, "expected one year" unless args.length == 1 && /\A\d+\z/.match?(args.first)
136
+
137
+ report = Boundary::Analyzer.year(args.first.to_i)
138
+ @stdout.puts(json ? JSON.pretty_generate(report.to_h) : report.map do |event|
139
+ "#{event[:date]} #{event[:type]}"
140
+ end.join("\n"))
141
+ 0
142
+ end
143
+
144
+ def render_grid(layout)
145
+ title = format("%<year>04d-%<month>02d", year: layout.year, month: layout.month)
146
+ lines = ["#{title} (week starts #{ISO::WeekDate::WEEKDAY_NAMES[layout.week_start - 1]})",
147
+ layout.day_names.map { |name| name[0, 3].rjust(4) }.join]
148
+ layout.rows.each do |row|
149
+ lines << row.map do |cell|
150
+ if cell.date.nil?
151
+ " "
152
+ elsif cell.in_current_month?
153
+ cell.date.day.to_s.rjust(4)
154
+ else
155
+ "(#{cell.date.day})".rjust(4)
156
+ end
157
+ end.join
158
+ end
159
+ lines.join("\n")
160
+ end
161
+
162
+ def parse_single_date(args, calendar)
163
+ raise ArgumentError, "expected one YYYY-MM-DD date" unless args.length == 1
164
+
165
+ CivilDate.parse(args.first, calendar:)
166
+ end
167
+
168
+ def version
169
+ @stdout.puts("jwcalendar #{VERSION}")
170
+ 0
171
+ end
172
+
173
+ def help
174
+ @stdout.puts <<~TEXT
175
+ JW Calendar #{VERSION} — deterministic civil-date computation
176
+
177
+ Usage: jwcalendar COMMAND [OPTIONS]
178
+
179
+ Commands:
180
+ inspect DATE Show date, weekday, ordinal day and ISO week
181
+ iso-week DATE Show the ISO week date
182
+ jdn DATE Show JDN, exact JD and MJD at midnight
183
+ convert DATE Convert between Gregorian and Julian labels
184
+ grid YYYY-MM Print a structured month grid
185
+ boundary YEAR List calendar boundary cases
186
+
187
+ Common options:
188
+ --json Emit machine-readable JSON where supported
189
+ --calendar NAME Select gregorian or julian for date commands
190
+ --help, --version Show help or version
191
+ TEXT
192
+ 0
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Conversion
5
+ # Converts labels between the proleptic Gregorian and Julian calendars.
6
+ module CalendarConverter
7
+ module_function
8
+
9
+ # Convert a date label while preserving its absolute JDN.
10
+ def convert(date, to:)
11
+ raise ArgumentError, "date must be a CivilDate" unless date.is_a?(CivilDate)
12
+
13
+ unless CivilDate::CALENDARS.include?(to)
14
+ raise InvalidCalendarError,
15
+ "target calendar must be :gregorian or :julian"
16
+ end
17
+
18
+ CivilDate.from_jdn(date.to_jdn, calendar: to)
19
+ end
20
+ end
21
+ end
22
+ end