From d5c8a98626028a7c5917b01ee2916872b11cfe44 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 19:11:38 +0100 Subject: [PATCH 01/16] Literal abstract dates --- lib/literal/day.rb | 158 +++++++++++++++++++++++++++++++++++++++++++ lib/literal/month.rb | 96 ++++++++++++++++++++++++++ lib/literal/year.rb | 49 ++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 lib/literal/day.rb create mode 100644 lib/literal/month.rb create mode 100644 lib/literal/year.rb diff --git a/lib/literal/day.rb b/lib/literal/day.rb new file mode 100644 index 0000000..02ff813 --- /dev/null +++ b/lib/literal/day.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +class Literal::Day < Literal::Data + DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"].freeze + SHORT_DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].freeze + + prop :year, Integer + prop :month, _Integer(1..12) + prop :day, _Integer(1..31) + + #: (year: Integer, month: Integer, day: Integer) -> Integer + def self.zellers_congruence(year:, month:, day:) + year, month, day = self.class.adjusted_date_for_zeller(year:, month:, day:) + + q = day + m = month + k = year % 100 + j = year / 100 + + (q + ((13 * (m + 1)) / 5) + k + (k / 4) + (j / 4) - (2 * j)) % 7 + end + + #: (year: Integer, month: Integer, day: Integer) -> [Integer, Integer, Integer] + def self.adjusted_date_for_zeller(year:, month:, day:) + if month < 3 + month += 12 + year -= 1 + end + + [year, month, day].freeze + end + + private def after_initialize + unless @day <= Literal::Month.number_of_days_in(year: @year, month: @month) + raise ArgumentError + end + end + + #: () -> String + def name + DAY_NAMES[day_of_week_index] + end + + #: () -> String + def short_name + SHORT_DAY_NAMES[day_of_week_index] + end + + #: () -> Literal::Day + def succ + days_in_month = Literal::Month.number_of_days_in(year: @year, month: @month) + + if @day < days_in_month + Literal::Day.new(year: @year, month: @month, day: @day + 1) + elsif @month < 12 + Literal::Day.new(year: @year, month: @month + 1, day: 1) + else + Literal::Day.new(year: @year + 1, month: 1, day: 1) + end + end + + #: () -> Literal::Day + def prev + if @day > 1 + Literal::Day.new(year: @year, month: @month, day: @day - 1) + elsif @month > 1 + Literal::Day.new(year: @year, month: @month - 1, day: Literal::Month.number_of_days_in(year: @year, month: @month - 1)) + else + Literal::Day.new(year: @year - 1, month: 12, day: Literal::Month.number_of_days_in(year: @year - 1, month: 12)) + end + end + + #: () -> bool + def monday? + 0 == day_of_week_index + end + + #: () -> bool + def tuesday? + 1 == day_of_week_index + end + + #: () -> bool + def wednesday? + 2 == day_of_week_index + end + + #: () -> bool + def thursday? + 3 == day_of_week_index + end + + #: () -> bool + def friday? + 4 == day_of_week_index + end + + #: () -> bool + def saturday? + 5 == day_of_week_index + end + + #: () -> bool + def sunday? + 6 == day_of_week_index + end + + #: () -> Literal::Month + def month + Literal::Month.new(year: @year, month: @month) + end + + #: () -> Literal::Year + def year + Literal::Year.new(year: @year) + end + + #: () -> bool + def weekend? + day_of_week_index > 4 + end + + #: () -> bool + def weekday? + day_of_week_index < 5 + end + + # TODO: Waiting on duration and addition + def this_monday; end + def this_tuesday; end + def this_wednesday; end + def this_thursday; end + def this_friday; end + def this_saturday; end + def this_sunday; end + + def next_monday; end + def next_tuesday; end + def next_wednesday; end + def next_thursday; end + def next_friday; end + def next_saturday; end + def next_sunday; end + + def last_monday; end + def last_tuesday; end + def last_wednesday; end + def last_thursday; end + def last_friday; end + def last_saturday; end + def last_sunday; end + + # Return the day of the week as an integer from 0 to 6 but where the 0th is Monday. + #: () -> Integer + private def day_of_week_index + (self.class.zellers_congruence(@year, @month, @day) + 5) % 7 + end +end diff --git a/lib/literal/month.rb b/lib/literal/month.rb new file mode 100644 index 0000000..6a6e828 --- /dev/null +++ b/lib/literal/month.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Literal::Month < Literal::Data + MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].freeze + SHORT_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].freeze + NON_LEAP_YEAR_DAY_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + + prop :year, Integer + prop :month, _Integer(1..12) + + # (year: Integer, month: Integer) -> Integer + def self.number_of_days_in(year:, month:) + if month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + 29 + else + NON_LEAP_YEAR_DAY_IN_MONTH[month - 1] + end + end + + #: () -> Literal::Month + def succ + if @month < 12 + self.class.new(year: @year, month: @month + 1) + else + self.class.new(year: @year + 1, month: 1) + end + end + + #: () -> Literal::Month + def prev + if @month > 1 + self.class.new(year: @year, month: @month - 1) + else + self.class.new(year: @year - 1, month: 12) + end + end + + #: () -> -1 | 0 | 1 + def <=>(other) + case other + when Literal::Month + if @year == other.year + @month <=> other.month + else + @year <=> other.year + end + else + raise ArgumentError + end + end + + #: () -> Literal::Year + def year + Literal::Year.new(year: @year) + end + + #: () -> String + def name + MONTH_NAMES[@month - 1] + end + + #: () -> String + def short_name + SHORT_MONTH_NAMES[@month - 1] + end + + #: () -> Integer + def number_of_days + self.class.number_of_days_in(year: @year, month: @month) + end + + #: () -> Range[Literal::Day] + def days + (first_day..last_day) + end + + #: () { (Literal::Day) -> void } -> void + def each_day + total = number_of_days + + i = 1 + while i <= total + yield Literal::Day.new(year: @year, month: @month, day: i) + end + end + + #: () -> Literal::Day + def first_day + Literal::Day.new(year: @year, month: @month, day: 1) + end + + #: () -> Literal::Day + def last_day + Literal::Day.new(year: @year, month: @month, day: number_of_days) + end +end diff --git a/lib/literal/year.rb b/lib/literal/year.rb new file mode 100644 index 0000000..4f24515 --- /dev/null +++ b/lib/literal/year.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Literal::Year < Literal::Data + prop :year, Integer + + #: () -> Literal::Year + def succ + self.class.new(year: @year + 1) + end + + #: () -> Literal::Year + def prev + self.class.new(year: @year - 1) + end + + #: () -> -1 | 0 | 1 + def <=>(other) + case other + when self.class + @year <=> other.year + else + raise ArgumentError + end + end + + #: () -> Literal::Month + def first_month + Literal::Month.new(year: @year, month: 1) + end + + #: () -> Literal::Month + def last_month + Literal::Month.new(year: @year, month: 12) + end + + #: () -> Range[Literal::Month] + def months + (first_month..last_month) + end + + #: () { (Literal::Month) -> void } -> void + def each_month(&) + i = 1 + while i <= 12 + yield Literal::Month.new(year: @year, month: i) + i += 1 + end + end +end From 946f708d6e1046597726dd5e1b7e9939fd60fc4f Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:05:25 +0100 Subject: [PATCH 02/16] Implement more things --- lib/literal/day.rb | 155 ++++++++++++++++++++++++++++++++-------- lib/literal/duration.rb | 38 ++++++++++ lib/literal/month.rb | 30 ++++++-- lib/literal/year.rb | 19 +++-- 4 files changed, 203 insertions(+), 39 deletions(-) create mode 100644 lib/literal/duration.rb diff --git a/lib/literal/day.rb b/lib/literal/day.rb index 02ff813..bc71d14 100644 --- a/lib/literal/day.rb +++ b/lib/literal/day.rb @@ -10,7 +10,7 @@ class Literal::Day < Literal::Data #: (year: Integer, month: Integer, day: Integer) -> Integer def self.zellers_congruence(year:, month:, day:) - year, month, day = self.class.adjusted_date_for_zeller(year:, month:, day:) + year, month, day = adjusted_date_for_zeller(year:, month:, day:) q = day m = month @@ -30,10 +30,13 @@ def self.adjusted_date_for_zeller(year:, month:, day:) [year, month, day].freeze end + #: () -> void private def after_initialize unless @day <= Literal::Month.number_of_days_in(year: @year, month: @month) raise ArgumentError end + + freeze end #: () -> String @@ -47,7 +50,7 @@ def short_name end #: () -> Literal::Day - def succ + def next_day days_in_month = Literal::Month.number_of_days_in(year: @year, month: @month) if @day < days_in_month @@ -59,8 +62,10 @@ def succ end end + alias_method :succ, :next_day + #: () -> Literal::Day - def prev + def prev_day if @day > 1 Literal::Day.new(year: @year, month: @month, day: @day - 1) elsif @month > 1 @@ -125,34 +130,128 @@ def weekday? day_of_week_index < 5 end - # TODO: Waiting on duration and addition - def this_monday; end - def this_tuesday; end - def this_wednesday; end - def this_thursday; end - def this_friday; end - def this_saturday; end - def this_sunday; end - - def next_monday; end - def next_tuesday; end - def next_wednesday; end - def next_thursday; end - def next_friday; end - def next_saturday; end - def next_sunday; end - - def last_monday; end - def last_tuesday; end - def last_wednesday; end - def last_thursday; end - def last_friday; end - def last_saturday; end - def last_sunday; end + def +(other) + case other + when Literal::Duration + year, month, day = @year, @month, @day + + year += other.years + month += other.months + day += other.days + + if month > 12 + year += (month - 1) / 12 + month = ((month - 1) % 12) + 1 + elsif month < 1 + year -= (month.abs / 12) + 1 + month = 12 - ((month.abs - 1) % 12) + end + + if days > 0 + while days > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) + month += 1 + days -= days_in_month + end + elsif days < 0 + while days < 0 + month -= 1 + days += Literal::Month.number_of_days_in(year:, month:) + end + end + + Literal::Day.new(year:, month:, day:) + else + raise ArgumentError + end + end + + #: () -> Literal::Day + def next_monday + next_day_of_week(0) + end + + #: () -> Literal::Day + def next_tuesday + next_day_of_week(1) + end + + #: () -> Literal::Day + def next_wednesday + next_day_of_week(2) + end + + #: () -> Literal::Day + def next_thursday + next_day_of_week(3) + end + + #: () -> Literal::Day + def next_friday + next_day_of_week(4) + end + + #: () -> Literal::Day + def next_saturday + next_day_of_week(5) + end + + #: () -> Literal::Day + def next_sunday + next_day_of_week(6) + end + + #: () -> Literal::Day + def prev_monday + prev_day_of_week(0) + end + + #: () -> Literal::Day + def prev_tuesday + prev_day_of_week(1) + end + + #: () -> Literal::Day + def prev_wednesday + prev_day_of_week(2) + end + + #: () -> Literal::Day + def prev_thursday + prev_day_of_week(3) + end + + #: () -> Literal::Day + def prev_friday + prev_day_of_week(4) + end + + #: () -> Literal::Day + def prev_saturday + prev_day_of_week(5) + end + + #: () -> Literal::Day + def prev_sunday + prev_day_of_week(6) + end # Return the day of the week as an integer from 0 to 6 but where the 0th is Monday. #: () -> Integer private def day_of_week_index - (self.class.zellers_congruence(@year, @month, @day) + 5) % 7 + (self.class.zellers_congruence(year: @year, month: @month, day: @day) + 5) % 7 + end + + #: (Integer) -> Literal::Day + private def next_day_of_week(target_day_index) + days_until_target = (target_day_index + 7 - day_of_week_index) % 7 + days_until_target = 7 if days_until_target == 0 + self + Literal::Duration.new(days: days_until_target) + end + + #: (Integer) -> Literal::Day + private def prev_day_of_week(target_day_index) + days_until_target = (day_of_week_index - target_day_index) % 7 + days_until_target = 7 if days_until_target == 0 + self - Literal::Duration.new(days: days_until_target) end end diff --git a/lib/literal/duration.rb b/lib/literal/duration.rb new file mode 100644 index 0000000..c79cdbd --- /dev/null +++ b/lib/literal/duration.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Literal::Duration < Literal::Object + prop :years, Integer, reader: :public, default: 0 + prop :months, Integer, reader: :public, default: 0 + prop :weeks, Integer, reader: :public, default: 0 + prop :days, Integer, reader: :public, default: 0 + + #: (Literal::Duration) -> Literal::Duration + def +(other) + case other + when Literal::Duration + Literal::Duration.new( + years: @years + other.years, + months: @months + other.months, + weeks: @weeks + other.weeks, + days: @days + other.days, + ) + else + raise ArgumentError + end + end + + #: (Literal::Duration) -> Literal::Duration + def -(other) + case other + when Literal::Duration + Literal::Duration.new( + years: @years - other.years, + months: @months - other.months, + weeks: @weeks - other.weeks, + days: @days - other.days, + ) + else + raise ArgumentError + end + end +end diff --git a/lib/literal/month.rb b/lib/literal/month.rb index 6a6e828..23f0e39 100644 --- a/lib/literal/month.rb +++ b/lib/literal/month.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true -class Literal::Month < Literal::Data +class Literal::Month < Literal::Object MONTH_NAMES = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"].freeze SHORT_MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"].freeze - NON_LEAP_YEAR_DAY_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + NON_LEAP_YEAR_DAY_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze prop :year, Integer prop :month, _Integer(1..12) + private def after_initialize + freeze + end + # (year: Integer, month: Integer) -> Integer def self.number_of_days_in(year:, month:) if month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) @@ -17,8 +21,18 @@ def self.number_of_days_in(year:, month:) end end + #: () -> Integer + def __year__ + @year + end + + #: () -> Integer + def __month__ + @month + end + #: () -> Literal::Month - def succ + def next_month if @month < 12 self.class.new(year: @year, month: @month + 1) else @@ -26,8 +40,10 @@ def succ end end + alias_method :succ, :next_month + #: () -> Literal::Month - def prev + def prev_month if @month > 1 self.class.new(year: @year, month: @month - 1) else @@ -39,10 +55,10 @@ def prev def <=>(other) case other when Literal::Month - if @year == other.year - @month <=> other.month + if @year == other.__year__ + @month <=> other.__month__ else - @year <=> other.year + @year <=> other.__year__ end else raise ArgumentError diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 4f24515..5c291e9 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -1,15 +1,26 @@ # frozen_string_literal: true -class Literal::Year < Literal::Data +class Literal::Year < Literal::Object prop :year, Integer + private def after_initialize + freeze + end + + #: () -> Integer + def __year__ + @year + end + #: () -> Literal::Year - def succ + def next_year self.class.new(year: @year + 1) end + alias_method :succ, :next_year + #: () -> Literal::Year - def prev + def prev_year self.class.new(year: @year - 1) end @@ -17,7 +28,7 @@ def prev def <=>(other) case other when self.class - @year <=> other.year + @year <=> other.__year__ else raise ArgumentError end From 80118ca3fe15dc1463580c9228cc0a7c8579ea4e Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:08:18 +0100 Subject: [PATCH 03/16] Year jump to month --- lib/literal/year.rb | 60 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 5c291e9..04554cd 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -57,4 +57,64 @@ def each_month(&) i += 1 end end + + #: () -> Literal::Month + def january + Literal::Month.new(year: @year, month: 1) + end + + #: () -> Literal::Month + def february + Literal::Month.new(year: @year, month: 2) + end + + #: () -> Literal::Month + def march + Literal::Month.new(year: @year, month: 3) + end + + #: () -> Literal::Month + def april + Literal::Month.new(year: @year, month: 4) + end + + #: () -> Literal::Month + def may + Literal::Month.new(year: @year, month: 5) + end + + #: () -> Literal::Month + def june + Literal::Month.new(year: @year, month: 6) + end + + #: () -> Literal::Month + def july + Literal::Month.new(year: @year, month: 7) + end + + #: () -> Literal::Month + def august + Literal::Month.new(year: @year, month: 8) + end + + #: () -> Literal::Month + def september + Literal::Month.new(year: @year, month: 9) + end + + #: () -> Literal::Month + def october + Literal::Month.new(year: @year, month: 10) + end + + #: () -> Literal::Month + def november + Literal::Month.new(year: @year, month: 11) + end + + #: () -> Literal::Month + def december + Literal::Month.new(year: @year, month: 12) + end end From 0aa49f640833f2f2aa5654aa411b7edd73d8fc1e Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:08:23 +0100 Subject: [PATCH 04/16] Month predicates --- lib/literal/month.rb | 60 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/lib/literal/month.rb b/lib/literal/month.rb index 23f0e39..55ce56b 100644 --- a/lib/literal/month.rb +++ b/lib/literal/month.rb @@ -109,4 +109,64 @@ def first_day def last_day Literal::Day.new(year: @year, month: @month, day: number_of_days) end + + #: () -> bool + def january? + 1 == @month + end + + #: () -> bool + def february? + 2 == @month + end + + #: () -> bool + def march? + 3 == @month + end + + #: () -> bool + def april? + 4 == @month + end + + #: () -> bool + def may? + 5 == @month + end + + #: () -> bool + def june? + 6 == @month + end + + #: () -> bool + def july? + 7 == @month + end + + #: () -> bool + def august? + 8 == @month + end + + #: () -> bool + def september? + 9 == @month + end + + #: () -> bool + def october? + 10 == @month + end + + #: () -> bool + def november? + 11 == @month + end + + #: () -> bool + def december? + 12 == @month + end end From 2e26a85acd5f0c333e071fb25402f182496bb66a Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:15:21 +0100 Subject: [PATCH 05/16] Leap years --- lib/literal/month.rb | 2 +- lib/literal/year.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/literal/month.rb b/lib/literal/month.rb index 55ce56b..3d9decd 100644 --- a/lib/literal/month.rb +++ b/lib/literal/month.rb @@ -14,7 +14,7 @@ class Literal::Month < Literal::Object # (year: Integer, month: Integer) -> Integer def self.number_of_days_in(year:, month:) - if month == 2 && year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + if month == 2 && Literal::Year.leap_year?(year:) 29 else NON_LEAP_YEAR_DAY_IN_MONTH[month - 1] diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 04554cd..0c68b7b 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -7,6 +7,11 @@ class Literal::Year < Literal::Object freeze end + #: (year: Integer) -> bool + def self.leap_year?(year:) + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + end + #: () -> Integer def __year__ @year @@ -117,4 +122,9 @@ def november def december Literal::Month.new(year: @year, month: 12) end + + #: () -> bool + def leap_year? + self.class.leap_year?(year: @year) + end end From 8bd51dda38a508fb4f7649b83220fa3a225bb007 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:15:26 +0100 Subject: [PATCH 06/16] ce and bce --- lib/literal/year.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 0c68b7b..0c57a4b 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -127,4 +127,14 @@ def december def leap_year? self.class.leap_year?(year: @year) end + + #: () -> bool + def ce? + @year > 0 + end + + #: () -> bool + def bce? + @year < 0 + end end From 6aa8f84e1eec7e2616059169f266ee7fa6785b03 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Wed, 28 May 2025 22:21:45 +0100 Subject: [PATCH 07/16] Add an optimisation for adding more than 400 years worth of days --- lib/literal/day.rb | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/literal/day.rb b/lib/literal/day.rb index bc71d14..396b7c8 100644 --- a/lib/literal/day.rb +++ b/lib/literal/day.rb @@ -147,6 +147,12 @@ def +(other) month = 12 - ((month.abs - 1) % 12) end + # Optimisation for when adding more than 400 years worth of days. + if days > 146_097 + years += (400 * (days / 146_097)) + days %= 146_097 + end + if days > 0 while days > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) month += 1 From 34cdf24815159752bb967aa2865806efc0fc5195 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 00:50:14 +0100 Subject: [PATCH 08/16] Various improvements --- lib/literal/day.rb | 62 ++++++++++++++++++++---- lib/literal/duration.rb | 78 ++++++++++++++++++++++++++++--- lib/literal/month.rb | 5 +- lib/literal/time.rb | 101 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 lib/literal/time.rb diff --git a/lib/literal/day.rb b/lib/literal/day.rb index 396b7c8..71851e7 100644 --- a/lib/literal/day.rb +++ b/lib/literal/day.rb @@ -21,7 +21,7 @@ def self.zellers_congruence(year:, month:, day:) end #: (year: Integer, month: Integer, day: Integer) -> [Integer, Integer, Integer] - def self.adjusted_date_for_zeller(year:, month:, day:) + private_class_method def self.adjusted_date_for_zeller(year:, month:, day:) if month < 3 month += 12 year -= 1 @@ -148,20 +148,20 @@ def +(other) end # Optimisation for when adding more than 400 years worth of days. - if days > 146_097 + if day > 146_097 years += (400 * (days / 146_097)) days %= 146_097 end - if days > 0 - while days > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) + if day > 0 + while day > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) month += 1 - days -= days_in_month + day -= days_in_month end - elsif days < 0 - while days < 0 + elsif day < 0 + while day < 0 month -= 1 - days += Literal::Month.number_of_days_in(year:, month:) + day += Literal::Month.number_of_days_in(year:, month:) end end @@ -171,6 +171,13 @@ def +(other) end end + def -(other) + case other + when Literal::Duration + self + (-other) + end + end + #: () -> Literal::Day def next_monday next_day_of_week(0) @@ -241,6 +248,45 @@ def prev_sunday prev_day_of_week(6) end + #: () { (Literal::Time) -> void } -> void + def each_hour + hour = 0 + while hour < 24 + yield Literal::Time.new(year: @year, month: @month, day: @day, hour: i) + hour += 1 + end + end + + #: () { (Literal::Time) -> void } -> void + def each_minute + hour = 0 + while hour < 24 + minute = 0 + while minute < 60 + yield Literal::Time.new(year: @year, month: @month, day: @day, hour:, minute:) + minute += 1 + end + hour += 1 + end + end + + #: () { (Literal::Time) -> void } -> void + def each_second + hour = 0 + while hour < 24 + minute = 0 + while minute < 60 + second = 0 + while second < 60 + yield Literal::Time.new(year: @year, month: @month, day: @day, hour:, minute:, second:) + second += 1 + end + minute += 1 + end + hour += 1 + end + end + # Return the day of the week as an integer from 0 to 6 but where the 0th is Monday. #: () -> Integer private def day_of_week_index diff --git a/lib/literal/duration.rb b/lib/literal/duration.rb index c79cdbd..ec4f5d3 100644 --- a/lib/literal/duration.rb +++ b/lib/literal/duration.rb @@ -1,10 +1,66 @@ # frozen_string_literal: true +# An abstract description of an amount of time. +# Ironically, the actual duration may not be known until it is applied to a concrete time. class Literal::Duration < Literal::Object - prop :years, Integer, reader: :public, default: 0 - prop :months, Integer, reader: :public, default: 0 - prop :weeks, Integer, reader: :public, default: 0 - prop :days, Integer, reader: :public, default: 0 + prop :years, Integer, reader: :public + prop :months, Integer, reader: :public + prop :days, Integer, reader: :public + prop :nanoseconds, Integer, reader: :public + + def initialize( + centuries: 0, + decades: 0, + years: 0, + months: 0, + fortnights: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + milliseconds: 0, + microseconds: 0, + nanoseconds: 0 + ) + years += 100 * centuries + years += 10 * decades + + days += 14 * fortnights + days += 7 * weeks + + microseconds += (nanoseconds / 1000) + nanoseconds %= 1000 + + milliseconds += (microseconds / 1000) + microseconds %= 1000 + + seconds += (milliseconds / 1000) + milliseconds %= 1000 + + minutes += (seconds / 60) + seconds %= 60 + + hours += (minutes / 60) + minutes %= 60 + + days += (hours / 24) + hours %= 24 + + minutes += (hours * 60) + seconds += (minutes * 60) + + nanoseconds += 1_000_000_000 * seconds + nanoseconds += 1_000_000 * milliseconds + nanoseconds += 1_000 * microseconds + + super( + years:, + months:, + days:, + nanoseconds: + ) + end #: (Literal::Duration) -> Literal::Duration def +(other) @@ -13,8 +69,8 @@ def +(other) Literal::Duration.new( years: @years + other.years, months: @months + other.months, - weeks: @weeks + other.weeks, days: @days + other.days, + nanoseconds: @nanoseconds + other.nanoseconds ) else raise ArgumentError @@ -28,11 +84,21 @@ def -(other) Literal::Duration.new( years: @years - other.years, months: @months - other.months, - weeks: @weeks - other.weeks, days: @days - other.days, + nanoseconds: @nanoseconds - other.nanoseconds ) else raise ArgumentError end end + + #: () -> Literal::Duration + def -@ + Literal::Duration.new( + years: -@years, + months: -@months, + days: -@days, + nanoseconds: -@nanoseconds + ) + end end diff --git a/lib/literal/month.rb b/lib/literal/month.rb index 3d9decd..af3fe92 100644 --- a/lib/literal/month.rb +++ b/lib/literal/month.rb @@ -94,9 +94,10 @@ def days def each_day total = number_of_days - i = 1 + day = 1 while i <= total - yield Literal::Day.new(year: @year, month: @month, day: i) + yield Literal::Day.new(year: @year, month: @month, day:) + day += 1 end end diff --git a/lib/literal/time.rb b/lib/literal/time.rb new file mode 100644 index 0000000..6502551 --- /dev/null +++ b/lib/literal/time.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +class Literal::Time < Literal::Object + prop :year, Integer + prop :month, _Integer(1..12) + prop :day, _Integer(1..31) + prop :hour, _Integer(0, 24), default: 0 + prop :minute, _Integer(0, 59), default: 0 + prop :second, _Integer(0, 59), default: 0 + prop :millisecond, _Integer(0, 999), default: 0 + prop :microsecond, _Integer(0, 999), default: 0 + prop :nanosecond, _Integer(0, 999), default: 0 + + #: () -> Literal::Year + def year + Literal::Year.new(year: @year) + end + + #: () -> Literal::Month + def month + Literal::Month.new(year: @year, month: @month) + end + + #: () -> Literal::Day + def day + Literal::Day.new(year: @year, month: @month, day: @day) + end + + #: (Literal::Duration) -> Literal::Time + def +(other) + case other + when Literal::Duration + year = @year + month = @month + day = @day + hour = @hour + minute = @minute + second = @second + millisecond = @millisecond + microsecond = @microsecond + nanosecond = @nanosecond + + year += other.years + month += other.months + day += other.days + + if month > 12 + year += (month - 1) / 12 + month = ((month - 1) % 12) + 1 + elsif month < 1 + year -= (month.abs / 12) + 1 + month = 12 - ((month.abs - 1) % 12) + end + + if day > 0 + while day > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) + month += 1 + day -= days_in_month + end + elsif day < 0 + while day < 0 + month -= 1 + day += Literal::Month.number_of_days_in(year:, month:) + end + end + + other_nanoseconds = other.nanoseconds + + hour += (other_nanoseconds / 3_600_000_000_000) + other_nanoseconds %= 3_600_000_000_000 + + minute += (other_nanoseconds / 60_000_000_000) + other_nanoseconds %= 60_000_000_000 + + second += (other_nanoseconds / 1_000_000_000) + other_nanoseconds %= 1_000_000_000 + + millisecond += (other_nanoseconds / 1_000_000) + other_nanoseconds %= 1_000_000 + + microsecond += (other_nanoseconds / 1_000) + other_nanoseconds %= 1_000 + + nanosecond += other_nanoseconds + + Literal::Time.new(year:, month:, day:, hour:, minute:, second:, millisecond:, microsecond:, nanosecond:) + else + raise ArgumentError + end + end + + #: (Literal::Duration) -> Literal::Time + def -(other) + case other + when Literal::Duration + self + (-other) + else + raise ArgumentError + end + end +end From 5ebf29379d177af87b23fd0a61611e354bb32e14 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 00:54:12 +0100 Subject: [PATCH 09/16] Update time.rb --- lib/literal/time.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/literal/time.rb b/lib/literal/time.rb index 6502551..487d35a 100644 --- a/lib/literal/time.rb +++ b/lib/literal/time.rb @@ -4,12 +4,12 @@ class Literal::Time < Literal::Object prop :year, Integer prop :month, _Integer(1..12) prop :day, _Integer(1..31) - prop :hour, _Integer(0, 24), default: 0 - prop :minute, _Integer(0, 59), default: 0 - prop :second, _Integer(0, 59), default: 0 - prop :millisecond, _Integer(0, 999), default: 0 - prop :microsecond, _Integer(0, 999), default: 0 - prop :nanosecond, _Integer(0, 999), default: 0 + prop :hour, _Integer(0, 24), default: 0, reader: true + prop :minute, _Integer(0, 59), default: 0, reader: true + prop :second, _Integer(0, 59), default: 0, reader: true + prop :millisecond, _Integer(0, 999), default: 0, reader: true + prop :microsecond, _Integer(0, 999), default: 0, reader: true + prop :nanosecond, _Integer(0, 999), default: 0, reader: true #: () -> Literal::Year def year From 4dfebf13df3d132931ce47eefe679f4a86a310db Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 01:09:21 +0100 Subject: [PATCH 10/16] Various improvements --- lib/literal/day.rb | 24 ++++++++++++++++++++++++ lib/literal/month.rb | 5 +++++ lib/literal/year.rb | 15 +++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/lib/literal/day.rb b/lib/literal/day.rb index 71851e7..c40f553 100644 --- a/lib/literal/day.rb +++ b/lib/literal/day.rb @@ -49,6 +49,30 @@ def short_name SHORT_DAY_NAMES[day_of_week_index] end + #: () -> Integer + def day_of_year + day_of_year = @day + + month = 1 + while month < @month + day_of_year += Literal::Month.number_of_days_in(year: @year, month:) + month += 1 + end + + day_of_year + end + + #: () -> Integer + def day_of_month + @day + end + + # Return the day of week from 1 to 7, starting on Monday. + #: () -> Integer + def day_of_week + day_of_week_index + 1 + end + #: () -> Literal::Day def next_day days_in_month = Literal::Month.number_of_days_in(year: @year, month: @month) diff --git a/lib/literal/month.rb b/lib/literal/month.rb index af3fe92..f2141ee 100644 --- a/lib/literal/month.rb +++ b/lib/literal/month.rb @@ -70,6 +70,11 @@ def year Literal::Year.new(year: @year) end + #: (Integer) -> Literal::Day + def day(day) + Literal::Day.new(year: @year, month: @month, day:) + end + #: () -> String def name MONTH_NAMES[@month - 1] diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 0c57a4b..18507fb 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -49,11 +49,26 @@ def last_month Literal::Month.new(year: @year, month: 12) end + #: () -> Literal::Day + def first_day + Literal::Day.new(year: @year, month: 1, day: 1) + end + + #: () -> Literal::Day + def last_day + Literal::Day.new(year: @year, month: 12, day: 31) + end + #: () -> Range[Literal::Month] def months (first_month..last_month) end + #: (Integer) -> Literal::Month + def month(month) + Literal::Month.new(year: @year, month:) + end + #: () { (Literal::Month) -> void } -> void def each_month(&) i = 1 From 47a7099f98df079e577d88dac384ae681259e3c1 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 12:36:22 +0100 Subject: [PATCH 11/16] Add time enumerator --- lib/literal/period.rb | 42 ++++++++++++++++++++++++++++++++++ lib/literal/time.rb | 29 ++++++++++++++++++----- lib/literal/time_enumerator.rb | 36 +++++++++++++++++++++++++++++ lib/literal/year.rb | 1 + 4 files changed, 102 insertions(+), 6 deletions(-) create mode 100644 lib/literal/period.rb create mode 100644 lib/literal/time_enumerator.rb diff --git a/lib/literal/period.rb b/lib/literal/period.rb new file mode 100644 index 0000000..3f2d91b --- /dev/null +++ b/lib/literal/period.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# Represents an abstract period of time between two abstract times +class Literal::Period < Literal::Object + prop :from, Literal::Time, reader: :public + prop :to, Literal::Time, reader: :public + + #: () -> void + private def after_initialize + unless @from <= @to + raise ArgumentError + end + + freeze + end + + #: () -> Literal::Duration + def duration + Literal::Duration.new( + years: @to.year - @from.year, + months: @to.month - @from.month, + days: @to.day - @from.day, + hours: @to.hour - @from.hour, + minutes: @to.minutes - @from.minutes, + seconds: @to.seconds - @from.seconds, + milliseconds: @to.milliseconds - @from.milliseconds, + microseconds: @to.microseconds - @from.microseconds, + nanoseconds: @to.nanoseconds - @from.nanoseconds + ) + end + + def every(step, unit, &block) + enumerator = Literal::TimeEnumerator.new( + from: @from, + to: @to, + unit:, + step: + ) + + block ? enumerator.each(&block) : enumerator + end +end diff --git a/lib/literal/time.rb b/lib/literal/time.rb index 487d35a..c1b4429 100644 --- a/lib/literal/time.rb +++ b/lib/literal/time.rb @@ -4,12 +4,17 @@ class Literal::Time < Literal::Object prop :year, Integer prop :month, _Integer(1..12) prop :day, _Integer(1..31) - prop :hour, _Integer(0, 24), default: 0, reader: true - prop :minute, _Integer(0, 59), default: 0, reader: true - prop :second, _Integer(0, 59), default: 0, reader: true - prop :millisecond, _Integer(0, 999), default: 0, reader: true - prop :microsecond, _Integer(0, 999), default: 0, reader: true - prop :nanosecond, _Integer(0, 999), default: 0, reader: true + prop :hour, _Integer(0, 24), default: 0, reader: :public + prop :minute, _Integer(0, 59), default: 0, reader: :public + prop :second, _Integer(0, 59), default: 0, reader: :public + prop :millisecond, _Integer(0, 999), default: 0, reader: :public + prop :microsecond, _Integer(0, 999), default: 0, reader: :public + prop :nanosecond, _Integer(0, 999), default: 0, reader: :public + + #: () -> void + private def after_initialize + freeze + end #: () -> Literal::Year def year @@ -26,6 +31,18 @@ def day Literal::Day.new(year: @year, month: @month, day: @day) end + #: () -> Date + def to_std_date + Date.new(@year, @month, @day) + end + + #: () -> Time + def to_std_time + total_subsec = (@millisecond * 1_000_000) + (@microsecond * 1_000) + @nanosecond + subsec_seconds = Rational(total_subsec, 1_000_000_000) + Time.new(@year, @month, @day, @hour, @minute, @second + subsec_seconds) + end + #: (Literal::Duration) -> Literal::Time def +(other) case other diff --git a/lib/literal/time_enumerator.rb b/lib/literal/time_enumerator.rb new file mode 100644 index 0000000..e98f04b --- /dev/null +++ b/lib/literal/time_enumerator.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class Literal::TimeEnumerator < Literal::Object + include Enumerable + + Unit = _Union( + :centuries, + :decades, + :years, + :quarters, + :months, + :fortnights, + :weeks, + :days, + :hours, + :minutes, + :seconds, + :milliseconds, + :microseconds, + :nanoseconds + ) + + prop :from, Literal::Time, reader: :public + prop :to, Literal::Time, reader: :public + prop :unit, Literal::TimeUnit, reader: :public + prop :step, Integer, reader: :public + + #: () -> Literal::Period + def period + Literal::Period.new(from: @from, to: @to) + end + + #: () { (Literal::Time) -> void } -> void + def each + end +end diff --git a/lib/literal/year.rb b/lib/literal/year.rb index 18507fb..d4d9a74 100644 --- a/lib/literal/year.rb +++ b/lib/literal/year.rb @@ -3,6 +3,7 @@ class Literal::Year < Literal::Object prop :year, Integer + #: () -> void private def after_initialize freeze end From 2bc3ef031e24e066db55851189014221f6b72e36 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 12:38:19 +0100 Subject: [PATCH 12/16] Add signature to every --- lib/literal/period.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/literal/period.rb b/lib/literal/period.rb index 3f2d91b..77e183a 100644 --- a/lib/literal/period.rb +++ b/lib/literal/period.rb @@ -29,12 +29,14 @@ def duration ) end - def every(step, unit, &block) + #: (Integer n, Symbol unit) -> Literal::TimeEnumerator + #: (Integer n, Symbol unit) { (Literal::Time) -> void } -> void + def every(n, unit, &block) enumerator = Literal::TimeEnumerator.new( from: @from, to: @to, unit:, - step: + step: n ) block ? enumerator.each(&block) : enumerator From 62c23cfbfb9b14b69c673ffac22ec529a882703f Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 12:55:58 +0100 Subject: [PATCH 13/16] Fix duration --- lib/literal/duration.rb | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/literal/duration.rb b/lib/literal/duration.rb index ec4f5d3..8cd56c8 100644 --- a/lib/literal/duration.rb +++ b/lib/literal/duration.rb @@ -6,6 +6,7 @@ class Literal::Duration < Literal::Object prop :years, Integer, reader: :public prop :months, Integer, reader: :public prop :days, Integer, reader: :public + prop :hours, Integer, reader: :public prop :nanoseconds, Integer, reader: :public def initialize( @@ -26,6 +27,9 @@ def initialize( years += 100 * centuries years += 10 * decades + years += (months / 12) + months %= 12 + days += 14 * fortnights days += 7 * weeks @@ -44,12 +48,7 @@ def initialize( hours += (minutes / 60) minutes %= 60 - days += (hours / 24) - hours %= 24 - - minutes += (hours * 60) seconds += (minutes * 60) - nanoseconds += 1_000_000_000 * seconds nanoseconds += 1_000_000 * milliseconds nanoseconds += 1_000 * microseconds @@ -58,6 +57,7 @@ def initialize( years:, months:, days:, + hours:, nanoseconds: ) end @@ -70,6 +70,7 @@ def +(other) years: @years + other.years, months: @months + other.months, days: @days + other.days, + hours: @hours + other.hours, nanoseconds: @nanoseconds + other.nanoseconds ) else @@ -85,6 +86,7 @@ def -(other) years: @years - other.years, months: @months - other.months, days: @days - other.days, + hours: @hours - other.hours, nanoseconds: @nanoseconds - other.nanoseconds ) else From 27b410ffe70edc17c9796ed69b0b20731f46fd10 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 13:01:25 +0100 Subject: [PATCH 14/16] Remove bad implementation --- lib/literal/day.rb | 41 ----------------------------------------- 1 file changed, 41 deletions(-) diff --git a/lib/literal/day.rb b/lib/literal/day.rb index c40f553..6915f64 100644 --- a/lib/literal/day.rb +++ b/lib/literal/day.rb @@ -154,47 +154,6 @@ def weekday? day_of_week_index < 5 end - def +(other) - case other - when Literal::Duration - year, month, day = @year, @month, @day - - year += other.years - month += other.months - day += other.days - - if month > 12 - year += (month - 1) / 12 - month = ((month - 1) % 12) + 1 - elsif month < 1 - year -= (month.abs / 12) + 1 - month = 12 - ((month.abs - 1) % 12) - end - - # Optimisation for when adding more than 400 years worth of days. - if day > 146_097 - years += (400 * (days / 146_097)) - days %= 146_097 - end - - if day > 0 - while day > (days_in_month = Literal::Month.number_of_days_in(year:, month:)) - month += 1 - day -= days_in_month - end - elsif day < 0 - while day < 0 - month -= 1 - day += Literal::Month.number_of_days_in(year:, month:) - end - end - - Literal::Day.new(year:, month:, day:) - else - raise ArgumentError - end - end - def -(other) case other when Literal::Duration From f06dc50df06ee019e3d1bc3685495a42c7676d33 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 13:01:35 +0100 Subject: [PATCH 15/16] Optimise duration with many days --- lib/literal/duration.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/literal/duration.rb b/lib/literal/duration.rb index 8cd56c8..b1575fa 100644 --- a/lib/literal/duration.rb +++ b/lib/literal/duration.rb @@ -33,6 +33,10 @@ def initialize( days += 14 * fortnights days += 7 * weeks + # Every 146_097 days is exactly 400 years + years += (400 * (days / 146_097)) + days %= 146_097 + microseconds += (nanoseconds / 1000) nanoseconds %= 1000 From 98e154e4ce1d8f462da625431884d32af428c392 Mon Sep 17 00:00:00 2001 From: Joel Drapper Date: Thu, 29 May 2025 13:02:01 +0100 Subject: [PATCH 16/16] Account for duration hours --- lib/literal/time.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/literal/time.rb b/lib/literal/time.rb index c1b4429..fa7de6b 100644 --- a/lib/literal/time.rb +++ b/lib/literal/time.rb @@ -81,6 +81,8 @@ def +(other) end end + hours += other.hours + other_nanoseconds = other.nanoseconds hour += (other_nanoseconds / 3_600_000_000_000)