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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Conversion
5
+ # Integer JDN and exact JD/MJD conversions. A civil date starts at midnight;
6
+ # its JDN is the integer labelled at the following astronomical noon.
7
+ module JulianDayNumber
8
+ module_function
9
+
10
+ HALF = Rational(1, 2)
11
+ MJD_OFFSET = Rational(2_400_001, 1)
12
+
13
+ # Return the date's integer JDN.
14
+ def for(date)
15
+ date.to_jdn
16
+ end
17
+
18
+ # Convert an integer JDN into a civil date.
19
+ def to_date(jdn, calendar: :gregorian)
20
+ CivilDate.from_jdn(jdn, calendar:)
21
+ end
22
+
23
+ # Return exact Julian Date for an explicitly supplied fraction of a day.
24
+ # Return JD at midnight plus an optional exact fraction of a day.
25
+ def jd(date, fraction: 0)
26
+ fraction = rational_fraction!(fraction)
27
+ raise ArgumentError, "fraction must be in 0...1" unless fraction >= 0 && fraction < 1
28
+
29
+ Rational(date.to_jdn, 1) - HALF + fraction
30
+ end
31
+
32
+ # Convert exact JD to [civil_date, fraction_of_day] at midnight boundaries.
33
+ # Split a JD into a civil date and exact fraction since midnight.
34
+ def from_jd(value, calendar: :gregorian)
35
+ jd_value = rational_number!(value)
36
+ shifted = jd_value + HALF
37
+ jdn = shifted.floor
38
+ [to_date(jdn, calendar:), shifted - jdn]
39
+ end
40
+
41
+ # Return MJD at midnight plus an optional exact fraction of a day.
42
+ def mjd(date, fraction: 0)
43
+ fraction = rational_fraction!(fraction)
44
+ raise ArgumentError, "fraction must be in 0...1" unless fraction >= 0 && fraction < 1
45
+
46
+ Rational(date.to_jdn, 1) - MJD_OFFSET + fraction
47
+ end
48
+
49
+ # Split an MJD into a civil date and exact fraction since midnight.
50
+ def from_mjd(value, calendar: :gregorian)
51
+ mjd_value = rational_number!(value)
52
+ day_offset = mjd_value.floor
53
+ fraction = mjd_value - day_offset
54
+ # MJD 0 is Gregorian 1858-11-17 at midnight (JDN 2,400,001).
55
+ [to_date(day_offset + 2_400_001, calendar:), fraction]
56
+ end
57
+
58
+ def rational_fraction!(value)
59
+ raise ArgumentError, "fraction must be Numeric" unless value.is_a?(Numeric)
60
+
61
+ value.to_r
62
+ end
63
+ private_class_method :rational_fraction!
64
+
65
+ def rational_number!(value)
66
+ raise ArgumentError, "Julian date value must be Numeric" unless value.is_a?(Numeric)
67
+
68
+ value.to_r
69
+ rescue RangeError
70
+ raise ArgumentError, "Julian date value must be finite"
71
+ end
72
+ private_class_method :rational_number!
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Conversion
5
+ # Immutable year/day-of-year value, using the selected civil calendar.
6
+ class OrdinalDate
7
+ attr_reader :year, :day, :calendar
8
+
9
+ # Create a validated year and ordinal day.
10
+ def initialize(year, day, calendar: :gregorian)
11
+ unless CivilDate::CALENDARS.include?(calendar)
12
+ raise InvalidCalendarError, "calendar must be :gregorian or :julian"
13
+ end
14
+
15
+ engine = calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
16
+ engine.days_in_year(year)
17
+ unless day.is_a?(Integer) && day.between?(1, engine.days_in_year(year))
18
+ raise InvalidDateError, "ordinal day must be in 1..#{engine.days_in_year(year)} for #{year}"
19
+ end
20
+
21
+ @year = year
22
+ @day = day
23
+ @calendar = calendar
24
+ freeze
25
+ end
26
+
27
+ # Convert a CivilDate to its ordinal form.
28
+ def self.for(date)
29
+ new(date.year, date.ordinal_day, calendar: date.calendar)
30
+ end
31
+
32
+ # Convert the ordinal value to its calendar date.
33
+ def to_date
34
+ jan1 = CivilDate.new(year, 1, 1, calendar:)
35
+ jan1.add_days(day - 1)
36
+ end
37
+
38
+ def to_s
39
+ format("%<year>04d-%<day>03d", year:, day:)
40
+ end
41
+
42
+ def ==(other)
43
+ other.is_a?(OrdinalDate) && [year, day, calendar] == [other.year, other.day, other.calendar]
44
+ end
45
+ alias eql? ==
46
+
47
+ def hash
48
+ [year, day, calendar].hash
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ class Error < StandardError; end
5
+ class InvalidDateError < Error; end
6
+ class InvalidCalendarError < Error; end
7
+ class InvalidISOWeekError < Error; end
8
+ class ReformGapError < Error; end
9
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Formatting
5
+ # Strict parsing/formatting helpers for supported extended ISO civil dates.
6
+ module ISO8601
7
+ module_function
8
+
9
+ def parse_date(value, calendar: :gregorian)
10
+ CivilDate.parse(value, calendar:)
11
+ end
12
+
13
+ def format_date(date)
14
+ raise ArgumentError, "date must be a CivilDate" unless date.is_a?(CivilDate)
15
+
16
+ date.to_s
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Grid
5
+ # Immutable cell in a month grid. A nil date represents a blank spillover.
6
+ class Cell
7
+ attr_reader :date, :week_index, :column_index
8
+
9
+ def initialize(date:, in_current_month:, week_index:, column_index:)
10
+ @date = date
11
+ @in_current_month = in_current_month
12
+ @week_index = week_index
13
+ @column_index = column_index
14
+ freeze
15
+ end
16
+
17
+ def in_current_month?
18
+ @in_current_month
19
+ end
20
+
21
+ def weekday
22
+ date&.weekday
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Grid
5
+ # Deterministic structured month layout. Weeks may be natural length (4–6)
6
+ # or fixed to six rows for print and stable UI layouts.
7
+ class MonthGrid
8
+ WEEKDAY_STARTS = { monday: 1, sunday: 7 }.freeze
9
+ attr_reader :year, :month, :week_start, :fixed_weeks, :calendar
10
+
11
+ # Build a month grid without producing any HTML or locale-dependent output.
12
+ def initialize(year:, month:, week_start: :monday, fixed_weeks: nil,
13
+ include_adjacent: true, calendar: :gregorian)
14
+ engine = calendar_engine(calendar)
15
+ engine.days_in_month(year, month)
16
+ week_start = normalize_week_start(week_start)
17
+ validate_fixed_weeks!(fixed_weeks)
18
+
19
+ @year = year
20
+ @month = month
21
+ @week_start = week_start
22
+ @fixed_weeks = fixed_weeks
23
+ @calendar = calendar
24
+ @include_adjacent = include_adjacent
25
+ first = CivilDate.new(year, month, 1, calendar:)
26
+ leading, weeks = layout_dimensions(first, engine, week_start, fixed_weeks)
27
+ @rows = build_rows(first, leading, weeks)
28
+ freeze
29
+ end
30
+
31
+ # Return seven-cell rows.
32
+ attr_reader :rows
33
+
34
+ def weeks
35
+ rows.length
36
+ end
37
+
38
+ def cells
39
+ rows.flatten.freeze
40
+ end
41
+
42
+ # Return column headings in display order.
43
+ def day_names
44
+ names = ISO::WeekDate::WEEKDAY_NAMES
45
+ Array.new(7) { |index| names[(week_start - 1 + index) % 7] }.freeze
46
+ end
47
+
48
+ def to_a
49
+ rows.map { |row| row.map { |cell| cell.date&.day } }.freeze
50
+ end
51
+
52
+ # Return a JSON-friendly hash of grid semantics.
53
+ def to_h
54
+ {
55
+ year:, month:, calendar:, week_start:,
56
+ fixed_weeks:, weekdays: day_names,
57
+ rows: rows.map do |row|
58
+ row.map do |cell|
59
+ { date: cell.date&.to_s, day: cell.date&.day,
60
+ in_current_month: cell.in_current_month?, weekday: cell.weekday,
61
+ week_index: cell.week_index, column_index: cell.column_index }
62
+ end
63
+ end
64
+ }
65
+ end
66
+
67
+ private
68
+
69
+ def calendar_engine(calendar)
70
+ return Calendars::Gregorian if calendar == :gregorian
71
+ return Calendars::Julian if calendar == :julian
72
+
73
+ raise InvalidCalendarError, "calendar must be :gregorian or :julian"
74
+ end
75
+
76
+ def normalize_week_start(week_start)
77
+ return week_start if week_start.is_a?(Integer) && week_start.between?(1, 7)
78
+
79
+ normalized = WEEKDAY_STARTS[week_start]
80
+ raise ArgumentError, "week_start must be :monday, :sunday, or ISO weekday 1..7" unless normalized
81
+
82
+ normalized
83
+ end
84
+
85
+ def validate_fixed_weeks!(fixed_weeks)
86
+ return if fixed_weeks.nil? || [4, 5, 6].include?(fixed_weeks)
87
+
88
+ raise ArgumentError, "fixed_weeks must be nil, 4, 5, or 6"
89
+ end
90
+
91
+ def layout_dimensions(first, engine, week_start, fixed_weeks)
92
+ leading = (first.weekday - week_start) % 7
93
+ natural_weeks = (leading + engine.days_in_month(year, month) + 6) / 7
94
+ weeks = fixed_weeks || natural_weeks
95
+ if weeks < natural_weeks
96
+ raise ArgumentError, "#{weeks} rows cannot contain all dates for #{year}-#{format('%02d', month)}"
97
+ end
98
+
99
+ [leading, weeks]
100
+ end
101
+
102
+ def build_rows(first, leading, weeks)
103
+ Array.new(weeks) do |row|
104
+ Array.new(7) do |column|
105
+ actual = first.add_days((row * 7) + column - leading)
106
+ in_month = actual.year == year && actual.month == month && actual.calendar == calendar
107
+ date = in_month || @include_adjacent ? actual : nil
108
+ Cell.new(date:, in_current_month: in_month, week_index: row, column_index: column)
109
+ end.freeze
110
+ end.freeze
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module ISO
5
+ # ISO week date value with Monday=1 through Sunday=7.
6
+ class WeekDate
7
+ WEEKDAY_NAMES = %w[Monday Tuesday Wednesday Thursday Friday Saturday Sunday].freeze
8
+ attr_reader :week_year, :week, :weekday
9
+
10
+ # Create a valid ISO week-year, week, and weekday tuple.
11
+ def initialize(week_year, week, weekday)
12
+ unless week_year.is_a?(Integer) && week_year.positive?
13
+ raise InvalidISOWeekError, "ISO week-year must be a positive Integer"
14
+ end
15
+ raise InvalidISOWeekError, "ISO weekday must be in 1..7" unless weekday.is_a?(Integer) && weekday.between?(1, 7)
16
+
17
+ weeks = self.class.weeks_in_year(week_year)
18
+ unless week.is_a?(Integer) && week.between?(1, weeks)
19
+ raise InvalidISOWeekError, "ISO week must be in 1..#{weeks} for #{week_year}"
20
+ end
21
+
22
+ @week_year = week_year
23
+ @week = week
24
+ @weekday = weekday
25
+ freeze
26
+ end
27
+
28
+ # Compute the ISO week date for an absolute civil date.
29
+ def self.for(date)
30
+ raise ArgumentError, "date must be a CivilDate" unless date.is_a?(CivilDate)
31
+
32
+ weekday = date.weekday
33
+ # The Thursday determines the ISO week-year, even for Julian labels.
34
+ thursday = CivilDate.from_jdn(date.to_jdn + 4 - weekday)
35
+ week_year = thursday.year
36
+ jan4 = Calendars::Gregorian.to_jdn(week_year, 1, 4)
37
+ week1_monday = jan4 - (jan4 % 7)
38
+ week = Arithmetic::FloorDivision.div(date.to_jdn - week1_monday, 7) + 1
39
+ new(week_year, week, weekday)
40
+ end
41
+
42
+ # Return 52 or 53 according to the ISO week-year rule.
43
+ def self.weeks_in_year(year)
44
+ unless year.is_a?(Integer) && year.positive?
45
+ raise InvalidISOWeekError,
46
+ "ISO week-year must be a positive Integer"
47
+ end
48
+
49
+ jan1_weekday = (Calendars::Gregorian.to_jdn(year, 1, 1) % 7) + 1
50
+ return 53 if jan1_weekday == 4 || (jan1_weekday == 3 && Calendars::Gregorian.leap_year?(year))
51
+
52
+ 52
53
+ end
54
+
55
+ # Convert the ISO tuple to a proleptic Gregorian CivilDate.
56
+ def to_date
57
+ jan4 = Calendars::Gregorian.to_jdn(week_year, 1, 4)
58
+ week1_monday = jan4 - (jan4 % 7)
59
+ CivilDate.from_jdn(week1_monday + ((week - 1) * 7) + weekday - 1)
60
+ end
61
+
62
+ def to_s
63
+ format("%<week_year>04d-W%<week>02d-%<weekday>d", week_year:, week:, weekday:)
64
+ end
65
+
66
+ def weekday_name
67
+ WEEKDAY_NAMES.fetch(weekday - 1)
68
+ end
69
+
70
+ def ==(other)
71
+ other.is_a?(WeekDate) && [week_year, week, weekday] == [other.week_year, other.week, other.weekday]
72
+ end
73
+ alias eql? ==
74
+
75
+ def hash
76
+ [week_year, week, weekday].hash
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ # Lazy inclusive range of dates from one calendar system.
5
+ class DateRange
6
+ include Enumerable
7
+
8
+ attr_reader :start_date, :end_date
9
+
10
+ # Create an inclusive range with matching calendar systems.
11
+ def initialize(start_date, end_date)
12
+ unless start_date.is_a?(CivilDate) && end_date.is_a?(CivilDate)
13
+ raise ArgumentError, "range endpoints must be CivilDate objects"
14
+ end
15
+ unless start_date.calendar == end_date.calendar
16
+ raise InvalidCalendarError, "range endpoints must use the same calendar"
17
+ end
18
+ raise ArgumentError, "start_date must not follow end_date" if start_date > end_date
19
+
20
+ @start_date = start_date
21
+ @end_date = end_date
22
+ freeze
23
+ end
24
+
25
+ # Yield each date without first materializing the range.
26
+ def each
27
+ return enum_for(:each) unless block_given?
28
+
29
+ offset = 0
30
+ while offset <= count - 1
31
+ yield start_date.add_days(offset)
32
+ offset += 1
33
+ end
34
+ self
35
+ end
36
+
37
+ # Number of dates in the inclusive interval.
38
+ def count
39
+ start_date.days_until(end_date) + 1
40
+ end
41
+ alias size count
42
+
43
+ def include?(date)
44
+ date.is_a?(CivilDate) && date.calendar == start_date.calendar &&
45
+ date.to_jdn >= start_date.to_jdn && date.to_jdn <= end_date.to_jdn
46
+ end
47
+
48
+ def first(number = nil)
49
+ return start_date if number.nil?
50
+ raise ArgumentError, "number must be a non-negative Integer" unless number.is_a?(Integer) && number >= 0
51
+
52
+ take(number)
53
+ end
54
+
55
+ def last(number = nil)
56
+ return end_date if number.nil?
57
+ raise ArgumentError, "number must be a non-negative Integer" unless number.is_a?(Integer) && number >= 0
58
+ return [] if number.zero?
59
+
60
+ first_index = [count - number, 0].max
61
+ (first_index...count).map { |offset| start_date.add_days(offset) }
62
+ end
63
+
64
+ def step(days = 1)
65
+ raise ArgumentError, "step must be a positive Integer" unless days.is_a?(Integer) && days.positive?
66
+ return enum_for(:step, days) unless block_given?
67
+
68
+ offset = 0
69
+ while offset < count
70
+ yield start_date.add_days(offset)
71
+ offset += days
72
+ end
73
+ self
74
+ end
75
+
76
+ def weekdays
77
+ return enum_for(:weekdays) unless block_given?
78
+
79
+ each { |date| yield date unless [6, 7].include?(date.weekday) }
80
+ self
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jw_calendar/version"
4
+ require "jw_calendar/errors"
5
+ require "jw_calendar/arithmetic/floor_division"
6
+ require "jw_calendar/calendars/gregorian"
7
+ require "jw_calendar/calendars/julian"
8
+ require "jw_calendar/civil_date"
9
+ require "jw_calendar/conversion/julian_day_number"
10
+ require "jw_calendar/conversion/calendar_converter"
11
+ require "jw_calendar/conversion/ordinal_date"
12
+ require "jw_calendar/iso/week_date"
13
+ require "jw_calendar/calendars/reform_calendar"
14
+ require "jw_calendar/grid/cell"
15
+ require "jw_calendar/grid/month_grid"
16
+ require "jw_calendar/range/date_range"
17
+ require "jw_calendar/boundary/report"
18
+ require "jw_calendar/boundary/analyzer"
19
+ require "jw_calendar/formatting/iso8601"
20
+
21
+ # Deterministic civil-date arithmetic for Gregorian, Julian, and reform calendars.
22
+ module JWCalendar
23
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jw_calendar
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - JW Calendar
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ A dependency-free civil-calendar engine for Ruby with proleptic Gregorian and Julian arithmetic,
14
+ Julian Day Number conversions, ISO week and ordinal dates, month grids, reform cutovers, and
15
+ boundary analysis. Civil dates are kept separate from instants and time zones.
16
+ email: []
17
+ executables:
18
+ - jwcalendar
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - CODE_OF_CONDUCT.md
24
+ - CONTRIBUTING.md
25
+ - LICENSE.txt
26
+ - README.md
27
+ - SECURITY.md
28
+ - docs/architecture.md
29
+ - docs/civil-dates.md
30
+ - docs/cli.md
31
+ - docs/correctness.md
32
+ - docs/gregorian.md
33
+ - docs/iso-week.md
34
+ - docs/julian-day-number.md
35
+ - docs/julian.md
36
+ - docs/month-grid.md
37
+ - docs/reform-calendars.md
38
+ - exe/jwcalendar
39
+ - lib/jw_calendar.rb
40
+ - lib/jw_calendar/arithmetic/floor_division.rb
41
+ - lib/jw_calendar/boundary/analyzer.rb
42
+ - lib/jw_calendar/boundary/report.rb
43
+ - lib/jw_calendar/calendars/gregorian.rb
44
+ - lib/jw_calendar/calendars/julian.rb
45
+ - lib/jw_calendar/calendars/reform_calendar.rb
46
+ - lib/jw_calendar/civil_date.rb
47
+ - lib/jw_calendar/cli/runner.rb
48
+ - lib/jw_calendar/conversion/calendar_converter.rb
49
+ - lib/jw_calendar/conversion/julian_day_number.rb
50
+ - lib/jw_calendar/conversion/ordinal_date.rb
51
+ - lib/jw_calendar/errors.rb
52
+ - lib/jw_calendar/formatting/iso8601.rb
53
+ - lib/jw_calendar/grid/cell.rb
54
+ - lib/jw_calendar/grid/month_grid.rb
55
+ - lib/jw_calendar/iso/week_date.rb
56
+ - lib/jw_calendar/range/date_range.rb
57
+ - lib/jw_calendar/version.rb
58
+ homepage: https://jwcalendar.com/
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ homepage_uri: https://jwcalendar.com/
63
+ source_code_uri: https://github.com/karencohenjw/jw_calendar
64
+ documentation_uri: https://github.com/karencohenjw/jw_calendar/tree/main/docs
65
+ changelog_uri: https://github.com/karencohenjw/jw_calendar/blob/main/CHANGELOG.md
66
+ bug_tracker_uri: https://github.com/karencohenjw/jw_calendar/issues
67
+ rubygems_mfa_required: 'true'
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.3'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.20
83
+ specification_version: 4
84
+ summary: Deterministic Gregorian, Julian, ISO week-date and calendar-grid utilities
85
+ for Ruby.
86
+ test_files: []