diff --git a/docs/Datetime.html b/docs/Datetime.html index 276dcb2..d0adf0d 100644 --- a/docs/Datetime.html +++ b/docs/Datetime.html @@ -324,7 +324,7 @@

defn

- (Fn [(Ref Datetime a), (Ref Datetime b)] Int) + (Fn [(Ref Datetime a), (Ref Datetime b)] Long)

                         (diff a b)
diff --git a/docs/Duration.html b/docs/Duration.html
index 76e043d..f0e3339 100644
--- a/docs/Duration.html
+++ b/docs/Duration.html
@@ -131,6 +131,9 @@ 

adds a Duration delta to a Datetime dt.

+

A Duration can name a span far wider than the range of dates a Datetime can +represent. A result that would fall outside that range saturates at the first +or last representable date rather than wrapping.

@@ -217,6 +220,29 @@

+
+ +

+ from-seconds +

+
+
+ defn +
+

+ (Fn [Long] Duration) +

+
+                        (from-seconds n)
+                    
+

+

creates a delta from a raw second count n.

+

The unit constructors above take an Int count, which cannot name a delta +longer than about 68 years in seconds. This is the counterpart to +to-seconds, so a delta of any width round-trips through it.

+ +

+

@@ -477,6 +503,7 @@

subtracts a Duration delta from a Datetime dt.

+

Out-of-range results saturate, as in add.

@@ -490,7 +517,7 @@

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-days d)
@@ -511,7 +538,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-hours d)
@@ -532,7 +559,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-minutes d)
@@ -553,7 +580,7 @@ 

defn

- (Fn [(Ref Duration a)] Int) + (Fn [(Ref Duration a)] Long)

                         (to-seconds d)
diff --git a/test/time.carp b/test/time.carp
index 262415d..88550a3 100644
--- a/test/time.carp
+++ b/test/time.carp
@@ -1692,19 +1692,19 @@
     "subtract 3661 seconds (1h1m1s) from 00:00:00")
 
   (assert-equal test
-    0
+    0l
     (Datetime.diff &(Datetime.date 2024 3 15) &(Datetime.date 2024 3 15))
     "diff: same date is 0")
   (assert-equal test
-    86400
+    86400l
     (Datetime.diff &(Datetime.date 2024 3 16) &(Datetime.date 2024 3 15))
     "diff: one day apart is 86400")
   (assert-equal test
-    -86400
+    -86400l
     (Datetime.diff &(Datetime.date 2024 3 15) &(Datetime.date 2024 3 16))
     "diff: reversed order is negative")
   (assert-equal test
-    3600
+    3600l
     (Datetime.diff
       &(Datetime.init 2024
                       3
@@ -1724,7 +1724,7 @@
                       (Maybe.Nothing)))
     "diff: one hour apart is 3600")
   (assert-equal test
-    90061
+    90061l
     (Datetime.diff
       &(Datetime.init 2024
                       3
@@ -1744,13 +1744,21 @@
                       (Maybe.Nothing)))
     "diff: 1 day 1 hour 1 minute 1 second = 90061")
   (assert-equal test
-    (* 365 86400)
+    (* 365l 86400l)
     (Datetime.diff &(Datetime.date 2023 1 1) &(Datetime.date 2022 1 1))
     "diff: non-leap year is 365 days")
   (assert-equal test
-    (* 366 86400)
+    (* 366l 86400l)
     (Datetime.diff &(Datetime.date 2025 1 1) &(Datetime.date 2024 1 1))
     "diff: leap year 2024 has 366 days")
+  (assert-equal test
+    (* 36525l 86400l)
+    (Datetime.diff &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1))
+    "diff: a century of seconds does not wrap")
+  (assert-equal test
+    (* -36525l 86400l)
+    (Datetime.diff &(Datetime.date 2000 1 1) &(Datetime.date 2100 1 1))
+    "diff: a negative century of seconds does not wrap")
 
   (assert-equal test
     &(Datetime.date 2024 2 15)
@@ -1900,33 +1908,51 @@
     "between: sub-day difference in seconds")
 
   (assert-equal test
-    3600
+    3600l
     (Duration.to-seconds &(Duration.hours 1))
     "to-seconds: exact")
   (assert-equal test
-    1
+    1l
     (Duration.to-minutes &(Duration.seconds 90))
     "to-minutes: truncates positive remainder")
   (assert-equal test
-    0
+    0l
     (Duration.to-minutes &(Duration.seconds 59))
     "to-minutes: below one minute is zero")
   (assert-equal test
-    -1
+    -1l
     (Duration.to-minutes &(Duration.seconds -90))
     "to-minutes: truncates toward zero for negatives")
   (assert-equal test
-    1
+    1l
     (Duration.to-hours &(Duration.minutes 90))
     "to-hours: truncates remainder")
   (assert-equal test
-    1
+    1l
     (Duration.to-days &(Duration.hours 25))
     "to-days: truncates remainder")
   (assert-equal test
-    7
+    7l
     (Duration.to-days &(Duration.weeks 1))
     "to-days: one week is seven days")
+  (assert-equal test
+    (* 3551l 604800l)
+    (Duration.to-seconds &(Duration.weeks 3551))
+    "weeks: a count past the 32-bit second range does not wrap")
+  (assert-equal test
+    (* 24856l 86400l)
+    (Duration.to-seconds &(Duration.days 24856))
+    "days: a count past the 32-bit second range does not wrap")
+  (assert-equal test
+    (* 36525l 86400l)
+    (Duration.to-seconds
+      &(Duration.between &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1)))
+    "between: a century does not wrap")
+  (assert-equal test
+    36525l
+    (Duration.to-days
+      &(Duration.between &(Datetime.date 2100 1 1) &(Datetime.date 2000 1 1)))
+    "to-days: a century is 36525 days")
 
   (assert-equal test
     &(Duration.seconds 90)
@@ -1964,6 +1990,9 @@
   (assert-true test
     (Duration.= &(Duration.minutes 1) &(Duration.seconds 60))
     "=: equal durations from different units")
+  (assert-false test
+    (Duration.= &(Duration.days 49710) &(Duration.seconds -23296))
+    "=: deltas that collide when truncated to 32 bits are not equal")
   (assert-false test
     (Duration.= &(Duration.minutes 1) &(Duration.seconds 61))
     "=: unequal durations")
@@ -1979,12 +2008,28 @@
   (assert-false test
     (Duration.> &(Duration.seconds 30) &(Duration.minutes 1))
     ">: shorter is not greater")
+  (assert-true test
+    (Duration.> &(Duration.days 30000) &(Duration.days 1))
+    ">: a delta past the 32-bit second range is still the longer one")
+  (assert-false test
+    (Duration.< &(Duration.days 30000) &(Duration.days 1))
+    "<: a delta past the 32-bit second range is not the shorter one")
   (assert-true test
     (Duration.zero? &(Duration.seconds 0))
     "zero?: the zero delta")
   (assert-false test
     (Duration.zero? &(Duration.seconds 1))
     "zero?: a non-zero delta")
+  (assert-false test
+    (Duration.zero?
+      &(Duration.plus &(Duration.days 49710) &(Duration.seconds 23296)))
+    "zero?: a delta of exactly 2^32 seconds is not the zero delta")
+  (assert-true test
+    (Duration.pos? &(Duration.days 30000))
+    "pos?: a delta past the 32-bit second range is positive")
+  (assert-false test
+    (Duration.neg? &(Duration.days 30000))
+    "neg?: a delta past the 32-bit second range is not negative")
   (assert-true test (Duration.pos? &(Duration.seconds 1)) "pos?: positive delta")
   (assert-false test
     (Duration.pos? &(Duration.seconds -1))
@@ -2034,4 +2079,74 @@
   (assert-equal test
     &@"-1d 2h 3m 4s"
     &(Duration.str &(Duration.seconds -93784))
-    "str: negative multi-unit"))
+    "str: negative multi-unit")
+  (assert-equal test
+    &@"36525d 1h 1m 1s"
+    &(Duration.str
+      &(Duration.plus &(Duration.days 36525) &(Duration.seconds 3661)))
+    "str: multi-decade delta")
+  (assert-equal test
+    &@"-36525d"
+    &(Duration.str &(Duration.negate &(Duration.days 36525)))
+    "str: negative multi-decade delta")
+
+  (assert-equal test
+    &@"2100-01-01"
+    &(Datetime.isoformat
+      &(Duration.add &(Datetime.date 2000 1 1) &(Duration.days 36525)))
+    "add: a century-long delta lands on the right date")
+  (assert-equal test
+    &@"2000-01-01"
+    &(Datetime.isoformat
+      &(Duration.sub &(Datetime.date 2100 1 1) &(Duration.days 36525)))
+    "sub: a century-long delta lands on the right date")
+  (assert-equal test
+    &@"2124-03-16 13:30:45"
+    &(Datetime.strftime
+      &(Duration.add
+        &(Datetime.init 2024
+                        3
+                        15
+                        (Maybe.Just 12)
+                        (Maybe.Just 30)
+                        (Maybe.Just 45)
+                        (Maybe.Nothing)
+                        (Maybe.Nothing))
+        &(Duration.plus &(Duration.days 36525) &(Duration.hours 1)))
+      "%Y-%m-%d %H:%M:%S")
+    "add: a century-long delta keeps the time of day")
+
+  (assert-equal test
+    &@"0001-01-01"
+    &(Datetime.isoformat
+      &(Duration.sub &(Datetime.date 2024 1 1) &(Duration.days 739000)))
+    "sub: a delta reaching before the first ordinal saturates")
+  (assert-equal test
+    &@"5879611-07-11"
+    &(Datetime.isoformat
+      &(Duration.add &(Datetime.date 2024 1 1) &(Duration.days 2147483647)))
+    "add: a delta past the last ordinal saturates")
+  (assert-equal test
+    &@"5879611-07-11"
+    &(Datetime.isoformat
+      &(Duration.add
+        &(Datetime.init 2024
+                        1
+                        1
+                        (Maybe.Just 23)
+                        (Maybe.Just 0)
+                        (Maybe.Just 0)
+                        (Maybe.Nothing)
+                        (Maybe.Nothing))
+        &(Duration.plus &(Duration.days 2147483647) &(Duration.hours 23))))
+    "add: saturation leaves room for the time-of-day carry")
+
+  (assert-equal test
+    (* 36525l 86400l)
+    (Duration.to-seconds &(Duration.from-seconds (* 36525l 86400l)))
+    "from-seconds: holds a count wider than an Int")
+  (assert-true test
+    (Duration.=
+      &(Duration.from-seconds (Duration.to-seconds &(Duration.days 36525)))
+      &(Duration.days 36525))
+    "from-seconds: round-trips to-seconds"))
diff --git a/time.carp b/time.carp
index 0b19afe..bf21c3d 100644
--- a/time.carp
+++ b/time.carp
@@ -1236,7 +1236,9 @@ earlier. This is a timezone-unaware comparison, similar to `>` and `<`.")
           time-b (+ (* (Maybe.from @(hours b) 0) HOUR)
                     (+ (* (Maybe.from @(minutes b) 0) MINUTE)
                        (Maybe.from @(seconds b) 0)))]
-      (+ (* (- ord-a ord-b) DAY) (- time-a time-b))))
+      (+
+        (* (Long.from-int (- ord-a ord-b)) (Long.from-int DAY))
+        (Long.from-int (- time-a time-b)))))
 
   (doc add-months "adds `n` months to the `Datetime` `dt`.
 
@@ -1279,7 +1281,7 @@ clamped to February 28.
             @(nanoseconds dt)
             @(tz dt)))))
 
-(deftype Duration [seconds- Int])
+(deftype Duration [seconds- Long])
 
 (doc Duration "this is a complementary module to be used with `Datetime` and
 is concerned with adding and subtracting time values of the scale from seconds
@@ -1311,21 +1313,53 @@ Example:
   (private init)
 
   (doc seconds "creates a delta representing a number of seconds `n`.")
-  (defn seconds [n] (init n))
+  (defn seconds [n] (init (Long.from-int n)))
   (doc minutes "creates a delta representing a number of minutes `n`.")
-  (defn minutes [n] (seconds (* n 60)))
+  (defn minutes [n] (init (* (Long.from-int n) 60l)))
   (doc hours "creates a delta representing a number of hours `n`.")
-  (defn hours [n] (minutes (* n 60)))
+  (defn hours [n] (init (* (Long.from-int n) 3600l)))
   (doc days "creates a delta representing a number of days `n`.")
-  (defn days [n] (hours (* n 24)))
+  (defn days [n] (init (* (Long.from-int n) 86400l)))
   (doc weeks "creates a delta representing a number of weeks `n`.")
-  (defn weeks [n] (days (* n 7)))
+  (defn weeks [n] (init (* (Long.from-int n) 604800l)))
+
+  (doc from-seconds "creates a delta from a raw second count `n`.
+
+The unit constructors above take an `Int` count, which cannot name a delta
+longer than about 68 years in seconds. This is the counterpart to
+[`to-seconds`](#to-seconds), so a delta of any width round-trips through it.")
+  (defn from-seconds [n] (init n))
+
+  (private shift)
+  (hidden shift)
+  ; A Datetime names its date by an Int ordinal, and from-ordinal is only
+  ; defined from 1 up, so saturate at both ends rather than wrap out of the
+  ; representable range. The time-of-day remainder is applied first so that its
+  ; own day carry happens before the clamp and cannot push back out of range.
+  (defn shift [dt total]
+    (let-do [nd (Datetime.add-seconds dt (Long.to-int (mod total 86400l)))
+             ord (Long.clamp 1l
+                             (Long.from-int Int.MAX)
+                             (+
+                               (Long.from-int (Datetime.to-ordinal &nd))
+                               (/ total 86400l)))
+             d (Datetime.from-ordinal (Long.to-int ord))]
+      (Datetime.set-day! &nd @(Datetime.day &d))
+      (Datetime.set-month! &nd @(Datetime.month &d))
+      (Datetime.set-year! &nd @(Datetime.year &d))
+      nd))
+
+  (doc add "adds a `Duration` `delta` to a `Datetime` `dt`.
+
+A `Duration` can name a span far wider than the range of dates a `Datetime` can
+represent. A result that would fall outside that range saturates at the first
+or last representable date rather than wrapping.")
+  (defn add [dt delta] (shift dt @(seconds- delta)))
 
-  (doc add "adds a `Duration` `delta` to a `Datetime` `dt`.")
-  (defn add [dt delta] (Datetime.add-seconds dt @(seconds- delta)))
+  (doc sub "subtracts a `Duration` `delta` from a `Datetime` `dt`.
 
-  (doc sub "subtracts a `Duration` `delta` from a `Datetime` `dt`.")
-  (defn sub [dt delta] (Datetime.add-seconds dt (neg @(seconds- delta))))
+Out-of-range results saturate, as in [`add`](#add).")
+  (defn sub [dt delta] (shift dt (neg @(seconds- delta))))
 
   (doc between
     "returns the signed difference between the `Datetime`s `a` and `b`
@@ -1341,16 +1375,16 @@ earlier, mirroring `Datetime.diff`.")
 
   (doc to-minutes "returns the total number of whole minutes represented by the
 delta `d`, truncating any remaining seconds toward zero.")
-  (defn to-minutes [d] (/ @(seconds- d) 60))
+  (defn to-minutes [d] (/ @(seconds- d) 60l))
 
   (doc to-hours
     "returns the total number of whole hours represented by the delta
 `d`, truncating any remainder toward zero.")
-  (defn to-hours [d] (/ @(seconds- d) 3600))
+  (defn to-hours [d] (/ @(seconds- d) 3600l))
 
   (doc to-days "returns the total number of whole days represented by the delta
 `d`, truncating any remainder toward zero.")
-  (defn to-days [d] (/ @(seconds- d) 86400))
+  (defn to-days [d] (/ @(seconds- d) 86400l))
 
   (doc plus "returns the sum of the deltas `a` and `b`.")
   (defn plus [a b] (init (+ @(seconds- a) @(seconds- b))))
@@ -1362,32 +1396,32 @@ delta `d`, truncating any remaining seconds toward zero.")
   (defn negate [d] (init (neg @(seconds- d))))
 
   (doc scale "returns the delta `d` scaled by the integer factor `n`.")
-  (defn scale [d n] (init (* @(seconds- d) n)))
+  (defn scale [d n] (init (* @(seconds- d) (Long.from-int n))))
 
   (doc mul "returns the delta `d` scaled by the integer factor `n`. An alias for
 `scale`.")
   (defn mul [d n] (scale d n))
 
   (doc = "checks whether the deltas `a` and `b` represent the same duration.")
-  (defn = [a b] (Int.= @(seconds- a) @(seconds- b)))
+  (defn = [a b] (Long.= @(seconds- a) @(seconds- b)))
   (implements = Duration.=)
 
   (doc < "checks whether the delta `a` is shorter than the delta `b`.")
-  (defn < [a b] (Int.< @(seconds- a) @(seconds- b)))
+  (defn < [a b] (Long.< @(seconds- a) @(seconds- b)))
   (implements < Duration.<)
 
   (doc > "checks whether the delta `a` is longer than the delta `b`.")
-  (defn > [a b] (Int.> @(seconds- a) @(seconds- b)))
+  (defn > [a b] (Long.> @(seconds- a) @(seconds- b)))
   (implements > Duration.>)
 
   (doc zero? "checks whether the delta `d` represents no elapsed time.")
-  (defn zero? [d] (Int.= @(seconds- d) 0))
+  (defn zero? [d] (Long.= @(seconds- d) 0l))
 
   (doc pos? "checks whether the delta `d` represents a positive duration.")
-  (defn pos? [d] (Int.> @(seconds- d) 0))
+  (defn pos? [d] (Long.> @(seconds- d) 0l))
 
   (doc neg? "checks whether the delta `d` represents a negative duration.")
-  (defn neg? [d] (Int.< @(seconds- d) 0))
+  (defn neg? [d] (Long.< @(seconds- d) 0l))
 
   (doc str
     "renders the delta `d` in a human-readable form such as `1d 2h 3m 4s`.
@@ -1396,18 +1430,19 @@ Components that are zero are omitted, a negative delta is prefixed with a `-`,
 and the zero delta renders as `0s`.")
   (defn str [d]
     (let [total @(seconds- d)]
-      (if (Int.= total 0)
+      (if (Long.= total 0l)
         @"0s"
-        (let-do [a (Int.abs total)
-                 days (/ a 86400)
-                 hrs (/ (mod a 86400) 3600)
-                 mins (/ (mod a 3600) 60)
-                 secs (mod a 60)
+        (let-do [a (Long.abs total)
+                 days (/ a 86400l)
+                 hrs (Long.to-int (/ (mod a 86400l) 3600l))
+                 mins (Long.to-int (/ (mod a 3600l) 60l))
+                 secs (Long.to-int (mod a 60l))
                  parts []]
-          (when (> days 0) (Array.push-back! &parts (fmt "%dd" days)))
+          (when (> days 0l)
+            (Array.push-back! &parts (fmt "%sd" &(Long.str days))))
           (when (> hrs 0) (Array.push-back! &parts (fmt "%dh" hrs)))
           (when (> mins 0) (Array.push-back! &parts (fmt "%dm" mins)))
           (when (> secs 0) (Array.push-back! &parts (fmt "%ds" secs)))
           (String.concat
-            &[(if (Int.< total 0) @"-" @"") (String.join " " &parts)])))))
+            &[(if (Long.< total 0l) @"-" @"") (String.join " " &parts)])))))
   (implements str Duration.str))