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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: beca66c4a6a4fd8b0dfffca8bdd1a3beac47660d3a87b0295eed510ba9f8f9fa
4
+ data.tar.gz: 2cc6a2d0a7b6b70bd6c7c14fbf13da897d47ad197f649e8a2607d0dc723cd892
5
+ SHA512:
6
+ metadata.gz: 337e84b8ccf6756b1b9ace6eec56066dcb2857593d2cb2bf0f375c3a9886c5a7812b6270711e86b8122b07827a9f46d63f375d4675006c30bb9143774415fe7c
7
+ data.tar.gz: 1b732076abff0d37fda3639d2204d85817ae89b4320de2145c6b12b229ec81edb3560f5eb14a2ec172f51c9ab7ceb97c2d18e0e5c24effed0699a098aa764bb6
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
4
+
5
+ ## [0.1.0] - 2026-10-01
6
+
7
+ ### Added
8
+
9
+ - Immutable Gregorian and Julian civil dates with validation, arithmetic, comparison, and ordinal day.
10
+ - Integer JDN conversion and exact rational JD/MJD day fractions.
11
+ - Gregorian/Julian conversion, ISO week dates, and ordinal date values.
12
+ - Structured natural and fixed-size month grids with adjacent or blank cells.
13
+ - Configurable reform calendars with Papal and British Empire cutover profiles.
14
+ - Lazy inclusive date ranges and structured calendar-boundary reports.
15
+ - `jwcalendar` CLI with inspection, conversion, grid, JDN, ISO-week, boundary, and JSON output commands.
16
+ - Minitest suite, examples, technical documentation, CI, and OIDC-based release workflow.
@@ -0,0 +1,17 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our pledge
4
+
5
+ We are committed to making participation in this project a welcoming and respectful experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6
+
7
+ ## Our standards
8
+
9
+ Examples of behavior that contributes to a positive environment include respectful language, patience with differing viewpoints, constructive feedback, and accepting responsibility for mistakes.
10
+
11
+ Examples of unacceptable behavior include harassment, insults, personal attacks, unwelcome sexual attention, publishing private information without permission, and other conduct that would be inappropriate in a professional setting.
12
+
13
+ ## Enforcement
14
+
15
+ Project maintainers may remove, edit, or reject contributions that violate this Code of Conduct and may temporarily or permanently exclude contributors. Report conduct concerns privately through the repository's GitHub security contact or by contacting a maintainer through GitHub.
16
+
17
+ This policy is adapted from the Contributor Covenant, version 2.1: <https://www.contributor-covenant.org/version/2/1/code_of_conduct/>.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,24 @@
1
+ # Contributing
2
+
3
+ ## Development setup
4
+
5
+ Use Ruby 3.3 or newer, then run:
6
+
7
+ ```sh
8
+ bundle install
9
+ bundle exec rake
10
+ ```
11
+
12
+ Run an example with `ruby -Ilib examples/inspect_date.rb`.
13
+
14
+ ## Calendar correctness
15
+
16
+ Calendar code needs explicit, independently checked expected values. Keep civil labels separate from instants and timezones. When changing an algorithm, add boundary regressions (especially month/year transitions, ISO week-years, leap rules, and calendar cutovers), plus deterministic round-trip or invariant coverage. Use Ruby's `Date` only as an independent test oracle for Gregorian cross-checks; the library implementation must remain its own integer arithmetic.
17
+
18
+ ## Pull requests and bug reports
19
+
20
+ Describe the observed behavior, expected behavior, Ruby version, and a minimal date example. For pull requests, explain the calendar convention and cite a reliable reference if the change follows a published rule. Keep changes focused and update relevant docs and executable examples.
21
+
22
+ ## Architecture expectations
23
+
24
+ Runtime dependencies should remain zero unless a concrete need is demonstrated. Public API belongs under `JWCalendar::`; calculations must be deterministic and free of mutable process-global state. Do not add timezone behavior to `CivilDate`.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JW Calendar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # JW Calendar for Ruby
2
+
3
+ JW Calendar is a deterministic, dependency-free civil-calendar engine for Ruby. It implements proleptic Gregorian and Julian arithmetic, exact day-number conversions, ISO week dates, ordinal dates, month grids, configurable calendar reforms, lazy date ranges, and boundary reports.
4
+
5
+ **A civil date is a calendar label, not a timestamp.** `2027-01-01` does not imply midnight UTC or any other instant. JWCalendar does not consult the machine timezone, locale, or clock.
6
+
7
+ ## Why this library exists
8
+
9
+ Date boundaries are easy to get subtly wrong: a date's ISO week-year can differ from its calendar year, Julian and Gregorian labels diverge, and a month grid may need dates from adjacent months. JWCalendar keeps those rules explicit and uses integer day arithmetic so the same input produces the same result on every host.
10
+
11
+ ## Installation
12
+
13
+ ```sh
14
+ gem install jw_calendar
15
+ ```
16
+
17
+ Or add to your Gemfile:
18
+
19
+ ```ruby
20
+ gem "jw_calendar"
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ```ruby
26
+ require "jw_calendar"
27
+
28
+ date = JWCalendar::CivilDate.gregorian(2027, 1, 1)
29
+ date.to_s # => "2027-01-01"
30
+ date.weekday_name # => "Friday"
31
+ date.iso_week.to_s # => "2026-W53-5"
32
+ date.ordinal_day # => 1
33
+ date.to_jdn # => 2461407
34
+ date.add_days(1).to_s # => "2027-01-02"
35
+ ```
36
+
37
+ ## Civil Dates
38
+
39
+ `JWCalendar::CivilDate` is an immutable value object with a positive year, month, day, and explicit `:gregorian` or `:julian` calendar. Dates compare by absolute day; equality and hashing also include the calendar label. `#next_day`, `#previous_day`, `#add_days`, and `#subtract_days` preserve the selected calendar.
40
+
41
+ ```ruby
42
+ date = JWCalendar::CivilDate.new(2024, 2, 29, calendar: :gregorian)
43
+ date.frozen? # => true
44
+ date.ordinal_day # => 60
45
+ ```
46
+
47
+ The supported year domain starts at 1 CE; year zero and BCE labels are not represented.
48
+
49
+ ## Gregorian Calendar
50
+
51
+ `JWCalendar::Calendars::Gregorian` implements proleptic Gregorian leap years, month lengths, validation, ordinal day, and integer JDN conversion. “Proleptic” means the modern Gregorian rules are applied consistently to dates before their historical adoption.
52
+
53
+ ## Julian Calendar
54
+
55
+ `JWCalendar::Calendars::Julian` is a distinct civil calendar with every fourth year leap. A **Julian calendar date** is not a **Julian Day Number**; the latter is an integer count of days used to map dates onto a common absolute timeline.
56
+
57
+ ```ruby
58
+ gregorian = JWCalendar::CivilDate.gregorian(1582, 10, 15)
59
+ julian = JWCalendar::Conversion::CalendarConverter.convert(gregorian, to: :julian)
60
+ julian.to_s # => "1582-10-05"
61
+ ```
62
+
63
+ ## Julian Day Numbers
64
+
65
+ Use `JWCalendar::Conversion::JulianDayNumber` for JDN, JD, and MJD operations. JDN is an integer associated with astronomical noon. The exact JD for the start of a civil date is therefore `JDN - 1/2`; MJD is `JD - 2_400_000.5`. Fractions are represented as `Rational` when supplied as exact values.
66
+
67
+ ```ruby
68
+ date = JWCalendar::CivilDate.gregorian(2000, 1, 1)
69
+ JWCalendar::Conversion::JulianDayNumber.jd(date) # => (4903089/2)
70
+ JWCalendar::Conversion::JulianDayNumber.mjd(date) # => (51544/1)
71
+ JWCalendar::CivilDate.from_jdn(date.to_jdn) # => same date
72
+ ```
73
+
74
+ `jd(date, fraction: Rational(1, 4))` adds an explicitly supplied fraction of that civil day. This is a day fraction, not a timezone-aware time-of-day object.
75
+
76
+ ## ISO Week Dates
77
+
78
+ `date.iso_week` returns an immutable `JWCalendar::ISO::WeekDate` with ISO week-year, week, and weekday (Monday=1 through Sunday=7). `#to_date` converts it back to a proleptic Gregorian `CivilDate`.
79
+
80
+ ```ruby
81
+ JWCalendar::CivilDate.gregorian(2027, 1, 1).iso_week.to_s # => "2026-W53-5"
82
+ JWCalendar::ISO::WeekDate.new(2026, 53, 5).to_date.to_s # => "2027-01-01"
83
+ ```
84
+
85
+ ISO week dates follow the ISO week-year boundary rule: week 1 contains January 4, equivalently the first Thursday of the ISO week-year.
86
+
87
+ ## Ordinal Dates
88
+
89
+ `JWCalendar::Conversion::OrdinalDate` represents year plus day-of-year, such as `2028-366`.
90
+
91
+ ```ruby
92
+ ordinal = JWCalendar::Conversion::OrdinalDate.for(JWCalendar::CivilDate.gregorian(2028, 12, 31))
93
+ ordinal.to_s # => "2028-366"
94
+ ordinal.to_date # => 2028-12-31
95
+ ```
96
+
97
+ ## Month Grids
98
+
99
+ `JWCalendar::Grid::MonthGrid` returns immutable rows of seven semantic `Cell` values; it does not render HTML. Choose Monday or Sunday (or ISO weekday 1–7), adjacent dates, blank spillover cells, natural row count, or a fixed four-, five-, or six-week layout.
100
+
101
+ ```ruby
102
+ grid = JWCalendar::Grid::MonthGrid.new(
103
+ year: 2027, month: 1, week_start: :sunday,
104
+ fixed_weeks: 6, include_adjacent: true
105
+ )
106
+ grid.rows.length # => 6
107
+ grid.cells.length # => 42
108
+ grid.rows.first.first.date.to_s # => "2026-12-27"
109
+ ```
110
+
111
+ Each cell exposes its date (or `nil` in blank mode), `in_current_month?`, weekday, week index, and column index. `#to_h` provides a JSON-friendly structure.
112
+
113
+ ## Calendar Reform
114
+
115
+ `JWCalendar::Calendars::ReformCalendar` models one explicitly configured local Julian-to-Gregorian cutover. It rejects skipped labels with `ReformGapError` and maps absolute days to the appropriate side. `ReformCalendar.papal` and `.british_empire` provide the named 1582 and 1752 profiles. These are examples of local historical rules, not a claim that one cutover applied everywhere.
116
+
117
+ ```ruby
118
+ reform = JWCalendar::Calendars::ReformCalendar.papal
119
+ reform.date(1582, 10, 4).calendar # => :julian
120
+ reform.date(1582, 10, 15).calendar # => :gregorian
121
+ reform.valid_date?(1582, 10, 10) # => false
122
+ ```
123
+
124
+ ## Boundary Analysis
125
+
126
+ `JWCalendar::Boundary::Analyzer.year(2027)` returns a `Report` containing structured events for leap days, month/year ends, six-row months, ISO week-year rollovers, ISO week 53 dates, and Gregorian/Julian offset changes. `Analyzer.range(date_range)` analyzes an explicit lazy range. Pass `reform_calendar: JWCalendar::Calendars::ReformCalendar.papal` to include labels skipped by that profile.
127
+
128
+ ```ruby
129
+ report = JWCalendar::Boundary::Analyzer.year(2024)
130
+ report.select { |event| event[:type] == :leap_day }
131
+ # => [{ type: :leap_day, date: "2024-02-29", calendar: :gregorian }]
132
+ ```
133
+
134
+ ## CLI
135
+
136
+ The `jwcalendar` executable is installed with the gem:
137
+
138
+ ```sh
139
+ jwcalendar --help
140
+ jwcalendar --version
141
+ jwcalendar inspect 2027-01-01
142
+ jwcalendar iso-week 2027-01-01
143
+ jwcalendar jdn 2000-01-01
144
+ jwcalendar convert 2027-01-01 --from gregorian --to julian
145
+ jwcalendar grid 2027-01 --week-start monday --fixed-weeks 6
146
+ jwcalendar boundary 2027
147
+ ```
148
+
149
+ Invalid input prints a concise error to standard error and exits non-zero.
150
+
151
+ ## JSON Output
152
+
153
+ Add `--json` to structured commands. Output uses stable field names and exact day fractions are serialized as rational strings.
154
+
155
+ ```sh
156
+ jwcalendar inspect 2027-01-01 --json
157
+ ```
158
+
159
+ The JSON includes `date`, `calendar`, `weekday`, `weekday_number`, `ordinal_day`, `jdn`, `iso_week_year`, `iso_week`, and `iso_weekday`.
160
+
161
+ ## Architecture
162
+
163
+ The public API is organized under `JWCalendar::CivilDate`, `Calendars`, `Conversion`, `ISO`, `Grid`, `Boundary`, and `DateRange`. Core date operations use integer arithmetic and contain no mutable global calendar state. Runtime dependencies are zero; CLI parsing and JSON use the Ruby standard library.
164
+
165
+ ## Correctness Model
166
+
167
+ Gregorian and Julian dates map to integer JDNs. Weekdays are calculated from the absolute day; ISO week-years are derived using the Thursday rule. Month grids are assembled from those same date operations. Tests include deterministic round trips, broad year samples, Ruby's independent `Date` implementation for proleptic Gregorian checks, and known boundary dates. Arithmetic methods are O(1), apart from output-sized operations such as iterating a range or building a grid.
168
+
169
+ ## Time Zones and Non-Goals
170
+
171
+ JWCalendar models civil dates only. It does not replace timezone databases, `TZInfo`, `ActiveSupport::TimeZone`, event scheduling, recurrence-rule engines, or a localization framework. It never assumes a civil date means midnight UTC. A caller that has an instant must choose its timezone policy before obtaining a civil date.
172
+
173
+ ## Performance
174
+
175
+ Gregorian/Julian conversion, weekday, ordinal lookup, ISO week conversion, and date arithmetic are constant-time integer operations. Date ranges are lazy. The `benchmark/` directory contains reproducible Ruby `Benchmark` scripts; results depend on runtime and hardware, and no universal throughput claim is made.
176
+
177
+ ## Supported Ruby Versions
178
+
179
+ JWCalendar requires Ruby 3.3 or newer. CI tests Ruby 3.3, 3.4, and 4.0.
180
+
181
+ ## Development
182
+
183
+ ```sh
184
+ bundle install
185
+ bundle exec rake
186
+ ```
187
+
188
+ The default task runs Minitest, RuboCop, and gem packaging. Examples can be run with `ruby -Ilib examples/NAME.rb`.
189
+
190
+ ## Testing
191
+
192
+ ```sh
193
+ bundle exec rake test
194
+ bundle exec rubocop
195
+ gem build jw_calendar.gemspec
196
+ ```
197
+
198
+ Tests cover leap rules, invalid labels, arithmetic, JDN/JD/MJD, ISO weeks, ordinals, reform gaps, grids, ranges, CLI behavior, JSON output, and deterministic round trips.
199
+
200
+ ## Security
201
+
202
+ See [SECURITY.md](SECURITY.md). The gem has no runtime dependencies and declares RubyGems MFA as required for publishing.
203
+
204
+ ## Contributing
205
+
206
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Changes to date math should include independently checked examples and regression tests for boundaries.
207
+
208
+ ## License
209
+
210
+ MIT. See [LICENSE.txt](LICENSE.txt).
211
+
212
+ ## Project Website
213
+
214
+ JW Calendar's main project website is [jwcalendar.com](https://jwcalendar.com/).
data/SECURITY.md ADDED
@@ -0,0 +1,11 @@
1
+ # Security Policy
2
+
3
+ ## Supported releases
4
+
5
+ Security fixes are provided for the latest released version. Please update to the latest version before reporting an issue.
6
+
7
+ ## Reporting a vulnerability
8
+
9
+ Please use GitHub's private vulnerability reporting for this repository: <https://github.com/karencohenjw/jw_calendar/security/advisories/new>. Do not include exploit details in a public issue. Reports should describe the affected version, impact, and a minimal reproduction where possible.
10
+
11
+ JWCalendar performs deterministic arithmetic on caller-provided values and has no runtime network access or runtime dependencies. Reports about parsing, denial-of-service inputs, package integrity, or release workflow permissions are in scope.
@@ -0,0 +1,26 @@
1
+ # Architecture
2
+
3
+ ## Public layers
4
+
5
+ - `CivilDate` stores a validated immutable calendar label and maps it to an integer JDN.
6
+ - `Calendars::Gregorian` and `Calendars::Julian` own their respective leap rules and JDN algorithms.
7
+ - `Conversion` handles cross-calendar and ordinal/JD representations.
8
+ - `ISO::WeekDate` handles ISO week-year value conversion.
9
+ - `Grid`, `DateRange`, and `Boundary` compose the date primitives without owning a clock or timezone.
10
+ - `CLI::Runner` is a small standard-library interface around the public operations.
11
+
12
+ The library uses positive civil years beginning at 1 CE and has no runtime dependencies. Constructors validate inputs before storing them. Value objects freeze their instance state; there is no process-global calendar selection.
13
+
14
+ ## Determinism
15
+
16
+ All date calculations use integers. JD/MJD fractions use `Rational` when produced by the library or supplied exactly. Nothing reads local time, environment locale, or system timezone. Range enumeration is lazy; a grid allocates only its four-to-six rows.
17
+
18
+ ## Complexity
19
+
20
+ Leap checks, conversion, weekday, ordinal lookup, comparison, and adding a fixed number of days are O(1). A month grid is O(1) in its fixed maximum size. Range traversal is O(n) time and O(1) extra space, excluding objects yielded to the caller. Boundary reports take O(days in the requested year/range) because they emit date-level ISO conditions.
21
+
22
+ ## References
23
+
24
+ - ISO week numbering is specified by [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html).
25
+ - Julian date conventions are described by the [U.S. Naval Observatory](https://aa.usno.navy.mil/data/JulianDate).
26
+ - Ruby's `Date` is used only as a test oracle for Gregorian arithmetic: [Ruby Date documentation](https://docs.ruby-lang.org/en/master/Date.html).
@@ -0,0 +1,14 @@
1
+ # Civil dates
2
+
3
+ `CivilDate` stores `(year, month, day, calendar)` and no time-of-day fields. Its default constructor calendar is Gregorian; explicit class constructors are available:
4
+
5
+ ```ruby
6
+ gregorian = JWCalendar::CivilDate.gregorian(2027, 1, 1)
7
+ julian = JWCalendar::CivilDate.julian(1582, 10, 4)
8
+ ```
9
+
10
+ Years are positive integers (1 CE and later). Invalid month/day combinations raise `InvalidDateError`; an unsupported calendar raises `InvalidCalendarError`. The date is frozen after validation.
11
+
12
+ Ordering compares absolute JDN values, so distinct calendar labels can represent the same day and compare equally in ordering. `==` and `hash` include the calendar system, which keeps each labelled representation distinct as a value. Arithmetic preserves the source calendar system. Converting a day to a Gregorian/Julian label is an explicit operation through `CalendarConverter` or `CivilDate.from_jdn`.
13
+
14
+ No conversion to midnight UTC is implied. If an application starts from an instant, it must apply its own timezone policy before creating a civil date.
data/docs/cli.md ADDED
@@ -0,0 +1,14 @@
1
+ # Command-line interface
2
+
3
+ The `jwcalendar` executable wraps the library with stable, deterministic commands:
4
+
5
+ ```text
6
+ jwcalendar inspect DATE [--calendar gregorian|julian] [--json]
7
+ jwcalendar iso-week DATE [--calendar gregorian|julian] [--json]
8
+ jwcalendar jdn DATE [--calendar gregorian|julian] [--json]
9
+ jwcalendar convert DATE [--from gregorian|julian] [--to gregorian|julian] [--json]
10
+ jwcalendar grid YYYY-MM [--week-start monday|sunday] [--fixed-weeks 4|5|6] [--no-adjacent] [--json]
11
+ jwcalendar boundary YEAR [--json]
12
+ ```
13
+
14
+ Structured commands accept `--json`. Invalid arguments are written to standard error and return exit status 2. The default output is intended for people; scripts should request JSON. JDN is emitted as an integer, and JD/MJD at midnight as exact rational strings.
@@ -0,0 +1,7 @@
1
+ # Correctness model
2
+
3
+ The core invariant is that a Gregorian or Julian label maps to one integer JDN and inverse conversion returns the same label. Cross-calendar conversion preserves JDN. Weekday derives from the same absolute day. ISO conversion uses the Monday containing January 4 and round-trips every sampled day.
4
+
5
+ Tests cover leap centuries, known historical and epoch values, New Year ISO transitions, deterministic year samples, cutover gaps, 7-column grids, exact JD/MJD fractions, and the Julian/Gregorian offset change after February in century years such as 1700. For supported positive Gregorian years, the test suite compares JDN results with Ruby's independent `Date` implementation configured for proleptic Gregorian rules.
6
+
7
+ The supported civil label domain is 1 CE onward, with no year zero. Time scales, leap seconds, timezone interpretation, and locale-specific week displays are outside this version's correctness claim.
data/docs/gregorian.md ADDED
@@ -0,0 +1,9 @@
1
+ # Proleptic Gregorian calendar
2
+
3
+ The Gregorian leap-year rule is divisibility by 4, except century years must also be divisible by 400. Thus 1600 and 2000 are leap years, while 1700, 1800, 1900, and 2100 are not.
4
+
5
+ `Calendars::Gregorian` uses closed-form integer arithmetic to map a valid positive-year label to JDN and invert an integer JDN. The proleptic model applies Gregorian rules consistently before historical adoption; it does not itself model a jurisdiction's changeover. For a historical Julian-to-Gregorian switch, use `ReformCalendar`.
6
+
7
+ Weekday derives from `JDN mod 7`, with Monday=1 and Sunday=7. Day-of-year is computed from the same validated Gregorian month lengths. These operations do not iterate from an epoch.
8
+
9
+ Ruby's standard `Date` is used in tests as an independent cross-check, not as the implementation: [Date documentation](https://docs.ruby-lang.org/en/master/Date.html).
data/docs/iso-week.md ADDED
@@ -0,0 +1,12 @@
1
+ # ISO week dates
2
+
3
+ An ISO week begins Monday. Week 1 is the week containing January 4 (equivalently the year's first Thursday). Therefore dates near New Year can have a week-year different from their Gregorian calendar year.
4
+
5
+ `CivilDate#iso_week` returns `ISO::WeekDate`. `WeekDate#to_date` returns the corresponding proleptic Gregorian date; construction validates whether the year has 52 or 53 weeks.
6
+
7
+ ```ruby
8
+ date = JWCalendar::CivilDate.gregorian(2027, 1, 1)
9
+ date.iso_week.to_s # => "2026-W53-5"
10
+ ```
11
+
12
+ The ISO weekday is Monday=1 through Sunday=7. The week-year is determined by moving to the date's Thursday, which is constant-time. Normative rules are in [ISO 8601](https://www.iso.org/iso-8601-date-and-time-format.html).
@@ -0,0 +1,9 @@
1
+ # JDN, JD, and MJD
2
+
3
+ `CivilDate#to_jdn` returns the integer Julian Day Number associated with that date at astronomical noon. JDNs provide a common integer coordinate for Gregorian and Julian labels.
4
+
5
+ JD is fractional. The start of a civil day at midnight is half a day before its noon-labelled integer: `JD = JDN - 1/2`. The inverse splits an exact JD into a date and a fraction since midnight. MJD uses `MJD = JD - 2,400,000.5`; at midnight, MJD equals `JDN - 2,400,001`.
6
+
7
+ The conversion methods accept or return rational values. For example, a quarter-day offset should be supplied as `Rational(1, 4)` when exactness matters. A fraction denotes a mathematical fraction of a day; this API does not model leap seconds or a timescale such as UTC, TT, or TAI.
8
+
9
+ For background on Julian date conventions, see the [U.S. Naval Observatory](https://aa.usno.navy.mil/data/JulianDate).
data/docs/julian.md ADDED
@@ -0,0 +1,13 @@
1
+ # Julian calendar
2
+
3
+ The Julian civil calendar inserts a leap day every four years. It is implemented separately from Gregorian arithmetic under `Calendars::Julian`.
4
+
5
+ Both calendar systems map to JDN, which lets `CalendarConverter` translate a date label while preserving its absolute day:
6
+
7
+ ```ruby
8
+ day = JWCalendar::CivilDate.gregorian(1582, 10, 15)
9
+ JWCalendar::Conversion::CalendarConverter.convert(day, to: :julian).to_s
10
+ # => "1582-10-05"
11
+ ```
12
+
13
+ “Julian calendar” refers to a civil calendar rule. “Julian Day Number” refers to an integer day count; the shared word does not make them the same concept. The library supports positive year labels and does not encode historical jurisdiction-specific adoption dates unless a `ReformCalendar` is selected.
@@ -0,0 +1,7 @@
1
+ # Month grids
2
+
3
+ `Grid::MonthGrid` produces rows of seven immutable cells. Each nonblank cell has a `CivilDate`, a current-month flag, weekday, zero-based row index, and zero-based column index.
4
+
5
+ `week_start` accepts `:monday`, `:sunday`, or an ISO weekday integer. By default the grid has the natural four, five, or six weeks needed to contain all dates. Set `fixed_weeks: 6` for a 42-cell layout. A shorter fixed size that cannot contain the month raises `ArgumentError`.
6
+
7
+ With `include_adjacent: true`, cells before and after the month are real dates. With `false`, those cells are blank (`date == nil`) while retaining their grid position. `#to_h` returns JSON-friendly row and weekday data; presentation remains the caller's responsibility.
@@ -0,0 +1,14 @@
1
+ # Reform calendars
2
+
3
+ Historical adoption of Gregorian rules happened on different dates in different places. A reform profile therefore requires an explicit local cutover pair:
4
+
5
+ ```ruby
6
+ reform = JWCalendar::Calendars::ReformCalendar.new(
7
+ last_julian_date: [1582, 10, 4],
8
+ first_gregorian_date: [1582, 10, 15]
9
+ )
10
+ ```
11
+
12
+ The pair must represent consecutive absolute days. Labels after the last Julian label and before the first Gregorian label raise `ReformGapError`. `date` selects the appropriate underlying calendar for labels on either side; `from_jdn` does the inverse. Optional constructors `.papal` and `.british_empire` are named profiles for commonly cited transitions, not universal history settings.
13
+
14
+ `CivilDate` intentionally records only its underlying proleptic Gregorian or Julian system. Keep the `ReformCalendar` instance with application data when the selected jurisdiction's reform rule matters.
data/exe/jwcalendar ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "jw_calendar"
6
+ require "jw_calendar/cli/runner"
7
+
8
+ exit JWCalendar::CLI::Runner.new.run(ARGV)
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Arithmetic
5
+ # Integer division rounded toward negative infinity, unlike Ruby's Integer#div
6
+ # only in its explicit validation and documentation of the calendar use case.
7
+ module FloorDivision
8
+ module_function
9
+
10
+ def div(numerator, denominator)
11
+ unless numerator.is_a?(Integer) && denominator.is_a?(Integer) && denominator.positive?
12
+ raise ArgumentError, "expected an Integer numerator and positive Integer denominator"
13
+ end
14
+
15
+ numerator.div(denominator)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JWCalendar
4
+ module Boundary
5
+ # Finds deterministic high-risk boundaries useful for regression tests.
6
+ class Analyzer
7
+ class << self
8
+ def year(year, calendar: :gregorian, reform_calendar: nil)
9
+ return reform_year(year, reform_calendar) if reform_calendar
10
+
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
+ events = []
18
+ months = (1..12).map do |month|
19
+ days = engine.days_in_month(year, month)
20
+ first_date = CivilDate.new(year, month, 1, calendar:)
21
+ grid = Grid::MonthGrid.new(year:, month:, calendar:)
22
+ events << event(:six_row_month, first_date, rows: grid.weeks) if grid.weeks == 6
23
+ events << event(:leap_day, CivilDate.new(year, 2, 29, calendar:)) if month == 2 && days == 29
24
+ events << event(:month_end, CivilDate.new(year, month, days, calendar:), month:)
25
+ days
26
+ end
27
+ events << event(:year_end, CivilDate.new(year, 12, months.last, calendar:))
28
+
29
+ first = CivilDate.new(year, 1, 1, calendar:)
30
+ last = CivilDate.new(year, 12, months.last, calendar:)
31
+ add_absolute_day_events(events, first.to_jdn, last.to_jdn, calendar:)
32
+ add_offset_change_events(events, first.to_jdn, last.to_jdn, calendar:)
33
+ Report.new(events)
34
+ end
35
+
36
+ def range(date_range)
37
+ raise ArgumentError, "date_range must be a DateRange" unless date_range.is_a?(DateRange)
38
+
39
+ events = []
40
+ previous_offset = nil
41
+ date_range.each do |date|
42
+ append_range_date_events(events, date)
43
+ previous_offset = append_offset_change_event(events, date, previous_offset)
44
+ end
45
+ Report.new(events)
46
+ end
47
+
48
+ private
49
+
50
+ def add_absolute_day_events(events, first_jdn, last_jdn, calendar:)
51
+ (first_jdn..last_jdn).each do |jdn|
52
+ date = CivilDate.from_jdn(jdn, calendar:)
53
+ append_iso_week_events(events, date)
54
+ end
55
+ end
56
+
57
+ def add_offset_change_events(events, first_jdn, last_jdn, calendar:)
58
+ previous_offset = nil
59
+ (first_jdn..last_jdn).each do |jdn|
60
+ date = CivilDate.from_jdn(jdn, calendar:)
61
+ previous_offset = append_offset_change_event(events, date, previous_offset)
62
+ end
63
+ end
64
+
65
+ def reform_year(year, reform)
66
+ validate_reform_year!(year, reform)
67
+
68
+ events = reform_gap_events(year, reform)
69
+ year_dates = reform_year_dates(year, reform, events)
70
+ raise InvalidDateError, "reform calendar has no valid dates in year #{year}" if year_dates.empty?
71
+
72
+ events << event(:year_end, year_dates.last)
73
+ year_dates.each { |date| append_iso_week_events(events, date) }
74
+ Report.new(events)
75
+ end
76
+
77
+ def append_range_date_events(events, date)
78
+ events << event(:leap_day, date) if date.month == 2 && date.day == 29
79
+ engine = date.calendar == :gregorian ? Calendars::Gregorian : Calendars::Julian
80
+ events << event(:month_end, date) if date.day == engine.days_in_month(date.year, date.month)
81
+ append_iso_week_events(events, date)
82
+ end
83
+
84
+ def append_iso_week_events(events, date)
85
+ gregorian_date = CivilDate.from_jdn(date.to_jdn)
86
+ iso = gregorian_date.iso_week
87
+ if iso.week_year != gregorian_date.year
88
+ events << event(:iso_week_year_rollover, date, iso_week_year: iso.week_year,
89
+ iso_week: iso.week)
90
+ end
91
+ events << event(:iso_week_53, date, iso_week_year: iso.week_year, iso_week: iso.week) if iso.week == 53
92
+ end
93
+
94
+ def append_offset_change_event(events, date, previous_offset)
95
+ return previous_offset unless valid_in_both_calendars?(date)
96
+
97
+ offset = Calendars::Julian.to_jdn(date.year, date.month, date.day) -
98
+ Calendars::Gregorian.to_jdn(date.year, date.month, date.day)
99
+ if previous_offset && offset != previous_offset
100
+ events << event(:gregorian_julian_offset_change, date, previous_offset:, offset:)
101
+ end
102
+ offset
103
+ end
104
+
105
+ def valid_in_both_calendars?(date)
106
+ Calendars::Gregorian.valid_date?(date.year, date.month, date.day) &&
107
+ Calendars::Julian.valid_date?(date.year, date.month, date.day)
108
+ end
109
+
110
+ def validate_reform_year!(year, reform)
111
+ raise ArgumentError, "reform_calendar must be a ReformCalendar" unless reform.is_a?(Calendars::ReformCalendar)
112
+ raise ArgumentError, "year must be a positive Integer" unless year.is_a?(Integer) && year.positive?
113
+ end
114
+
115
+ def reform_gap_events(year, reform)
116
+ reform.skipped_labels.filter_map do |label|
117
+ { type: :reform_gap, date: label, calendar: :reform } if label.start_with?(format("%04d-", year))
118
+ end
119
+ end
120
+
121
+ def reform_year_dates(year, reform, events)
122
+ 1.upto(12).flat_map do |month|
123
+ dates = (1..31).filter_map do |day|
124
+ reform.date(year, month, day) if reform.valid_date?(year, month, day)
125
+ end
126
+ append_reform_month_events(events, year, month, dates) unless dates.empty?
127
+ dates
128
+ end
129
+ end
130
+
131
+ def append_reform_month_events(events, year, month, dates)
132
+ leap_day = dates.find { |date| date.day == 29 } if month == 2
133
+ events << event(:leap_day, leap_day) if leap_day
134
+ events << event(:month_end, dates.last, month:)
135
+ return unless dates.map(&:calendar).uniq.length == 1
136
+
137
+ grid = Grid::MonthGrid.new(year:, month:, calendar: dates.first.calendar)
138
+ events << event(:six_row_month, dates.first, rows: grid.weeks) if grid.weeks == 6
139
+ end
140
+
141
+ def event(type, date, extra = {})
142
+ { type:, date: date.to_s, calendar: date.calendar }.merge(extra)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end