ruby-temporal 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: ca52af9ee124f93b4d3992cd1a0b05cc3d7c20299dad22cbf47b711c94765bd1
4
+ data.tar.gz: ec3a36d0b0714f24751b0eb416ce2b5fc94e9969735dea98658848dfc67ec768
5
+ SHA512:
6
+ metadata.gz: 0ee0b1ea084f251371a3e5fd4e0d075f85de4dab8c960a8742d8362cdd5b1d110b9cf866054c0f5b9cd5c7ffc0927ec63d7e46affc76756c97cbae1c3775a561
7
+ data.tar.gz: '03787383471ade7b8e79b1531942b6d0cd43501bfd87350e3582f32bb10127d6e3cdee24be19cef09acde1e38953239ddb1d1c444da747ee32c03db06b091766'
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-12-04
4
+
5
+ - Create Temporal base classes with only constructor definition. Other methods will be defined in later releases.
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "ruby-temporal" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["leonardoprado@gmail.com"](mailto:"leonardoprado@gmail.com").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Leonardo Prado
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Ruby Temporal
2
+
3
+ This gem is a implementation of TC39 Temporal API.
4
+
5
+ ## Specification
6
+
7
+ This gem aims to be a direct implementation from the original spec, with only small changes to adapt it to Ruby sinatx and idioms.
8
+
9
+ To achieve this, we are using as a base all specs from [test262 repo](https://github.com/tc39/test262). We converted their logic from JavaScript to Ruby using minitest with the exception of languiage specific tests, like `prototype`, for example.
10
+
11
+ The tests are being created using commit `d8d4c064` as reference. Changes from later commits will be added after the initial implementation is completed.
12
+
13
+ The planned tests are the following:
14
+
15
+ | Original test path | Path on gem tests|
16
+ | --- | --- |
17
+ | test/built-ins/Temporal | test/test262/built-ins/ |
18
+ | test/intl402/Temporal | test/test262/intl402/ |
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ bundle add temporal
24
+ # or
25
+ gem install temporal
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ Instructions will be made after the API is defined and implemented.
31
+
32
+ The official usage reference from original implementation can be found on [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal)
33
+
34
+ ## Development
35
+
36
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` or `minitest` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
37
+
38
+ The objective of initial development is to implement all test262 specs. We are doing it using TDD, without specific focus on being idiomatic, performant or DRY. Those are reserved for the next step in the project.
39
+
40
+ ## Contributing
41
+
42
+ Bug reports and pull requests are welcome on GitHub at https://github.com/DNA/ruby-temporal. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/DNA/ruby-temporal/blob/main/CODE_OF_CONDUCT.md).
43
+
44
+ ## License
45
+
46
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
47
+
48
+ ## Code of Conduct
49
+
50
+ Everyone interacting in the ruby-temporal project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/DNA/ruby-temporal/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class Duration
5
+ UNIT_LIMITS = {
6
+ year: 4_294_967_296, # 2**32
7
+ month: 4_294_967_296, # 2**32
8
+ week: 4_294_967_296, # 2**32
9
+ days: 104_249_991_375, # (2**53 / 86_400.0).ceil | 2**53 is based
10
+ hours: 2_501_999_792_984, # (2**53 / 86_400.0).ceil | on EcmaScript
11
+ minutes: 150_119_987_579_017, # (2**53 / 86_400.0).ceil | Number.MAX_SAFE_INTEGER
12
+ seconds: 9_007_199_254_740_992, # 2**53
13
+ }.freeze
14
+
15
+ UNITS = %i[years months days
16
+ hours minutes seconds weeks
17
+ milliseconds microseconds nanoseconds].freeze
18
+
19
+ attr_reader(*UNITS)
20
+
21
+ def initialize(years = nil, months = nil, weeks = nil, days = nil,
22
+ hours = nil, minutes = nil, seconds = nil,
23
+ milliseconds = nil, microseconds = nil, nanoseconds = nil)
24
+ values = [years, months, weeks, days,
25
+ hours, minutes, seconds,
26
+ milliseconds, microseconds, nanoseconds]
27
+ values.map! do |v|
28
+ case v
29
+ in 0 then nil
30
+ in String then v.to_i
31
+ in Numeric then v
32
+ else nil
33
+ end
34
+ end
35
+
36
+ values.uniq!.compact!
37
+
38
+ unless values.all?(&:positive?) || values.all?(&:negative?)
39
+ raise RangeError, "Can't mix positive and negative numbers"
40
+ end
41
+
42
+ self.years = years
43
+ self.months = months
44
+ self.weeks = weeks
45
+ self.days = days
46
+ self.hours = hours
47
+ self.minutes = minutes
48
+ self.seconds = seconds
49
+ self.milliseconds = milliseconds
50
+ self.microseconds = microseconds
51
+ self.nanoseconds = nanoseconds
52
+
53
+ overboard
54
+ end
55
+
56
+ def total(unit)
57
+ if unit == :seconds
58
+ (days * 86_400) +
59
+ (hours * 3600) +
60
+ (minutes * 60) +
61
+ seconds +
62
+ (((milliseconds * 1_000_000) +
63
+ (microseconds * 1_000) +
64
+ nanoseconds
65
+ ) / 1_000_000_000.0)
66
+ else
67
+ 0
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def overboard
74
+ overboard_unit(:nanoseconds, :microseconds, 1000)
75
+ overboard_unit(:microseconds, :milliseconds, 1000)
76
+ overboard_unit(:milliseconds, :seconds, 1000)
77
+ overboard_unit(:seconds, :minutes, 60)
78
+ overboard_unit(:minutes, :hours, 60)
79
+ overboard_unit(:hours, :days, 24)
80
+ end
81
+
82
+ def overboard_unit(from, to, mod)
83
+ from_value = send(from)
84
+ to_value = send(to)
85
+ carry, value = from_value.abs.divmod(mod)
86
+
87
+ if from_value.negative?
88
+ value = -value
89
+ carry = -carry
90
+ end
91
+
92
+ send(:"#{from}=", value)
93
+ send(:"#{to}=", to_value + carry) unless carry.zero?
94
+ end
95
+
96
+ def years=(value)
97
+ @years = convert_type(value, 4_294_967_296)
98
+ end
99
+
100
+ def months=(value)
101
+ @months = convert_type(value, 4_294_967_296)
102
+ end
103
+
104
+ def weeks=(value)
105
+ @weeks = convert_type(value, 4_294_967_296)
106
+ end
107
+
108
+ def days=(value)
109
+ @days = convert_type(value, 104_249_991_375)
110
+ end
111
+
112
+ def hours=(value)
113
+ @hours = convert_type(value, 2_501_999_792_984)
114
+ end
115
+
116
+ def minutes=(value)
117
+ @minutes = convert_type(value, 150_119_987_579_017)
118
+ end
119
+
120
+ def seconds=(value)
121
+ @seconds = convert_type(value, 9_007_199_254_740_992)
122
+ end
123
+
124
+ def milliseconds=(value)
125
+ @milliseconds = convert_type(value)
126
+ end
127
+
128
+ def microseconds=(value)
129
+ @microseconds = convert_type(value)
130
+ end
131
+
132
+ def nanoseconds=(value)
133
+ @nanoseconds = convert_type(value)
134
+ end
135
+
136
+ def convert_type(value, limit = nil)
137
+ value = case value
138
+ in Float
139
+ raise RangeError, "Can't use fractional values" unless value == value.truncate
140
+
141
+ value.to_i
142
+ in Numeric then value.to_i
143
+ in NilClass then 0
144
+ in String then Integer(value)
145
+ else raise TypeError
146
+ end
147
+
148
+ if limit
149
+ raise RangeError if value >= limit
150
+ raise RangeError if value <= -limit
151
+ end
152
+
153
+ value
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Temporal
6
+ MAX_TIMESTAMP = 8_640_000_000_000_000_000
7
+
8
+ class Instant
9
+ def initialize(timestamp)
10
+ self.timestamp = timestamp
11
+ end
12
+
13
+ def epoch_milliseconds = (@timestamp / 1_000_000_000).truncate
14
+ def epoch_nanoseconds = @timestamp / 1_000
15
+
16
+ private
17
+
18
+ def timestamp=(value)
19
+ value = Float(value)
20
+
21
+ raise RangeError, "timestamp overflow" if value > MAX_TIMESTAMP
22
+ raise RangeError, "timestamp underflow" if value < -MAX_TIMESTAMP
23
+
24
+ @timestamp = value * 1_000
25
+ rescue TypeError, ArgumentError => e
26
+ raise TypeError, "Invalid string for timestamp", cause: e
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Temporal
6
+ class Now
7
+ def self.time_zone_id = "UTC"
8
+
9
+ def self.instant
10
+ Temporal::Instant.new(now.to_f)
11
+ end
12
+
13
+ def self.plain_date_iso(tz = nil)
14
+ t = now(tz)
15
+ Temporal::PlainDate.new(t.year, t.month, t.day)
16
+ end
17
+
18
+ def self.plain_date_time_iso(tz = nil)
19
+ t = now(tz)
20
+ Temporal::PlainDateTime.new(t.year, t.month, t.day, t.hour, t.min, t.sec)
21
+ end
22
+
23
+ def self.plain_time_iso(tz = nil)
24
+ t = now(tz)
25
+ Temporal::PlainTime.new(t.hour, t.min, t.sec)
26
+ end
27
+
28
+ def self.zoned_date_time_iso(tz = "UTC")
29
+ Temporal::ZonedDateTime.new(now(tz).to_f, tz: validate_timezone(tz))
30
+ end
31
+
32
+ def self.now(tz = nil)
33
+ if tz
34
+ Time.now(in: validate_timezone(tz))
35
+ else
36
+ Time.now
37
+ end
38
+ end
39
+
40
+ ISO_TZ_OFFSET = /[+-]\d\d:?\d\d/ # +12:34 -12:34 +1234 -1234
41
+ ENDLINE_OFFSET = /#{ISO_TZ_OFFSET}$/o # +12:34$ -12:34$ +1234$ -1234$
42
+ BRACKETED_OFFSET = /\[(#{ISO_TZ_OFFSET})\]/o # [+12:34] [-12:34] [+1234] [-1234]
43
+ IANA_TZ = /\[(\w+)\]/ # [ISO]
44
+
45
+ def self.validate_timezone(value)
46
+ value = value.downcase if value.respond_to? :downcase
47
+
48
+ case value
49
+ in "a".."i" | "k".."z"
50
+ value.upcase
51
+ in "utc" | "UTC" | /z$/
52
+ "UTC"
53
+ in IANA_TZ
54
+ value[IANA_TZ, 1]
55
+ in BRACKETED_OFFSET
56
+ value[BRACKETED_OFFSET, 1]
57
+ in ENDLINE_OFFSET
58
+ value[ENDLINE_OFFSET]
59
+ else
60
+ raise RangeError, "Invalid value `#{value}` for timezone"
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class PlainDate
5
+ attr_reader :year, :month, :day, :calendar_id
6
+
7
+ def initialize(year, month, day, calendar_id: nil)
8
+ @year = Units::Year.new(year)
9
+ @month = Units::Month.new(month, @year)
10
+ @day = Units::Day.new(day, @month)
11
+ @month.lower = @day
12
+ self.calendar_id = calendar_id
13
+ end
14
+
15
+ def month_code = :"#{format("M%02d", month)}"
16
+
17
+ def era_year = nil
18
+
19
+ def era = nil
20
+
21
+ def add(hash = {}, **kwargs)
22
+ raise ArgumentError unless hash.instance_of? Hash
23
+
24
+ values = hash.merge kwargs
25
+ raise ArgumentError if values.empty?
26
+
27
+ unit_keys = %i[years months weeks days hours minutes
28
+ seconds milliseconds microseconds nanoseconds]
29
+
30
+ units = values.slice(*unit_keys)
31
+
32
+ raise ArgumentError, "No valid unit found" if units.empty?
33
+
34
+ raise RangeError, "Can't mix positive and negative numbers" unless
35
+ units.values.reject(&:zero?).all?(&:positive?) ||
36
+ units.values.reject(&:zero?).all?(&:negative?)
37
+
38
+ @day += values[:days] || 0
39
+ @month += values[:months] || 0
40
+ @year += values[:years] || 0
41
+
42
+ self
43
+ end
44
+
45
+ def ==(other)
46
+ @year == other.year &&
47
+ @month == other.month &&
48
+ @day == other.day
49
+ end
50
+
51
+ private
52
+
53
+ def calendar_id=(value)
54
+ value = case value
55
+ in Symbol then value
56
+ in String then value.downcase.to_sym
57
+ in NilClass then :iso8601
58
+ else raise RangeError
59
+ end
60
+
61
+ raise RangeError if value != :iso8601
62
+
63
+ @calendar_id = value
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class PlainDateTime
5
+ def initialize(year, month, day, hour = 0, minute = 0, second = 0,
6
+ millisecond = 0, microsecond = 0, nanosecond = 0,
7
+ calendar_id: nil)
8
+ @date = PlainDate.new(year, month, day, calendar_id: calendar_id)
9
+ @time = PlainTime.new(hour, minute, second, millisecond, microsecond, nanosecond)
10
+ end
11
+
12
+ def year = @date.year
13
+ def month = @date.month
14
+ def day = @date.day
15
+ def era = @date.era
16
+ def era_year = @date.era_year
17
+ def month_code = @date.month_code
18
+
19
+ def hour = @time.hour
20
+ def minute = @time.minute
21
+ def second = @time.second
22
+ def millisecond = @time.millisecond
23
+ def microsecond = @time.microsecond
24
+ def nanosecond = @time.nanosecond
25
+ def calendar_id = @date.calendar_id
26
+ end
27
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class PlainMonthDay
5
+ attr_reader :month, :day, :calendar_id
6
+
7
+ IMPLICIT_YEAR = 1972
8
+
9
+ def initialize(month, day, year: nil, calendar_id: nil)
10
+ @year = (Units::Year.new(year) if year)
11
+ @month = Units::Month.new(month, @year)
12
+ @day = Units::Day.new(day, @month)
13
+ @month.lower = @day
14
+ self.calendar_id = calendar_id
15
+ end
16
+
17
+ def year
18
+ @year || IMPLICIT_YEAR
19
+ end
20
+
21
+ def month_code = :"#{format("M%02d", month)}"
22
+
23
+ def era = nil
24
+
25
+ def to_s(calendar_name: nil)
26
+ if calendar_name == :always
27
+ "#{year}-#{@month}-#{@day}[u-ca=#{calendar_id}]"
28
+ elsif year
29
+ "#{year}-#{@month}-#{@day}"
30
+ else
31
+ "#{@month}-#{@day}"
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def calendar_id=(value)
38
+ value = case value
39
+ in Symbol then value
40
+ in String then value.downcase.to_sym
41
+ in NilClass then :iso8601
42
+ else raise RangeError
43
+ end
44
+
45
+ raise RangeError if value != :iso8601
46
+
47
+ @calendar_id = value
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class PlainTime
5
+ attr_reader :hour, :minute, :second, :millisecond, :microsecond, :nanosecond
6
+
7
+ def initialize(hour = 0, minute = 0, second = 0,
8
+ millisecond = 0, microsecond = 0, nanosecond = 0)
9
+ self.hour = hour
10
+ self.minute = minute
11
+ self.second = second
12
+ self.millisecond = millisecond
13
+ self.microsecond = microsecond
14
+ self.nanosecond = nanosecond
15
+ end
16
+
17
+ def hour=(value)
18
+ @hour = case value
19
+ in (..-1)
20
+ raise RangeError, "Hour can't be negative"
21
+ in (24..)
22
+ raise RangeError, "Hour can't be bigger than 23"
23
+ else
24
+ value.to_i
25
+ end
26
+ end
27
+
28
+ def minute=(value)
29
+ @minute = case value
30
+ in (..-1)
31
+ raise RangeError, "Minute can't be negative"
32
+ in (59..)
33
+ raise RangeError, "Minute can't be bigger than 59"
34
+ else
35
+ value.to_i
36
+ end
37
+ end
38
+
39
+ def second=(value)
40
+ @second = case value
41
+ in (..-1)
42
+ raise RangeError, "Second can't be negative"
43
+ in (59..)
44
+ raise RangeError, "Second can't be bigger than 59"
45
+ else
46
+ value.to_i
47
+ end
48
+ end
49
+
50
+ def millisecond=(value)
51
+ @millisecond = case value
52
+ in (..-1)
53
+ raise RangeError, "Millisecond can't be negative"
54
+ in (999..)
55
+ raise RangeError, "Millisecond can't be bigger than 999"
56
+ else
57
+ value.to_i
58
+ end
59
+ end
60
+
61
+ def microsecond=(value)
62
+ @microsecond = case value
63
+ in (..-1)
64
+ raise RangeError, "Microsecond can't be negative"
65
+ in (999..)
66
+ raise RangeError, "Microsecond can't be bigger than 999"
67
+ else
68
+ value.to_i
69
+ end
70
+ end
71
+
72
+ def nanosecond=(value)
73
+ @nanosecond = case value
74
+ in (..-1)
75
+ raise RangeError, "Nanosecond can't be negative"
76
+ in (999..)
77
+ raise RangeError, "Nanosecond can't be bigger than 999"
78
+ else
79
+ value.to_i
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ class PlainYearMonth
5
+ attr_reader :year, :month, :calendar_id
6
+
7
+ IMPLICIT_DAY = 1
8
+
9
+ def initialize(year, month, day: nil, calendar_id: nil)
10
+ @year = Units::Year.new(year)
11
+ @month = Units::Month.new(month, @year)
12
+ self.calendar_id = calendar_id
13
+
14
+ return unless day
15
+
16
+ @day = Units::Day.new(day, @month)
17
+ @month.lower = @day
18
+ end
19
+
20
+ def day = @day || IMPLICIT_DAY
21
+
22
+ def month_code = :"#{format("M%02d", month)}"
23
+
24
+ def era = nil
25
+
26
+ def era_year = nil
27
+
28
+ def to_s(calendar_name: nil)
29
+ if calendar_name == :always
30
+ "%4d-%02d-%02d[u-ca=%s]" % [@year, @month, day, calendar_id]
31
+ elsif day
32
+ "%4d-%02d-%02d" % [@year, @month, day]
33
+ else
34
+ "%4d-%02d" % [@year, @month]
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def calendar_id=(value)
41
+ value = case value
42
+ in Symbol then value
43
+ in String then value.downcase.to_sym
44
+ in NilClass then :iso8601
45
+ else raise RangeError
46
+ end
47
+
48
+ raise RangeError if value != :iso8601
49
+
50
+ @calendar_id = value
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal::Units
4
+ class Base
5
+ include Comparable
6
+
7
+ def initialize(value)
8
+ self.value = value
9
+ end
10
+
11
+ def <=>(other) = @value <=> other
12
+
13
+ def ==(other) = @value == other
14
+
15
+ def +(other) = self.class.new(@value + other)
16
+
17
+ def -(other) = self.class.new(@value - other)
18
+
19
+ def to_s = @value.to_s
20
+
21
+ def to_i = @value.to_i
22
+
23
+ def to_f = @value.to_f
24
+
25
+ def inspect
26
+ "<#{self.class} @value=#{@value} @reference=#{@reference}>"
27
+ end
28
+
29
+ private
30
+
31
+ def value=(value)
32
+ raise TypeError, "Value for month must respond to #to_i" unless value.respond_to? :to_i
33
+
34
+ value ||= 0
35
+
36
+ @value = Float(value).to_i
37
+ rescue ArgumentError
38
+ raise TypeError, "Value can't be converted into a number"
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal::Units
4
+ class Day < Base
5
+ def initialize(value, upper)
6
+ @upper = upper
7
+
8
+ super(value)
9
+ end
10
+
11
+ def max_value = @upper.max_day
12
+
13
+ def over_value = max_value + 1
14
+
15
+ def +(other)
16
+ sum = @value + other
17
+
18
+ @value = case sum
19
+ when (..0)
20
+ @upper -= 1
21
+ max_value - sum.abs
22
+ when (over_value..)
23
+ @upper += 1
24
+ sum - max_value + 1
25
+ else
26
+ sum
27
+ end
28
+ self
29
+ end
30
+
31
+ def -(other)
32
+ sum = @value - other
33
+
34
+ @value = case sum
35
+ when (..0)
36
+ @upper -= 1
37
+ max_value - sum.abs
38
+ when (over_value..)
39
+ @upper += 1
40
+ sum - max_value
41
+ else
42
+ sum
43
+ end
44
+
45
+ self
46
+ end
47
+
48
+ private
49
+
50
+ def value=(value)
51
+ int = super
52
+
53
+ case int.to_i
54
+ when (..0)
55
+ raise RangeError, "Day must be bigger than zero"
56
+ when (over_value...)
57
+ raise RangeError, "Day #{int} for month #{@upper} can't be bigger than #{max_value}"
58
+ else
59
+ @value = int.to_i
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal::Units
4
+ class Month < Base
5
+ attr_reader :lower
6
+
7
+ def initialize(value, year = nil)
8
+ @year = year
9
+
10
+ super(value)
11
+ end
12
+
13
+ def on_leap_year? = @year.nil? || (@year.respond_to?(:leap?) && @year.leap?)
14
+
15
+ def max_day
16
+ case [@value, on_leap_year?]
17
+ in [1 | 3 | 5 | 7 | 8 | 10 | 12, *]
18
+ 31
19
+ in [4 | 6 | 9 | 11, *]
20
+ 30
21
+ in [2, true]
22
+ 29
23
+ in [2, false]
24
+ 28
25
+ end
26
+ end
27
+
28
+ def lower=(unit)
29
+ @lower = case unit
30
+ in Integer, Float then Day.new(unit)
31
+ in String then Day.new(unit.to_i)
32
+ in Day then unit
33
+ else raise ArgumentError, "Month lower unit must be a number or instance of Day"
34
+ end
35
+ end
36
+
37
+ def +(other)
38
+ sum = @value + other
39
+
40
+ @value = case sum
41
+ when (..0)
42
+ @year = @year.send(:-, 1, inplace: true)
43
+ 12 - sum.abs
44
+ when (13..)
45
+ @year = @year.send(:+, 1, inplace: true)
46
+ sum - 12
47
+ else
48
+ sum
49
+ end
50
+
51
+ if @lower > max_day
52
+ diff = max_day - @lower.to_i
53
+
54
+ @lower += diff
55
+ end
56
+
57
+ self
58
+ end
59
+
60
+ def -(other)
61
+ sum = @value - other
62
+
63
+ @value = case sum
64
+ when (..0)
65
+ @year = @year.send(:+, 1, inplace: true)
66
+ 12 + sum
67
+ when (13..)
68
+ @year = @year.send(:-, 1, inplace: true)
69
+ sum - 12
70
+ else
71
+ sum
72
+ end
73
+
74
+ if @lower > max_day
75
+ diff = max_day - @lower.to_i
76
+
77
+ @lower += diff
78
+ end
79
+
80
+ self.class.new(sum, @year)
81
+ end
82
+
83
+ private
84
+
85
+ def value=(value)
86
+ int = super
87
+
88
+ case int.to_i
89
+ when (..0)
90
+ raise RangeError, "Month must be bigger than zero"
91
+ when (13..)
92
+ raise RangeError, "Month must be less than 13"
93
+ else
94
+ @value = int.to_i
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal::Units
4
+ class Year < Base
5
+ def leap? = (@value % 4).zero?
6
+
7
+ def +(other, inplace: true)
8
+ result = @value + other
9
+
10
+ @value = result if inplace
11
+
12
+ self.class.new(result)
13
+ end
14
+
15
+ def -(other, inplace: true)
16
+ result = @value - other
17
+
18
+ @value = result if inplace
19
+
20
+ self.class.new(result)
21
+ end
22
+
23
+ private
24
+
25
+ def value=(value)
26
+ value = case value
27
+ in true then 1
28
+ in false then 0
29
+ else value
30
+ end
31
+
32
+ super
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Temporal
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Temporal
6
+ class ZonedDateTime
7
+ MAX_TIMESTAMP = 864 * (10**19)
8
+ MIN_TIMESTAMP = -MAX_TIMESTAMP
9
+
10
+ attr_reader :calendar_id
11
+
12
+ def initialize(timestamp, tz: "UTC", calendar_id: nil)
13
+ raise RangeError, "Timestamp underflow" if timestamp < MIN_TIMESTAMP
14
+ raise RangeError, "Timestamp overflow" if timestamp > MAX_TIMESTAMP
15
+
16
+ self.timezone = tz
17
+ self.calendar_id = calendar_id
18
+
19
+ seconds, nanosecond = timestamp.divmod(1_000_000_000)
20
+
21
+ @time = Time.at(seconds, nanosecond, :nanosecond, in: tz)
22
+ @timestamp = timestamp
23
+ end
24
+
25
+ def year = @time.year
26
+ def month = @time.month
27
+ def day = @time.day
28
+ def era = @time.era
29
+ def era_year = @time.era_year
30
+ def month_code = :"#{format("M%02d", @time.month)}"
31
+
32
+ def hour = @time.hour
33
+ def minute = @time.min
34
+ def second = @time.sec
35
+ def millisecond = (@time.subsec.to_f * 1_000).to_i
36
+ def microsecond = @time.usec % 1_000
37
+ def nanosecond = @time.nsec % 1_000
38
+
39
+ def epoch_milliseconds = (@time.to_f * 1_000).to_i
40
+ def epoch_nanoseconds = @timestamp
41
+
42
+ def day_of_week = @time.wday
43
+ def day_of_year = @time.yday
44
+ def week_of_year = (@time.yday / 7) + 1
45
+ def days_in_week = 7
46
+ def days_in_month = 30
47
+ def days_in_year = 366
48
+ def months_in_year = 12
49
+ def leap_year? = true
50
+ def offset = "+00:00"
51
+ def offset_nanoseconds = 0
52
+
53
+ def time_zone_id = @timezone.upcase
54
+
55
+ def to_s = "#{@time.inspect[..-5]}+00:00[UTC]".tr(" ", "T")
56
+
57
+ private
58
+
59
+ def timezone=(value)
60
+ @timezone = case value
61
+ in "a".."i" | "k".."z" | "utc" | "UTC"
62
+ value.upcase
63
+ in /(\+|-)\d\d:\d\d/
64
+ value
65
+ else
66
+ raise RangeError, "Invalid value `#{value}` for timezone"
67
+ end
68
+ end
69
+
70
+ def calendar_id=(value)
71
+ value = case value
72
+ in Symbol then value
73
+ in String then value.downcase.to_sym
74
+ in NilClass then :iso8601
75
+ else raise RangeError
76
+ end
77
+
78
+ raise RangeError if value != :iso8601
79
+
80
+ @calendar_id = value
81
+ end
82
+ end
83
+ end
data/lib/temporal.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "temporal/duration"
4
+ require_relative "temporal/instant"
5
+ require_relative "temporal/now"
6
+ require_relative "temporal/plain_date"
7
+ require_relative "temporal/plain_date_time"
8
+ require_relative "temporal/plain_month_day"
9
+ require_relative "temporal/plain_time"
10
+ require_relative "temporal/plain_year_month"
11
+ require_relative "temporal/units/base"
12
+ require_relative "temporal/units/day"
13
+ require_relative "temporal/units/month"
14
+ require_relative "temporal/units/year"
15
+ require_relative "temporal/version"
16
+ require_relative "temporal/zoned_date_time"
17
+
18
+ module Temporal
19
+ class Error < StandardError; end
20
+ # Your code goes here...
21
+ end
data/mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.3"
metadata ADDED
@@ -0,0 +1,69 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ruby-temporal
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Leonardo Prado
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-02-06 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description:
14
+ email:
15
+ - leonardoprado@gmail.com
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - CODE_OF_CONDUCT.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - lib/temporal.rb
26
+ - lib/temporal/duration.rb
27
+ - lib/temporal/instant.rb
28
+ - lib/temporal/now.rb
29
+ - lib/temporal/plain_date.rb
30
+ - lib/temporal/plain_date_time.rb
31
+ - lib/temporal/plain_month_day.rb
32
+ - lib/temporal/plain_time.rb
33
+ - lib/temporal/plain_year_month.rb
34
+ - lib/temporal/units/base.rb
35
+ - lib/temporal/units/day.rb
36
+ - lib/temporal/units/month.rb
37
+ - lib/temporal/units/year.rb
38
+ - lib/temporal/version.rb
39
+ - lib/temporal/zoned_date_time.rb
40
+ - mise.toml
41
+ homepage: https://github.com/DNA/ruby-temporal
42
+ licenses:
43
+ - MIT
44
+ metadata:
45
+ allowed_push_host: https://rubygems.org
46
+ homepage_uri: https://github.com/DNA/ruby-temporal
47
+ source_code_uri: https://github.com/DNA/ruby-temporal
48
+ changelog_uri: https://github.com/DNA/ruby-temporal/blob/main/README.md
49
+ rubygems_mfa_required: 'true'
50
+ post_install_message:
51
+ rdoc_options: []
52
+ require_paths:
53
+ - lib
54
+ required_ruby_version: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: 3.3.0
59
+ required_rubygems_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: '0'
64
+ requirements: []
65
+ rubygems_version: 3.5.22
66
+ signing_key:
67
+ specification_version: 4
68
+ summary: Ruby implementation of TC39 Temporal API.
69
+ test_files: []