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 +7 -0
- data/CHANGELOG.md +16 -0
- data/CODE_OF_CONDUCT.md +17 -0
- data/CONTRIBUTING.md +24 -0
- data/LICENSE.txt +21 -0
- data/README.md +214 -0
- data/SECURITY.md +11 -0
- data/docs/architecture.md +26 -0
- data/docs/civil-dates.md +14 -0
- data/docs/cli.md +14 -0
- data/docs/correctness.md +7 -0
- data/docs/gregorian.md +9 -0
- data/docs/iso-week.md +12 -0
- data/docs/julian-day-number.md +9 -0
- data/docs/julian.md +13 -0
- data/docs/month-grid.md +7 -0
- data/docs/reform-calendars.md +14 -0
- data/exe/jwcalendar +8 -0
- data/lib/jw_calendar/arithmetic/floor_division.rb +19 -0
- data/lib/jw_calendar/boundary/analyzer.rb +147 -0
- data/lib/jw_calendar/boundary/report.rb +28 -0
- data/lib/jw_calendar/calendars/gregorian.rb +114 -0
- data/lib/jw_calendar/calendars/julian.rb +90 -0
- data/lib/jw_calendar/calendars/reform_calendar.rb +104 -0
- data/lib/jw_calendar/civil_date.rb +135 -0
- data/lib/jw_calendar/cli/runner.rb +196 -0
- data/lib/jw_calendar/conversion/calendar_converter.rb +22 -0
- data/lib/jw_calendar/conversion/julian_day_number.rb +75 -0
- data/lib/jw_calendar/conversion/ordinal_date.rb +52 -0
- data/lib/jw_calendar/errors.rb +9 -0
- data/lib/jw_calendar/formatting/iso8601.rb +20 -0
- data/lib/jw_calendar/grid/cell.rb +26 -0
- data/lib/jw_calendar/grid/month_grid.rb +114 -0
- data/lib/jw_calendar/iso/week_date.rb +80 -0
- data/lib/jw_calendar/range/date_range.rb +83 -0
- data/lib/jw_calendar/version.rb +5 -0
- data/lib/jw_calendar.rb +23 -0
- metadata +86 -0
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.
|
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -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).
|
data/docs/civil-dates.md
ADDED
|
@@ -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.
|
data/docs/correctness.md
ADDED
|
@@ -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.
|
data/docs/month-grid.md
ADDED
|
@@ -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,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
|