Awarded: ICDevs.org Bounty #53 - DateTime Library Motoko - $10,000

I’m hoping to at least split up the repo into a couple packages so the timezone part/bloat is optional

2 Likes

Just an aside, great call on iterating interactively.

1 Like

Missed last week but not too much to show. Ill put my current API here for anyone to comment on/make suggestions. This is not anything final, just what I have so far.

Types:


    public type Duration = {
        #nanoseconds : Int;
        #milliseconds : Int;
        #seconds : Int;
        #minutes : Int;
        #hours : Int;
        #days : Int;
        #weeks : Int;
        #months : Int;
        #years : Int;
    };

    public type Components = {
        year : Int;
        month : Nat;
        day : Nat;
        hour : Nat;
        minute : Nat;
        nanosecond : Nat;
    };

    public type TextFormat = {
        #iso8601;
    };

    public type TimeZoneDescriptor = {
        #unspecified;
        #utc;
        #hoursAndMinutes : (Int, Nat);
    };

    public type TimeZone = {
        #fixed : FixedTimeZone;
        #dynamic : DynamicTimeZone;
    };

    public type DynamicTimeZone = {
        getOffsetSeconds : (components : Components) -> Int;
    };

    public type FixedTimeZone = {
        #seconds : Int;
        #hoursAndMinutes : (Int, Nat);
    };

Module: Components


    public type FromTextResult = {
        components : Components;
        timeZoneDescriptor : TimeZoneDescriptor;
    };

    /// Returns the the epoch (1970-01-01T00:00:00Z) in component form
    ///
    /// ```motoko include=import
    /// let epoch : Components.Components = Components.epoch();
    /// ```
    public func epoch() : Components ;

    /// Compares two components, returning the order between them.
    /// Will return null if either of the components are invalid
    ///
    /// ```motoko include=import
    /// let c1 : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let c2 : Components.Components = {year = 2020; month = 2; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let ?order : ?Order.Order = Components.compare(c1, c2) else return #error("One or both components are invalid");
    /// ```
    public func compare(c1 : Components, c2 : Components) : ?Order.Order;

    /// Compares two components, returning the order between them.
    /// Will trap if either of the components are invalid
    ///
    /// ```motoko include=import
    /// let c1 : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let c2 : Components.Components = {year = 2020; month = 2; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let order : Order.Order = Components.compareOrTrap(c1, c2);
    /// ```
    public func compareOrTrap(c1 : Components, c2 : Components) : Order.Order;

    /// Converts the components to the equivalent UTC time in nanoseconds since the epoch.
    /// Will return null if the components are invalid
    ///
    /// ```motoko include=import
    /// let c : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let ?order : ?Time.Time = Components.toTime(c) else return #error("Components are invalid");
    /// ```
    public func toTime(components : Components) : ?Time.Time;

    /// Converts the UTC time in nanoseconds since the epoch to the equivalent components.
    ///
    /// ```motoko include=import
    /// let components : Components.Components = Components.fromTime(123467890);
    /// ```
    public func fromTime(nanoseconds : Int) : Components;

    /// Checks if the specified components are valid.
    /// Checks that the day is valid for the month and year, and that the time is valid.
    /// Returns true if the components are valid, false otherwise.
    ///
    /// ```motoko include=import
    /// let c : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let isValid : Bool = Components.isValid(c);
    /// ```
    public func isValid(components : Components) : Bool;

    /// Converts datetime components to text in ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let c : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let text : Text = Components.toText(c, TimeZone.utc());
    /// ```
    public func toText(components : Components, timeZone : TimeZoneDescriptor) : Text;

    /// Converts datetime components to the specified text format.
    ///
    /// Formats:
    /// - `#iso8601` - ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let c : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let text : Text = Components.toTextFormatted(c, #iso8601, TimeZone.utc());
    /// ```
    public func toTextFormatted(components : Components, timeZone : TimeZoneDescriptor, format : InternalTypes.TextFormat) : Text;

    /// Parses a formatted datetime text into components and timezone with the specified format.
    /// Returns null if the text is not a valid formatted datetime
    /// Formats:
    /// - `#iso8601` - ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let ?result : ?FromTextResult = Components.fromTextFormatted("2020-01-01T00:00:00Z", #iso8601) else return #error("Invalid datetime text");
    /// ```
    public func fromTextFormatted(text : Text, format : TextFormat) : ?FromTextResult;


    public func addTime(components : Components, nanoseconds : Time.Time) : Components;

Module: DateTime

    /// Creates an instance of the `DateTime` type from a `Time.Time` value.
    ///
    /// ```motoko include=import
    /// let nowTime : Time.Time = Time.now();
    /// let nowDateTime : DateTime.DateTime = DateTime(time);
    /// ```
    public func DateTime(time : Time.Time) : DateTime = object {
        /// Adds a `Duration` and returns the resulting new `DateTime` value.
        /// Does not modify the current `DateTime` value.
        ///
        /// ```motoko include=import
        /// let now : DateTime.DateTime = DateTime.now();
        /// let fourDays : DateTime.Duration = #days(4);
        /// let fourDaysFromNow : DateTime.DateTime = now.add(fourDays);
        /// ```
        public func add(duration : Duration) : DateTime;


        // TODO rename
        /// Calculates the time difference between this `DateTime` and another `DateTime` value.
        /// Will return a negative value if the other `DateTime` is in the future compared with this `DateTime` value.
        ///
        /// ```motoko include=import
        /// let dateTime : DateTime.DateTime = DateTime.now();
        /// let otherDateTime : DateTime.DateTime = DateTime.fromText("2021-01-01T00:00:00.000Z");
        /// let timeSince : Time.Time = dateTime.timeSince(otherDateTime);
        /// ```
        public func timeSince(other : DateTime) : Time.Time;

        /// Creates a `Time.Time` (nanoseconds since epoch) value from a `DateTime` value.
        ///
        /// ```motoko include=import
        /// let dateTime : DateTime.DateTime = DateTime.now();
        /// let nanoseconds : Time.Time = dateTime.toTime();
        /// ```
        public func toTime() : Time.Time;

        /// Formats the `DateTime` as Text value using the ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
        ///
        /// ```motoko include=import
        /// let dateTime : DateTime.DateTime = DateTime.now();
        /// let dateTimeText : Text = dateTime.toText();
        /// ```
        public func toText() : Text;

        /// Formats the `DateTime` as Text value using the given format.
        ///
        /// Formats:
        /// - `#iso8601` - ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
        ///
        /// ```motoko include=import
        /// let dateTime : DateTime.DateTime = DateTime.now();
        /// let dateTimeText : Text = dateTime.toTextFormatted(#iso8601);
        /// ```
        public func toTextFormatted(format : TextFormat) : Text;

        
        /// Creates a `Components` from a `DateTime` value.
        ///
        /// ```motoko include=import
        /// let datetime : DateTime.DateTime = DateTime.now();
        /// let components : Components.Components = datetime.toComponents();
        /// ```
        public func toComponents() : Components;

        /// Checks if the `DateTime` is in a leap year.
        ///
        /// ```motoko include=import
        /// let datetime : DateTime.DateTime = DateTime.now();
        /// let isInLeapYear : Bool = datetime.isInLeapYear();
        /// ```
        public func isInLeapYear() : Bool;

        /// Compares this `DateTime` with another `DateTime` value.
        ///
        /// ```motoko include=import
        /// let a : DateTime.DateTime = DateTime.fromTime(...);
        /// let b : DateTime.DateTime = DateTime.fromTime(...);
        /// let order : Order.Order = a.compare(b);
        /// ```
        public func compare(other : DateTime) : Order.Order;
        
        /// Checks the equality of this `DateTime` with another `DateTime` value.
        ///
        /// ```motoko include=import
        /// let a : DateTime.DateTime = DateTime.fromTime(...);
        /// let b : DateTime.DateTime = DateTime.fromTime(...);
        /// let areEqual : Bool= a.equal(b);
        /// ```
        public func equal(other : DateTime) : Bool;
    };

    /// Checks the equality of two `DateTime` values.
    ///
    /// ```motoko include=import
    /// let a : DateTime.DateTime = DateTime.fromTime(...);
    /// let b : DateTime.DateTime = DateTime.fromTime(...);
    /// let equal : Bool = DateTime.equal(a, b);
    /// ```
    public func equal(a : DateTime, b : DateTime) : Bool;

    /// Compares two `DateTime` values and returns their order.
    ///
    /// ```motoko include=import
    /// let a : DateTime.DateTime = DateTime.fromTime(...);
    /// let b : DateTime.DateTime = DateTime.fromTime(...);
    /// let order : Order.Order = DateTime.compare(a, b);
    /// ```
    public func compare(a : DateTime, b : DateTime) : Order.Order;

    /// Creates a `DateTime` for the current time
    ///
    /// ```motoko include=import
    /// let now : DateTime.DateTime = DateTime.now();
    /// ```
    public func now() : DateTime;

    /// Creates a `DateTime` from a `Time.Time` (nanoseconds since epoch) value.
    /// (Same functionality as DateTime constructor)
    ///
    /// ```motoko include=import
    /// let nanoseconds : Time.Time = Time.now();
    /// let dateTime : DateTime.DateTime = DateTime.fromTime(nanoseconds);
    /// ```
    public func fromTime(nanoseconds : Time.Time) : DateTime;

    /// Creates a `DateTime` from a `Components` value.
    /// Returns null if the `Components` value is invalid.
    ///
    /// ```motoko include=import
    /// let components : Components.Components = { 
    ///     year = 2021;
    ///     month = 1;
    ///     day = 1;
    ///     hour = 0;
    ///     minute = 0;
    ///     nanosecond = 0;
    /// };
    /// let ?dateTime : ?DateTime.DateTime = DateTime.fromComponents(components) else return #error("Invalid date");
    /// ```
    public func fromComponents(components : Components) : ?DateTime;


    /// Formats the `DateTime` as Text value using the ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let dateTime : DateTime.DateTime = DateTime.now();
    /// let dateTimeText : Text = DateTime.toText(dateTime);
    /// ```
    public func toText(dateTime : DateTime) : Text;

    /// Formats the `DateTime` as Text value using the given format.
    ///
    /// Formats:
    /// - `#iso8601` - ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let dateTime : DateTime.DateTime = DateTime.now();
    /// let dateTimeText : Text = DateTime.toTextFormatted(datetime, #iso8601);
    /// ```
    public func toTextFormatted(dateTime : DateTime, format : TextFormat) : Text;

    /// Parses the Text value as a `DateTime` using the given format.
    /// Returns null if the Text value is invalid.
    ///
    /// Formats:
    /// - `#iso8601` - ISO 8601 format (e.g. `2021-01-01T00:00:00.000Z`)
    ///
    /// ```motoko include=import
    /// let dateTimeText : Text = "2021-01-01T00:00:00.000Z";
    /// let ?dateTime : ?DateTime.DateTime = DateTime.fromTextFormatted(dateTimeText, #iso8601) else return #error("Invalid date");
    /// ```
    public func fromTextFormatted(text : Text, format : TextFormat) : ?DateTime;

Module: LocalDateTime



    public func LocalDateTime(components : Components, timeZone : TimeZone) : LocalDateTime = object {

        public func equal(other : LocalDateTime) : Bool;

        public func add(duration : DateTime.Duration) : LocalDateTime;

        public func nanosecondsSince(other : LocalDateTime) : Int;

        public func toTime() : Time.Time;

        public func toText() : Text;

        public func toTextFormatted(format : DateTime.TextFormat) : Text;

        public func toComponents() : Components.Components;

        public func isInLeapYear() : Bool;

        public func compare(other : LocalDateTime) : Order.Order;

        public func toUtcDateTime() : DateTime.DateTime;

        public func getOffsetSeconds() : Int;

        public func getTimeZoneDescriptor() : TimeZoneDescriptor;
    };

    public func fromComponents(components : Components, timeZone : TimeZone) : ?LocalDateTime;

    public func equal(a : LocalDateTime, b : LocalDateTime) : Bool;

    public func now(timeZone : TimeZone) : LocalDateTime;

    public func fromTime(nanoseconds : Time.Time, timeZone : TimeZone) : LocalDateTime;

    public func toText(dateTime : LocalDateTime) : Text;

    public func toTextFormatted(dateTime : LocalDateTime, format : TextFormat) : Text;

    public func fromTextFormatted(text : Text, format : TextFormat) : ?LocalDateTime;

Module: TimeZone



    public func utc() : TimeZone;

    public func withFixedOffset(offset : Offset) : TimeZone;
    
    /// Gets the UTC offset in seconds for the specified components and time zone.
    /// The components are used if the timezone is dynamic. This is due to the timezone offset
    /// being dependent on the date (daylight savings, changes to locale offset, etc...).
    ///
    /// ```motoko include=import
    /// let timeZone : TimeZone.TimeZone = ...;
    /// let c : Components.Components = {year = 2020; month = 1; day = 1; hour = 0; minute = 0; nanosecond = 0};
    /// let offsetSeconds : Int = TimeZone.getOffsetSeconds(timeZone, c);
    /// ```
    public func getOffsetSeconds(timeZone : TimeZone, components : Components) : Int;

    /// Gets the UTC offset in seconds for the specified fixed time zone.
    ///
    /// ```motoko include=import
    /// let timeZone : TimeZone.TimeZone = #fixed(#hoursAndMinutes(1, 0));
    /// let offsetSeconds : Int = TimeZone.getFixedOffsetSeconds(timeZone);
    /// ```
    public func getFixedOffsetSeconds(fixedTimeZone: FixedTimeZone) : Int;

Nothing big to show as an update, just been filling out tests before I go to implement Locale based timezones and add more advanced features.

I am implementing some more text formats and was wondering if there was any preference on what datetime text format is used? I was thinking something at least very close to strftime or Rust chromo strftime which are like %Y-%m-%d
I have used others like C# datetime formats which are more of yyyy-MM-dd

Not much to show but I have started working on the IANA TZ database parsing.

My current approach is to create a script to convert the db files to motoko source code. So if there are any new databases, then the script can be run to update the source.

Im probably going to split them into different libraries due to size, but for now I am keeping them one for simplicity

1 Like

UPDATE 2023-06-26

I have cracked the IANA data and I feel pretty good about it

  • I switched to use momentjs data as my data source vs trying to parse the IANA data itself https://momentjs.com/. This allows me to just write a JS script and library (moment) vs having to manually parse the files. Also the strftime just seemed not intuitive and dated, so went the momentjs route of YYYY-MM-dd route
  • I am using the JS script to generate Motoko files that are essentially giant arrays/data structures that hold all the data I need
  • I split up the generated Motoko into different files. 1 file per timezone top level group like ‘America.mo’ which has ‘America/Los_Angeles’ and 1 file per locale like ‘EN_AU.mo’ for ‘en_AU’
  • Using generated motoko files with different files allows an à la carte model where you only include the code that is relevant to you or none at all.
  • If you dont know before hand the ones you need, all can be imported/found with ‘RegionFinder’/‘LocaleFinder’ or use ‘RegionList’/‘LocaleList’ for an entire list (also pregenerated source code’
  • Whenever the timezones/locales need to be updated, there are scripts to just regenerate the files

Sample:
No regional timezones or locales

import LocalDateTime "LocalDateTime";

let components = {
    year = 2023;
    month = 1;
    day = 1;
    hour = 0;
    minute = 0;
    nanosecond = 0;
};
let timeZone = #fixed(#seconds(0));

LocalDateTime.LocalDateTime(components, timeZone);

Compiled Size: 203KB

Single Regional Timezone/Locale

import EN "../iana/locales/EN";
import America "../iana/timezones/America";
import LocalDateTime "LocalDateTime";
import RegionalTimeZone "RegionalTimeZone";

let components = {
    year = 2023;
    month = 1;
    day = 1;
    hour = 0;
    minute = 0;
    nanosecond = 0;
};
let region = America.Los_Angeles.region;
let locale = EN.locale;
let timeZone = #dynamic(RegionalTimeZone.RegionalTimeZone(region));

LocalDateTime.LocalDateTime(components, timeZone);

Compiled Size: 219KB

Region/Locale Finder

import LocalDateTime "LocalDateTime";
import RegionalTimeZone "RegionalTimeZone";
import RegionFinder "../iana/RegionFinder";
import LocaleFinder "../iana/LocaleFinder";

let components = {
    year = 2023;
    month = 1;
    day = 1;
    hour = 0;
    minute = 0;
    nanosecond = 0;
};
let ?region = RegionList.find("America/Los_Angeles") else Debug.trap("Region not found");
let ?locale = LocaleFinder.find("en") else Debug.trap("Locale not found");
let timeZone = #dynamic(RegionalTimeZone.RegionalTimeZone(region));

LocalDateTime.LocalDateTime(components, timeZone);

Compiled Size: 1389KB

There are more optimizations to do but i thought that was pretty cool

1 Like

UPDATE 2023-07-05

Been having some issues integrating the toText and fromText functionality utilizing locales. There is a lot of complexity and weird cases to handle so its been taking longer than i expected. I have also struggled specifially with Meridiems (AM/PM) and Oridnals (1st, 2nd, …) because not all locales follow the same pattern. Moment handles this using custom code per locale and regex per locale. Both of which are issues because I cant translate the custom code from JS to Motoko. I tried a few work arounds sine MOST follow a pattern but there is always at least one that just blows it up. Also since Motoko doesnt have Regex libraries yet, I cant use what is provided. For now I am disabling some parsing around these cases temporarily until I find a better solution. I am going to try to finish the basic version then come back to it. I might need another data source than momentjs for some of these

2 Likes

UPDATE 2023-07-13
Pre-Final Review

I believe I am at a place to get a review of current functionality and naming.
Before I put on the final polish, I want to get some feedback to not have to redocument after changes made
Things to be done still:

  • Add README docs
  • Final pass on function/type docs
  • Meridiem/Ordinal parsing/date formatting (right now its essentially hard coded to AM/PM and english ordinals)
  • A few more tests

@skilesare @icme @quint or anyone else, now would be the time for anymore feedback.

Here is a link to the generate docs: https://github.com/edjCase/motoko_datetime/blob/main/docs/index.md

If there is no feedback then ill go ahead and polish up and submit for final review

1 Like

Also Ill just give an overview of my library

Components - Raw date and time fields that represent a specific date/time with no context of timezone or ‘instance in time’

DateTime - UTC based datetime with no timezone context or functionality. If need, can convert to a LocalDateTime

LocalDateTime - Timezone based datetime more complex functionality than DateTime

TimeZone - Fixed offset or dynamic. Dynamic are timezones with different offsets based on raw date (Components)

IanaTimeZone - A dynamic implementation of a timezone based off IANA timezone data. Timezone data can be retrieved with direct reference to any file in iana/timezones or found with iana/TimeZoneFinder or iana/TimeZoneList.

Locale - Location specific datetime formatting information. Like timezones can be gotten from iana/locales or iana/LocaleFinder or iana/LocaleList

2 Likes

Alright. I updated a few things and threw some polish on it

Since there was no feedback im going to officially request a final review by ICDevs @skilesare

3 Likes

Is this opened to multiple developers, I want to take the challenge, with timezone, daylight savings, leapyear and internalisation support, the inspiration is to do a total port of momentjs to Motoko

2 Likes

Feel free to add onto this. Could use more love in the localization department like mentioned above
But you’ll have to talk with icdevs if you are looking for compensation

1 Like

Gekctek has finished this bounty. You’re welcome to add things to the library if you need them, but the official bounty is closed.

I was just browsing ICDevs site. Looks like this bounty is still labeled as open

Might have someone interested in doing some other bounties, so I’m showing them

It is on my list to get everything updated both in the forum and on the ICDevs site. We’ll have some EVM stuff soon(waiting for the bitcoin stuff to withdraw from PGN -7 day wait!!!) and I’m working on what I hope will be some cool new stuff around bounties that cater the larger ecosystem. All the current ICDevs bounties are Closed or being wrapped up by someone. More soon!

1 Like