Bläddra i källkod

Calendar arithmetic for DateTimes

David Peter 1 år sedan
förälder
incheckning
46bbe68493

+ 27 - 16
book/src/date-and-time.md

@@ -1,17 +1,11 @@
 # Date and time
 
 Numbat supports date and time handling based on the [proleptic Gregorian calendar](https://en.wikipedia.org/wiki/Proleptic_Gregorian_calendar),
-which is the (usual) Gregorian calendar extended to dates before its introduction in 1582. Julian calendar dates are currently not supported.
+which is the (usual) Gregorian calendar extended to dates before its introduction in 1582.
 
 A few examples of useful operations that can be performed on dates and times:
 
 ```nbt
-# Which date is 40 days from now?
-now() + 40 days
-
-# Which date was 1 million seconds ago?
-now() - 1 million seconds
-
 # How many days are left until September 1st?
 date("2024-11-01") - today() -> days
 
@@ -21,13 +15,22 @@ now() -> tz("Asia/Kathmandu")  # use tab completion to find time zone names
 # What is the local time when it is 2024-11-01 12:30:00 in Australia?
 datetime("2024-11-01 12:30:00 Australia/Sydney") -> local
 
+# Which date was 1 million seconds ago?
+now() - 1 million seconds
+
+# Which date is 40 days from now?
+calendar_add(now(), 40 days)
+
+# Which weekday was the 1st day of this century?
+date("2000-01-01") -> weekday
+
 # What is the current UNIX timestamp?
 now() -> unixtime
 
-# What is the date corresponding to the UNIX timestamp 1707568901?
+# What is the date corresponding to a given UNIX timestamp?
 from_unixtime(1707568901)
 
-# How long are one million seconds in days, hours, minutes, seconds
+# How long are one million seconds in days, hours, minutes, seconds?
 1 million seconds -> human
 ```
 
@@ -44,12 +47,16 @@ The following operations are supported for `DateTime` objects:
 
 <div class="warning">
 
-**Warning**: You can add `years` or `months` to a given date (`now() + 3 months`), but note that the result might not be what you expect.
-The unit `year` is defined as the *average* length of a year (a [tropical year](https://en.wikipedia.org/wiki/Tropical_year), to be precise), and
-`month` is defined as the *average* length of a month (1/12 of a `year`). So this does not take into account the actual length of the months or the leap years.
-However, note that adding or subtracting "one year" or "one month" is not a well-defined operation anyway. For example, what should "one month after March 31st"
-be? April 30th or May 1st? If your answer is April 30th, then what is "one month after March 30th"? If your answer is May 1st, then what is "one month after
-April 1st"?
+**Warning**: You can directly add `days`, `months` and `years` to a given date (`now() + 3 months`), but note that the result might not be what you expect.
+The unit `day` is defined as having a length of 24 hours. But due to daylight
+saving time, days can be shorter or longer than that. A `month` is defined
+as 1/12 of a `year`, but calendar months have varying lengths. And a `year`
+is defined as the average length of a
+[tropical](https://en.wikipedia.org/wiki/Tropical_year) year. But a calendar
+year can have 365 or 366 days, depending on whether it is a leap year or not.
+
+If you want to take all of these factors into account, you should use the `calendar_add`/`calendar_sub` functions instead of directly adding or
+subtracting `days`, `months`, or `years`.
 
 </div>
 
@@ -68,7 +75,11 @@ The following functions are available for date and time handling:
 - `get_local_timezone() -> String`: Returns the users local timezone
 - `unixtime(dt: DateTime) -> Scalar`: Converts a `DateTime` to a UNIX timestamp.
 - `from_unixtime(ut: Scalar) -> DateTime`: Converts a UNIX timestamp to a `DateTime` object.
-- `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds
+- `calendar_add(dt: DateTime, span: Time)`: Add a span of time to a `DateTime` object, taking proper calendar arithmetic into accound.
+- `calendar_sub(dt: DateTime, span: Time)`: Subtract a span of time from a `DateTime` object, taking proper calendar arithmetic into accound.
+- `weekday(dt: DateTime) -> String`: Returns the weekday of a `DateTime` object as a string.
+- `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds.
+- `julian_date(dt: DateTime) -> Scalar`: Convert a `DateTime` to a [Julian date](https://en.wikipedia.org/wiki/Julian_day).
 
 ## Date time formats
 

+ 21 - 0
book/src/list-functions-datetime.md

@@ -74,6 +74,27 @@ Parses a string (time only) into a `DateTime` object.
 fn time(input: String) -> DateTime
 ```
 
+### `calendar_add`
+Adds the given time span to a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.
+
+```nbt
+fn calendar_add(dt: DateTime, span: Time) -> DateTime
+```
+
+### `calendar_sub`
+Subtract the given time span from a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.
+
+```nbt
+fn calendar_sub(dt: DateTime, span: Time) -> DateTime
+```
+
+### `weekday`
+Get the day of the week from a given `DateTime`.
+
+```nbt
+fn weekday(dt: DateTime) -> String
+```
+
 ### `julian_date` (Julian date)
 Convert a `DateTime` to a Julian date, the number of days since the origin of the Julian date system (noon on November 24, 4714 BC in the proleptic Gregorian calendar).
 More information [here](https://en.wikipedia.org/wiki/Julian_day).

+ 27 - 0
examples/tests/datetime.nbt

@@ -93,6 +93,33 @@ assert_eq(from_unixtime(1658346725), dt_unixtime_1)
 
 
 
+# Calendar arithmetic
+
+let dt_start = datetime("2024-03-30 12:00:00 Europe/Berlin") # one day before DST starts
+
+# If we simply add "1 day == 24 hours", we end up at 13:00 on the next day:
+assert_eq(dt_start + 1 day, datetime("2024-03-31 13:00:00 Europe/Berlin"))
+
+# If we use DST-aware calendar arithmetic, we end up at 12:00 on the next day:
+assert_eq(calendar_add(dt_start, 1 day), datetime("2024-03-31 12:00:00 Europe/Berlin"))
+assert_eq(calendar_add(dt_start, 2 days), datetime("2024-04-01 12:00:00 Europe/Berlin"))
+
+assert_eq(calendar_add(dt_start, 3 months), datetime("2024-06-30 12:00:00 Europe/Berlin"))
+assert_eq(calendar_add(dt_start, 12 months), datetime("2025-03-30 12:00:00 Europe/Berlin"))
+
+assert_eq(calendar_add(dt_start, 10 years), datetime("2034-03-30 12:00:00 Europe/Berlin"))
+
+assert_eq(calendar_add(dt_start, 1 second), datetime("2024-03-30 12:00:01 Europe/Berlin"))
+assert_eq(calendar_add(dt_start, 1 minute), datetime("2024-03-30 12:01:00 Europe/Berlin"))
+assert_eq(calendar_add(dt_start, 1 hour), datetime("2024-03-30 13:00:00 Europe/Berlin"))
+
+
+
+# Weekday
+
+assert_eq(date("2024-08-01") -> weekday, "Thursday")
+
+
 # Julian date
 
 let dt_jd = datetime("2013-01-01 00:30:00 UTC")

+ 25 - 0
numbat/modules/datetime/functions.nbt

@@ -1,5 +1,7 @@
 use core::strings
+use core::quantities
 use units::si
+use units::time
 
 @description("Returns the current date and time.")
 fn now() -> DateTime
@@ -43,6 +45,29 @@ fn date(input: String) -> DateTime =
 fn time(input: String) -> DateTime =
   datetime("{_today_str()} {input}")
 
+fn _add_days(dt: DateTime, n_days: Scalar) -> DateTime
+fn _add_months(dt: DateTime, n_months: Scalar) -> DateTime
+fn _add_years(dt: DateTime, n_years: Scalar) -> DateTime
+
+@description("Adds the given time span to a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.")
+fn calendar_add(dt: DateTime, span: Time) -> DateTime =
+   if unit_of(span) == days
+     then _add_days(dt, span / days)
+     else if unit_of(span) == months
+       then _add_months(dt, span / months)
+       else if unit_of(span) == years
+         then _add_years(dt, span / years)
+         else if unit_of(span) == seconds || unit_of(span) == minutes || unit_of(span) == hours
+           then dt + span
+           else error("calendar_add: Unsupported unit: {unit_of(span)}")
+
+@description("Subtract the given time span from a `DateTime`. This uses leap-year and DST-aware calendar arithmetic with variable-length days, months, and years.")
+fn calendar_sub(dt: DateTime, span: Time) -> DateTime =
+  calendar_add(dt, -span)
+
+@description("Get the day of the week from a given `DateTime`.")
+fn weekday(dt: DateTime) -> String = format_datetime("%A", dt)
+
 @name("Julian date")
 @description("Convert a `DateTime` to a Julian date, the number of days since the origin of the Julian date system (noon on November 24, 4714 BC in the proleptic Gregorian calendar).")
 @url("https://en.wikipedia.org/wiki/Julian_day")

+ 39 - 0
numbat/src/ffi/datetime.rs

@@ -1,5 +1,7 @@
+use jiff::Span;
 use jiff::Timestamp;
 use jiff::Zoned;
+use num_traits::ToPrimitive;
 
 use super::macros::*;
 use super::Args;
@@ -67,3 +69,40 @@ pub fn from_unixtime(mut args: Args) -> Result<Value> {
 
     return_datetime!(dt)
 }
+
+fn calendar_add(
+    mut args: Args,
+    unit_name: &str,
+    to_span: fn(i64) -> std::result::Result<Span, jiff::Error>,
+) -> Result<Value> {
+    let dt = datetime_arg!(args);
+    let n = quantity_arg!(args).unsafe_value().to_f64();
+
+    if n.fract() != 0.0 {
+        return Err(RuntimeError::UserError(format!(
+            "calendar_add: requires an integer number of {unit_name}s"
+        )));
+    }
+
+    let n_i64 = n.to_i64().ok_or_else(|| {
+        RuntimeError::UserError(format!("calendar:add: number of {unit_name}s is too large",))
+    })?;
+
+    let output = dt
+        .checked_add(to_span(n_i64).map_err(|_| RuntimeError::DurationOutOfRange)?)
+        .map_err(|_| RuntimeError::DateTimeOutOfRange)?;
+
+    return_datetime!(output)
+}
+
+pub fn _add_days(args: Args) -> Result<Value> {
+    calendar_add(args, "day", |n| Span::new().try_days(n))
+}
+
+pub fn _add_months(args: Args) -> Result<Value> {
+    calendar_add(args, "month", |n| Span::new().try_months(n))
+}
+
+pub fn _add_years(args: Args) -> Result<Value> {
+    calendar_add(args, "year", |n| Span::new().try_years(n))
+}

+ 4 - 0
numbat/src/ffi/functions.rs

@@ -95,6 +95,10 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
         insert_function!(unixtime, 1..=1);
         insert_function!(from_unixtime, 1..=1);
 
+        insert_function!(_add_days, 2..=2);
+        insert_function!(_add_months, 2..=2);
+        insert_function!(_add_years, 2..=2);
+
         // Currency
         insert_function!(exchange_rate, 1..=1);