beta_calendars 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: 49e4c5c571e4c9878ab8006fe05159c07a3e3dc86c48a73d425b40368e3d74c2
4
+ data.tar.gz: a4ba1e530f4f2ed9ee885fdf3cd9b53fbb388628b9ed6362c3d90822b195ed12
5
+ SHA512:
6
+ metadata.gz: 8c96ba768ca43572525d54f7abcb5d3b826fec126b52389070b6370769dfc8d444c2f1a15ae3f6995c88afbb4b22ad397fe6db66eccb99e24c8bdff436aec103
7
+ data.tar.gz: eabe8fdaaa4628ce79a5169f96a114bd872a9e56e0ece0a56dd04b2f6f0680e2a9116399a42a5d9a6238a83c500df99fafc75bec0d12a86717cf168e45c30918
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Generate month and year calendar grids with Sunday or Monday week starts.
6
+ - Include optional adjacent-month dates and six-row print layouts.
7
+ - Provide blank planning grids and ISO week metadata.
8
+ - Serialize calendar structures to hashes and JSON.
9
+ - Add the `beta-calendars` command-line interface.
@@ -0,0 +1,22 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our pledge
4
+
5
+ We are committed to making participation in this project a respectful,
6
+ welcoming experience for everyone, regardless of background or identity.
7
+
8
+ ## Our standards
9
+
10
+ Examples of behavior that contributes to a positive environment include being
11
+ kind, considering differing viewpoints, giving and accepting constructive
12
+ feedback, and taking responsibility for mistakes.
13
+
14
+ Unacceptable behavior includes harassment, insults, personal attacks, and
15
+ publishing another person's private information without permission.
16
+
17
+ ## Enforcement
18
+
19
+ Report conduct concerns through the project's GitHub issue tracker. Project
20
+ maintainers will review reports and take appropriate, proportionate action.
21
+
22
+ This code of conduct is adapted from the Contributor Covenant, version 2.1.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,19 @@
1
+ # Contributing
2
+
3
+ Bug reports, focused feature proposals, and pull requests are welcome. Please
4
+ include a small example that shows the expected calendar dates and week-start
5
+ policy when reporting a calculation issue.
6
+
7
+ ## Development
8
+
9
+ The library targets Ruby 3.1 and newer and has no runtime gem dependencies.
10
+
11
+ ```sh
12
+ bundle install
13
+ bundle exec rake test
14
+ gem build beta_calendars.gemspec
15
+ ```
16
+
17
+ Keep changes deterministic, use Ruby's proleptic Gregorian `Date` calculations,
18
+ and add meaningful tests for boundary dates. Do not add network access to the
19
+ calendar computation path.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mateo Pedersen
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,222 @@
1
+ # Beta Calendars Ruby
2
+
3
+ **Deterministic Gregorian calendar grids for Ruby applications.** Generate monthly and yearly calendar data locally, with week-start policies, ISO week metadata, adjacent dates, fixed six-week layouts, blank planning grids, JSON output, and a small CLI.
4
+
5
+ ## Why this exists
6
+
7
+ A calendar grid looks simple, but correct output depends on leap years, weekday offsets, Sunday or Monday week starts, ISO week-year boundaries, and the geometry chosen for printing. This library turns those rules into a predictable data model without a web service, scraping, or runtime gem dependencies.
8
+
9
+ ## Installation
10
+
11
+ ```sh
12
+ gem install beta_calendars
13
+ ```
14
+
15
+ Or add it to a Gemfile:
16
+
17
+ ```ruby
18
+ gem "beta_calendars"
19
+ ```
20
+
21
+ The gem requires Ruby 3.1 or newer and uses Ruby's standard `Date` and `JSON` libraries.
22
+
23
+ ## Quick Start
24
+
25
+ ```ruby
26
+ require "beta_calendars"
27
+
28
+ calendar = BetaCalendars.month(year: 2027, month: 1, week_start: :monday)
29
+
30
+ calendar.year # => 2027
31
+ calendar.month # => 1
32
+ calendar.month_name # => "January"
33
+ calendar.days.size # => 31
34
+ calendar.weeks # => rows of seven DayCell values
35
+ calendar.to_h # => developer-friendly nested hash
36
+ calendar.to_json # => JSON with ISO-8601 date strings
37
+ ```
38
+
39
+ `DayCell` instances are immutable and expose `date`, `day`, `weekday`,
40
+ `iso_week`, `iso_week_year`, `in_current_month`, and `weekend`. `weekday` follows
41
+ Ruby's convention, where Sunday is `0`; `iso_weekday` follows ISO-8601, where
42
+ Monday is `1` and Sunday is `7`.
43
+
44
+ ## Monthly Calendars
45
+
46
+ `BetaCalendars.month` returns a month object. Its `days` array contains only
47
+ dates in the requested month, while `weeks` and `matrix` contain the display
48
+ grid. Each populated position is a `DayCell`, not just a day number.
49
+
50
+ ```ruby
51
+ january = BetaCalendars.month(year: 2027, month: 1)
52
+ january.month_name # => "January"
53
+ january.days.first.date.iso8601 # => "2027-01-01"
54
+ january.weeks.first.first.to_h
55
+ # => {:date=>"2026-12-27", :day=>27, ...}
56
+ ```
57
+
58
+ The grid uses the natural four, five, or six rows required by that month unless
59
+ `fixed_six_weeks: true` is selected.
60
+
61
+ ## Monday vs Sunday Week Start
62
+
63
+ Choose the first weekday explicitly. The API accepts `:monday` and `:sunday`
64
+ (or their string forms); invalid values raise `BetaCalendars::InvalidWeekStartError`.
65
+
66
+ ```ruby
67
+ sunday = BetaCalendars.month(year: 2027, month: 1, week_start: :sunday)
68
+ monday = BetaCalendars.month(year: 2027, month: 1, week_start: :monday)
69
+
70
+ sunday.weekdays # => ["Sunday", "Monday", ...]
71
+ monday.weekdays # => ["Monday", "Tuesday", ...]
72
+ ```
73
+
74
+ ## Adjacent Month Dates
75
+
76
+ Out-of-month cells contain the neighboring real dates by default. Set
77
+ `adjacent_dates: false` to leave those cells as `nil`; this is useful when a
78
+ renderer wants visibly empty leading and trailing cells.
79
+
80
+ ```ruby
81
+ filled = BetaCalendars.month(year: 2027, month: 1, adjacent_dates: true)
82
+ empty = BetaCalendars.month(year: 2027, month: 1, adjacent_dates: false)
83
+
84
+ filled.weeks.first.first.date.iso8601 # => "2026-12-27"
85
+ empty.weeks.first.first # => nil
86
+ ```
87
+
88
+ ## Fixed Six-Week Print Layouts
89
+
90
+ For printable month pages that need identical geometry, set
91
+ `fixed_six_weeks: true`. Every month then has exactly six rows and seven
92
+ columns, including months that naturally need only four or five rows.
93
+
94
+ ```ruby
95
+ print_month = BetaCalendars.month(
96
+ year: 2027,
97
+ month: 2,
98
+ week_start: :monday,
99
+ fixed_six_weeks: true
100
+ )
101
+
102
+ print_month.weeks.length # => 6
103
+ print_month.weeks.all? { |week| week.length == 7 } # => true
104
+ ```
105
+
106
+ With `adjacent_dates: false`, positions outside February remain `nil` even in
107
+ the sixth row. The library supplies geometry; the application decides whether
108
+ those positions render as whitespace, lines, or another design element.
109
+
110
+ ## Year Calendars
111
+
112
+ `BetaCalendars.year` contains all twelve month objects. Indexing is one-based,
113
+ so `year[1]` is January and `year[12]` is December.
114
+
115
+ ```ruby
116
+ calendar_year = BetaCalendars.year(year: 2027, week_start: :monday)
117
+ calendar_year.months.length # => 12
118
+ calendar_year[1].month_name # => "January"
119
+ calendar_year[12].month_name # => "December"
120
+ calendar_year.to_h
121
+ calendar_year.to_json
122
+ ```
123
+
124
+ ## Blank Calendar Grids
125
+
126
+ `blank_grid` creates an empty seven-column planning structure with weekday
127
+ labels and row/column metadata. It does not assign dates to the cells.
128
+
129
+ ```ruby
130
+ planning_grid = BetaCalendars.blank_grid(rows: 6, week_start: :sunday)
131
+ planning_grid.weeks.length # => 6
132
+ planning_grid.weeks.first.length # => 7
133
+ planning_grid.weeks.first.first.to_h
134
+ # => {:row=>0, :column=>0, :weekday=>"Sunday"}
135
+ ```
136
+
137
+ For a printable blank calendar example, see the [Beta Calendars blank
138
+ calendar](https://www.betacalendars.com/blank-calendar). The returned grid is
139
+ generic and can be used in unrelated planning applications.
140
+
141
+ ## JSON Output
142
+
143
+ All calendar value objects implement `to_h` and `to_json`. Dates serialize as
144
+ ISO-8601 strings such as `"2027-01-01"`; ISO week and week-year are separate
145
+ integer fields, so year-boundary meaning is preserved.
146
+
147
+ ```ruby
148
+ require "json"
149
+
150
+ calendar = BetaCalendars.month(year: 2027, month: 1, week_start: :monday)
151
+ json = calendar.to_json
152
+ parsed = JSON.parse(json)
153
+ parsed.fetch("weeks").first
154
+ ```
155
+
156
+ You can also get the plain two-dimensional cell array directly:
157
+
158
+ ```ruby
159
+ matrix = BetaCalendars.month_matrix(
160
+ year: 2027,
161
+ month: 1,
162
+ week_start: :monday,
163
+ adjacent_dates: false
164
+ )
165
+ ```
166
+
167
+ ## CLI
168
+
169
+ The installed `beta-calendars` executable offers readable terminal output or
170
+ JSON. Options may follow the command arguments.
171
+
172
+ ```sh
173
+ beta-calendars month 2027 1
174
+ beta-calendars month 2027 1 --week-start monday
175
+ beta-calendars month 2027 1 --json
176
+ beta-calendars month 2027 2 --fixed-six-weeks --no-adjacent-dates
177
+ beta-calendars year 2027 --json
178
+ beta-calendars blank --rows 6 --week-start sunday
179
+ beta-calendars info 2027
180
+ ```
181
+
182
+ ## Calendar Correctness
183
+
184
+ The implementation uses Ruby's `Date` with the proleptic Gregorian calendar
185
+ for month length, weekday placement, and ISO-8601 week calculations. It treats
186
+ calendar dates as date-only values: there are no time zones, daylight-saving
187
+ transitions, or remote data sources in the calculation path. Supported years
188
+ are 1 through 9999.
189
+
190
+ ```ruby
191
+ BetaCalendars.leap_year?(2024) # => true
192
+ BetaCalendars.days_in_month(2027, 2) # => 28
193
+ BetaCalendars.iso_week(Date.new(2021, 1, 1)).to_h # => {:week=>53, :year=>2020}
194
+ ```
195
+
196
+ ## Printing and Presentation
197
+
198
+ This library produces calendar data models. Your application chooses how to
199
+ render them as HTML, PDF, images, terminal output, or another format. For
200
+ printable calendar examples and planning resources, see
201
+ [Beta Calendars](https://www.betacalendars.com/).
202
+
203
+ ## Related resources
204
+
205
+ - [Monthly calendar examples](https://www.betacalendars.com/monthly-calendar) show the month-grid use case supported by `BetaCalendars.month`.
206
+ - [Monthly planner pages](https://www.betacalendars.com/monthly-planner) are a related planning use case for the library's blank grids.
207
+
208
+ ## Development
209
+
210
+ ```sh
211
+ bundle install
212
+ bundle exec rake test
213
+ gem build beta_calendars.gemspec
214
+ ```
215
+
216
+ The test suite covers leap years, month geometry, both week starts, adjacent
217
+ dates, ISO week-year boundaries, fixed print grids, blank grids, JSON output,
218
+ and the executable.
219
+
220
+ ## License
221
+
222
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rake/testtask"
4
+ require "bundler/gem_tasks"
5
+
6
+ Rake::TestTask.new(:test) do |task|
7
+ task.libs << "test"
8
+ task.pattern = "test/**/*_test.rb"
9
+ end
10
+
11
+ task default: :test
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/beta_calendars/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "beta_calendars"
7
+ spec.version = BetaCalendars::VERSION
8
+ spec.authors = ["Mateo Pedersen"]
9
+ spec.email = []
10
+
11
+ spec.summary = "Deterministic Ruby calendar grids for monthly, yearly and printable calendar applications."
12
+ spec.description = <<~DESCRIPTION
13
+ Beta Calendars Ruby generates Gregorian month and year calendar structures,
14
+ including configurable Sunday or Monday week starts, adjacent-month cells,
15
+ ISO week metadata, fixed six-week print grids, blank planning grids, JSON
16
+ serialization, and a small command-line interface. All calculations run
17
+ locally using Ruby's standard Date library.
18
+ DESCRIPTION
19
+ spec.homepage = "https://www.betacalendars.com/"
20
+ spec.license = "MIT"
21
+ spec.required_ruby_version = ">= 3.1"
22
+
23
+ spec.metadata = {
24
+ "homepage_uri" => spec.homepage,
25
+ "source_code_uri" => "https://github.com/mateopedersen/beta-calendars-ruby",
26
+ "documentation_uri" => "https://github.com/mateopedersen/beta-calendars-ruby#readme",
27
+ "changelog_uri" => "https://github.com/mateopedersen/beta-calendars-ruby/blob/main/CHANGELOG.md",
28
+ "bug_tracker_uri" => "https://github.com/mateopedersen/beta-calendars-ruby/issues",
29
+ "rubygems_mfa_required" => "true"
30
+ }
31
+
32
+ # An explicit allowlist keeps local credentials, build products, and unrelated
33
+ # project files out of the published gem.
34
+ spec.files = %w[
35
+ CHANGELOG.md
36
+ CODE_OF_CONDUCT.md
37
+ CONTRIBUTING.md
38
+ LICENSE.txt
39
+ README.md
40
+ Rakefile
41
+ beta_calendars.gemspec
42
+ exe/beta-calendars
43
+ lib/beta_calendars.rb
44
+ lib/beta_calendars/blank_grid.rb
45
+ lib/beta_calendars/cli.rb
46
+ lib/beta_calendars/day_cell.rb
47
+ lib/beta_calendars/errors.rb
48
+ lib/beta_calendars/iso_week.rb
49
+ lib/beta_calendars/month.rb
50
+ lib/beta_calendars/serializable.rb
51
+ lib/beta_calendars/version.rb
52
+ lib/beta_calendars/year.rb
53
+ ]
54
+ spec.bindir = "exe"
55
+ spec.executables = ["beta-calendars"]
56
+ spec.require_paths = ["lib"]
57
+ spec.add_development_dependency "minitest", ">= 5.0", "< 6"
58
+ spec.add_development_dependency "rake", ">= 12.3", "< 14"
59
+ end
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "beta_calendars"
5
+
6
+ exit(BetaCalendars::CLI.run(ARGV))
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ # A position in a date-free planning grid.
5
+ class BlankCell
6
+ include Serializable
7
+
8
+ attr_reader :row, :column, :weekday
9
+
10
+ def initialize(row:, column:, weekday:)
11
+ @row = row
12
+ @column = column
13
+ @weekday = weekday
14
+ freeze
15
+ end
16
+
17
+ def to_h
18
+ { row: row, column: column, weekday: weekday }
19
+ end
20
+ end
21
+
22
+ # A blank rows-by-seven structure ready for a planner or printable renderer.
23
+ class BlankGrid
24
+ include Serializable
25
+
26
+ attr_reader :rows, :week_start, :weekdays, :weeks
27
+
28
+ def initialize(rows:, week_start:)
29
+ unless rows.is_a?(Integer) && rows.between?(1, 60)
30
+ raise InvalidGridSizeError, "rows must be an integer from 1 to 60"
31
+ end
32
+
33
+ @rows = rows
34
+ @week_start = CalendarValidation.normalize_week_start(week_start)
35
+ @weekdays = Month::WEEKDAY_NAMES.rotate(week_start == :sunday ? 0 : 1).freeze
36
+ @weeks = rows.times.map do |row|
37
+ 7.times.map do |column|
38
+ BlankCell.new(row: row, column: column, weekday: weekdays[column])
39
+ end.freeze
40
+ end.freeze
41
+ freeze
42
+ end
43
+
44
+ alias grid weeks
45
+
46
+ def to_h
47
+ {
48
+ rows: rows,
49
+ columns: 7,
50
+ week_start: week_start,
51
+ weekdays: weekdays,
52
+ weeks: weeks.map { |week| week.map(&:to_h).freeze }.freeze
53
+ }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module BetaCalendars
6
+ # Small standard-library command-line interface.
7
+ module CLI
8
+ module_function
9
+
10
+ def run(arguments, out: $stdout, err: $stderr)
11
+ args = arguments.dup
12
+ command = args.shift
13
+ return help(out) if command.nil? || %w[help --help -h].include?(command)
14
+
15
+ options = { week_start: :sunday, adjacent_dates: true, fixed_six_weeks: false, json: false, rows: 6 }
16
+ parser = option_parser(options)
17
+ parser.parse!(args)
18
+
19
+ case command
20
+ when "month"
21
+ run_month(args, options, out)
22
+ when "year"
23
+ run_year(args, options, out)
24
+ when "blank"
25
+ run_blank(args, options, out)
26
+ when "info"
27
+ run_info(args, options, out)
28
+ else
29
+ raise OptionParser::InvalidArgument, "unknown command #{command.inspect}"
30
+ end
31
+ 0
32
+ rescue OptionParser::ParseError, ArgumentError, BetaCalendars::Error => error
33
+ err.puts("beta-calendars: #{error.message}")
34
+ 2
35
+ end
36
+
37
+ def option_parser(options)
38
+ OptionParser.new do |parser|
39
+ parser.banner = "Usage: beta-calendars COMMAND [arguments] [options]"
40
+ parser.on("--week-start START", "Week start: monday or sunday") { |value| options[:week_start] = value.to_sym }
41
+ parser.on("--fixed-six-weeks", "Force monthly grids to six rows") { options[:fixed_six_weeks] = true }
42
+ parser.on("--no-adjacent-dates", "Leave out-of-month cells empty") { options[:adjacent_dates] = false }
43
+ parser.on("--rows COUNT", Integer, "Blank grid rows (1-60)") { |value| options[:rows] = value }
44
+ parser.on("--json", "Print JSON") { options[:json] = true }
45
+ parser.on("-h", "--help", "Show this help") { raise OptionParser::InvalidArgument, parser.to_s }
46
+ end
47
+ end
48
+
49
+ def run_month(args, options, out)
50
+ require_count!(args, 2, "month YEAR MONTH")
51
+ calendar = BetaCalendars.month(
52
+ year: integer!(args[0], "year"),
53
+ month: integer!(args[1], "month"),
54
+ week_start: options[:week_start],
55
+ adjacent_dates: options[:adjacent_dates],
56
+ fixed_six_weeks: options[:fixed_six_weeks]
57
+ )
58
+ options[:json] ? out.puts(calendar.to_json) : print_month(calendar, out)
59
+ end
60
+
61
+ def run_year(args, options, out)
62
+ require_count!(args, 1, "year YEAR")
63
+ calendar = BetaCalendars.year(
64
+ year: integer!(args[0], "year"),
65
+ week_start: options[:week_start],
66
+ adjacent_dates: options[:adjacent_dates],
67
+ fixed_six_weeks: options[:fixed_six_weeks]
68
+ )
69
+ if options[:json]
70
+ out.puts(calendar.to_json)
71
+ else
72
+ out.puts("#{calendar.year} Calendar (weeks start #{calendar.week_start})")
73
+ calendar.months.each { |month| print_month(month, out) }
74
+ end
75
+ end
76
+
77
+ def run_blank(args, options, out)
78
+ require_count!(args, 0, "blank")
79
+ grid = BetaCalendars.blank_grid(rows: options[:rows], week_start: options[:week_start])
80
+ if options[:json]
81
+ out.puts(grid.to_json)
82
+ else
83
+ out.puts(grid.weekdays.map { |name| name[0, 2] }.join(" "))
84
+ grid.weeks.each { out.puts(Array.new(7, " ").join(" ")) }
85
+ end
86
+ end
87
+
88
+ def run_info(args, options, out)
89
+ require_count!(args, 1, "info YEAR")
90
+ year = integer!(args[0], "year")
91
+ info = {
92
+ year: year,
93
+ leap_year: BetaCalendars.leap_year?(year),
94
+ months: (1..12).map do |month_number|
95
+ { month: month_number, name: Date::MONTHNAMES[month_number], days: BetaCalendars.days_in_month(year, month_number) }
96
+ end
97
+ }
98
+ options[:json] ? out.puts(JSON.generate(info)) : print_info(info, out)
99
+ end
100
+
101
+ def print_month(calendar, out)
102
+ out.puts("#{calendar.month_name} #{calendar.year}")
103
+ out.puts(calendar.weekdays.map { |name| name[0, 2] }.join(" "))
104
+ calendar.weeks.each do |week|
105
+ out.puts(week.map { |cell| cell ? format("%2d", cell.day) : " " }.join(" "))
106
+ end
107
+ out.puts
108
+ end
109
+
110
+ def print_info(info, out)
111
+ out.puts("#{info[:year]}: #{info[:leap_year] ? 'leap year' : 'common year'}")
112
+ info[:months].each { |month| out.puts(format("%-9s %2d days", month[:name], month[:days])) }
113
+ end
114
+
115
+ def integer!(value, label)
116
+ Integer(value, 10)
117
+ rescue ArgumentError
118
+ raise OptionParser::InvalidArgument, "#{label} must be an integer"
119
+ end
120
+
121
+ def require_count!(args, expected, usage)
122
+ return if args.length == expected
123
+
124
+ raise OptionParser::InvalidArgument, "expected #{usage}"
125
+ end
126
+
127
+ def help(out)
128
+ out.puts <<~HELP
129
+ Usage: beta-calendars COMMAND [arguments] [options]
130
+
131
+ Commands:
132
+ month YEAR MONTH Print one month grid
133
+ year YEAR Print all twelve months
134
+ blank Print a blank planning grid
135
+ info YEAR Show leap-year and month-length information
136
+
137
+ Options:
138
+ --week-start monday|sunday
139
+ --fixed-six-weeks
140
+ --no-adjacent-dates
141
+ --rows COUNT Blank grid rows (1-60)
142
+ --json
143
+ HELP
144
+ 0
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ # Immutable metadata for one real Gregorian date in a month grid.
5
+ class DayCell
6
+ include Serializable
7
+
8
+ attr_reader :date, :day, :year, :month, :weekday, :iso_weekday,
9
+ :iso_week, :iso_week_year, :in_current_month, :weekend
10
+
11
+ def initialize(date:, current_year:, current_month:)
12
+ @date = date
13
+ @day = date.day
14
+ @year = date.year
15
+ @month = date.month
16
+ @weekday = date.wday # Sunday = 0, matching Ruby Date#wday.
17
+ @iso_weekday = date.cwday # Monday = 1 through Sunday = 7.
18
+ @iso_week = date.cweek
19
+ @iso_week_year = date.cwyear
20
+ @in_current_month = (date.year == current_year && date.month == current_month)
21
+ @weekend = (date.wday == 0 || date.wday == 6)
22
+ freeze
23
+ end
24
+
25
+ def to_h
26
+ {
27
+ date: date.iso8601,
28
+ day: day,
29
+ year: year,
30
+ month: month,
31
+ weekday: weekday,
32
+ iso_weekday: iso_weekday,
33
+ iso_week: iso_week,
34
+ iso_week_year: iso_week_year,
35
+ in_current_month: in_current_month,
36
+ weekend: weekend
37
+ }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ class Error < StandardError; end
5
+
6
+ class InvalidWeekStartError < ArgumentError
7
+ def initialize(value)
8
+ super("week_start must be :monday or :sunday (got #{value.inspect})")
9
+ end
10
+ end
11
+
12
+ class InvalidCalendarDateError < ArgumentError; end
13
+ class InvalidGridSizeError < ArgumentError; end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ # ISO-8601 week number and the week-based year that owns it.
5
+ class ISOWeek
6
+ include Serializable
7
+
8
+ attr_reader :week, :year
9
+ alias iso_year year
10
+
11
+ def initialize(week:, year:)
12
+ @week = week
13
+ @year = year
14
+ freeze
15
+ end
16
+
17
+ def to_h
18
+ { week: week, year: year }
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ # Calendar grid and date metadata for one Gregorian month.
5
+ class Month
6
+ include Serializable
7
+
8
+ WEEKDAY_NAMES = %w[Sunday Monday Tuesday Wednesday Thursday Friday Saturday].freeze
9
+
10
+ attr_reader :year, :month, :week_start, :adjacent_dates, :fixed_six_weeks,
11
+ :days_in_month, :weeks, :weekdays
12
+
13
+ def initialize(year:, month:, week_start:, adjacent_dates:, fixed_six_weeks:)
14
+ CalendarValidation.validate_year!(year)
15
+ CalendarValidation.validate_month!(month)
16
+ @year = year
17
+ @month = month
18
+ @week_start = CalendarValidation.normalize_week_start(week_start)
19
+ @adjacent_dates = !!adjacent_dates
20
+ @fixed_six_weeks = !!fixed_six_weeks
21
+ @days_in_month = Date.new(year, month, -1, Date::GREGORIAN).day
22
+ @weekdays = ordered_weekday_names.freeze
23
+ @weeks = build_weeks.freeze
24
+ freeze
25
+ end
26
+
27
+ def month_name
28
+ Date::MONTHNAMES.fetch(month)
29
+ end
30
+
31
+ # Returns one immutable cell for each day in this month, excluding adjacent dates.
32
+ def days
33
+ (1..days_in_month).map do |day|
34
+ DayCell.new(date: Date.new(year, month, day, Date::GREGORIAN), current_year: year, current_month: month)
35
+ end.freeze
36
+ end
37
+
38
+ alias matrix weeks
39
+
40
+ def to_h
41
+ {
42
+ year: year,
43
+ month: month,
44
+ month_name: month_name,
45
+ week_start: week_start,
46
+ adjacent_dates: adjacent_dates,
47
+ fixed_six_weeks: fixed_six_weeks,
48
+ weekdays: weekdays,
49
+ weeks: weeks.map { |week| week.map { |cell| cell&.to_h }.freeze }.freeze
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def ordered_weekday_names
56
+ start = (week_start == :sunday ? 0 : 1)
57
+ 7.times.map { |offset| WEEKDAY_NAMES[(start + offset) % 7] }
58
+ end
59
+
60
+ def build_weeks
61
+ first_date = Date.new(year, month, 1, Date::GREGORIAN)
62
+ start_weekday = week_start == :sunday ? 0 : 1
63
+ leading = (first_date.wday - start_weekday) % 7
64
+ row_count = fixed_six_weeks ? 6 : ((leading + days_in_month + 6) / 7)
65
+ grid_start = first_date - leading
66
+
67
+ row_count.times.map do |row|
68
+ 7.times.map do |column|
69
+ date = grid_start + (row * 7) + column
70
+ if adjacent_dates || (date.year == year && date.month == month)
71
+ DayCell.new(date: date, current_year: year, current_month: month)
72
+ end
73
+ end.freeze
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ module Serializable
5
+ def to_json(*arguments)
6
+ JSON.generate(to_h, *arguments)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetaCalendars
4
+ # Twelve month value objects for one Gregorian calendar year.
5
+ class Year
6
+ include Serializable
7
+
8
+ attr_reader :year, :week_start, :adjacent_dates, :fixed_six_weeks, :months
9
+
10
+ def initialize(year:, week_start:, adjacent_dates:, fixed_six_weeks:)
11
+ CalendarValidation.validate_year!(year)
12
+ @year = year
13
+ @week_start = CalendarValidation.normalize_week_start(week_start)
14
+ @adjacent_dates = !!adjacent_dates
15
+ @fixed_six_weeks = !!fixed_six_weeks
16
+ @months = (1..12).map do |month_number|
17
+ Month.new(
18
+ year: year,
19
+ month: month_number,
20
+ week_start: @week_start,
21
+ adjacent_dates: @adjacent_dates,
22
+ fixed_six_weeks: @fixed_six_weeks
23
+ )
24
+ end.freeze
25
+ freeze
26
+ end
27
+
28
+ # Month numbers are one-based: year[1] is January and year[12] is December.
29
+ def [](month_number)
30
+ unless month_number.is_a?(Integer) && month_number.between?(1, 12)
31
+ raise IndexError, "month number must be between 1 and 12"
32
+ end
33
+
34
+ months.fetch(month_number - 1)
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ year: year,
40
+ week_start: week_start,
41
+ adjacent_dates: adjacent_dates,
42
+ fixed_six_weeks: fixed_six_weeks,
43
+ months: months.map(&:to_h).freeze
44
+ }
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+
6
+ require_relative "beta_calendars/version"
7
+ require_relative "beta_calendars/errors"
8
+ require_relative "beta_calendars/serializable"
9
+
10
+ module BetaCalendars
11
+ module CalendarValidation
12
+ module_function
13
+
14
+ def validate_year!(year)
15
+ unless year.is_a?(Integer) && year.between?(1, 9999)
16
+ raise InvalidCalendarDateError, "year must be an integer from 1 to 9999"
17
+ end
18
+ end
19
+
20
+ def validate_month!(month)
21
+ unless month.is_a?(Integer) && month.between?(1, 12)
22
+ raise InvalidCalendarDateError, "month must be an integer from 1 to 12"
23
+ end
24
+ end
25
+
26
+ def normalize_week_start(value)
27
+ normalized = value.respond_to?(:to_sym) ? value.to_sym : value
28
+ return normalized if %i[monday sunday].include?(normalized)
29
+
30
+ raise InvalidWeekStartError, value
31
+ end
32
+ end
33
+ end
34
+
35
+ require_relative "beta_calendars/day_cell"
36
+ require_relative "beta_calendars/month"
37
+ require_relative "beta_calendars/year"
38
+ require_relative "beta_calendars/blank_grid"
39
+ require_relative "beta_calendars/iso_week"
40
+ require_relative "beta_calendars/cli"
41
+
42
+ module BetaCalendars
43
+ class << self
44
+ # Build a local Gregorian month grid. Out-of-month cells contain adjacent
45
+ # dates by default; pass adjacent_dates: false to use nil cells instead.
46
+ # @param year [Integer] Gregorian year from 1 through 9999
47
+ # @param month [Integer] month number from 1 through 12
48
+ # @param week_start [Symbol, String] :monday or :sunday
49
+ # @param adjacent_dates [Boolean] populate cells from neighboring months
50
+ # @param fixed_six_weeks [Boolean] always create six rows
51
+ # @return [BetaCalendars::Month]
52
+ def month(year:, month:, week_start: :sunday, adjacent_dates: true, fixed_six_weeks: false)
53
+ Month.new(
54
+ year: year,
55
+ month: month,
56
+ week_start: week_start,
57
+ adjacent_dates: adjacent_dates,
58
+ fixed_six_weeks: fixed_six_weeks
59
+ )
60
+ end
61
+
62
+ # Build twelve monthly calendars for the selected Gregorian year.
63
+ # @param year [Integer] Gregorian year from 1 through 9999
64
+ # @param week_start [Symbol, String] :monday or :sunday
65
+ # @param adjacent_dates [Boolean] populate cells from neighboring months
66
+ # @param fixed_six_weeks [Boolean] always create six rows per month
67
+ # @return [BetaCalendars::Year]
68
+ def year(year:, week_start: :sunday, adjacent_dates: true, fixed_six_weeks: false)
69
+ Year.new(
70
+ year: year,
71
+ week_start: week_start,
72
+ adjacent_dates: adjacent_dates,
73
+ fixed_six_weeks: fixed_six_weeks
74
+ )
75
+ end
76
+
77
+ # Build an empty printable planning grid with seven weekday columns.
78
+ # @param rows [Integer] row count from 1 through 60
79
+ # @param week_start [Symbol, String] :monday or :sunday
80
+ # @return [BetaCalendars::BlankGrid]
81
+ def blank_grid(rows: 6, week_start: :sunday)
82
+ BlankGrid.new(rows: rows, week_start: week_start)
83
+ end
84
+
85
+ # Return whether a Gregorian year has 366 days.
86
+ # @param year [Integer] Gregorian year from 1 through 9999
87
+ # @return [Boolean]
88
+ def leap_year?(year)
89
+ CalendarValidation.validate_year!(year)
90
+ (year % 400).zero? || ((year % 4).zero? && !(year % 100).zero?)
91
+ end
92
+
93
+ # Return the number of days in a Gregorian month.
94
+ # @param year [Integer] Gregorian year from 1 through 9999
95
+ # @param month [Integer] month number from 1 through 12
96
+ # @return [Integer]
97
+ def days_in_month(year, month)
98
+ CalendarValidation.validate_year!(year)
99
+ CalendarValidation.validate_month!(month)
100
+ Date.new(year, month, -1, Date::GREGORIAN).day
101
+ end
102
+
103
+ # Return the ISO week and ISO week-year for a Date, or for year/month/day.
104
+ # @param date_or_year [Date, Integer] a Date or Gregorian year
105
+ # @param month [Integer, nil] month number when a year is supplied
106
+ # @param day [Integer, nil] day of month when a year is supplied
107
+ # @return [BetaCalendars::ISOWeek]
108
+ def iso_week(date_or_year, month = nil, day = nil)
109
+ date = if date_or_year.is_a?(Date) && month.nil? && day.nil?
110
+ date_or_year
111
+ elsif date_or_year.is_a?(Integer) && month.is_a?(Integer) && day.is_a?(Integer)
112
+ CalendarValidation.validate_year!(date_or_year)
113
+ CalendarValidation.validate_month!(month)
114
+ unless Date.valid_date?(date_or_year, month, day, Date::GREGORIAN)
115
+ raise InvalidCalendarDateError, "day must be valid for the selected year and month"
116
+ end
117
+ Date.new(date_or_year, month, day, Date::GREGORIAN)
118
+ else
119
+ raise ArgumentError, "provide a Date or integer year, month, and day"
120
+ end
121
+ ISOWeek.new(week: date.cweek, year: date.cwyear)
122
+ end
123
+
124
+ # Return the same metadata-rich two-dimensional grid exposed by Month#weeks.
125
+ # @return [Array<Array<BetaCalendars::DayCell, nil>>]
126
+ def month_matrix(year:, month:, week_start: :sunday, adjacent_dates: true, fixed_six_weeks: false)
127
+ self.month(
128
+ year: year,
129
+ month: month,
130
+ week_start: week_start,
131
+ adjacent_dates: adjacent_dates,
132
+ fixed_six_weeks: fixed_six_weeks
133
+ ).weeks
134
+ end
135
+ end
136
+ end
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: beta_calendars
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Mateo Pedersen
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '6'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '5.0'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '6'
32
+ - !ruby/object:Gem::Dependency
33
+ name: rake
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '12.3'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '14'
42
+ type: :development
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '12.3'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '14'
52
+ description: |
53
+ Beta Calendars Ruby generates Gregorian month and year calendar structures,
54
+ including configurable Sunday or Monday week starts, adjacent-month cells,
55
+ ISO week metadata, fixed six-week print grids, blank planning grids, JSON
56
+ serialization, and a small command-line interface. All calculations run
57
+ locally using Ruby's standard Date library.
58
+ email: []
59
+ executables:
60
+ - beta-calendars
61
+ extensions: []
62
+ extra_rdoc_files: []
63
+ files:
64
+ - CHANGELOG.md
65
+ - CODE_OF_CONDUCT.md
66
+ - CONTRIBUTING.md
67
+ - LICENSE.txt
68
+ - README.md
69
+ - Rakefile
70
+ - beta_calendars.gemspec
71
+ - exe/beta-calendars
72
+ - lib/beta_calendars.rb
73
+ - lib/beta_calendars/blank_grid.rb
74
+ - lib/beta_calendars/cli.rb
75
+ - lib/beta_calendars/day_cell.rb
76
+ - lib/beta_calendars/errors.rb
77
+ - lib/beta_calendars/iso_week.rb
78
+ - lib/beta_calendars/month.rb
79
+ - lib/beta_calendars/serializable.rb
80
+ - lib/beta_calendars/version.rb
81
+ - lib/beta_calendars/year.rb
82
+ homepage: https://www.betacalendars.com/
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://www.betacalendars.com/
87
+ source_code_uri: https://github.com/mateopedersen/beta-calendars-ruby
88
+ documentation_uri: https://github.com/mateopedersen/beta-calendars-ruby#readme
89
+ changelog_uri: https://github.com/mateopedersen/beta-calendars-ruby/blob/main/CHANGELOG.md
90
+ bug_tracker_uri: https://github.com/mateopedersen/beta-calendars-ruby/issues
91
+ rubygems_mfa_required: 'true'
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '3.1'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 4.0.20
107
+ specification_version: 4
108
+ summary: Deterministic Ruby calendar grids for monthly, yearly and printable calendar
109
+ applications.
110
+ test_files: []