For Userscript Developers - Functions to Validate and Parse Datetime

I have written Javascript datetime validation and parsing functions for use with wkof. They are interpreting time in the user local time zone and they properly handle leap years. Some of these functions support relative time.

The validation functions comply with the wkof requirements for a validate callback in a settings dialog. They accept a text input and return either true (if the datetime is valid) or an error text string that will be displayed to the user. If the user messed the datetime format they are presented with the format that is expected from them. If the format is correct but the numbers are invalid for a date they are presented with a ‘Number out of range’ message.

The functions provided:

  • validateDateTime

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

This function validates datetime according to the YYYY-MM-DD HH:MM format. It accepts both 12 hours and 24 hours formats. The time (HH:MM) is optional. Seconds and milliseconds are not supported.

  • validateDateTimeRelative

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

Same as validateDateTime with added support for relative time in the format ±24d10h30m. Days, hours and minutes are optional as long as one of them is present.

  • validateDateTimeFull

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

This function validates datetime according to the YYYY-MM-DD HH:MM:SS.mmm format. Seconds and milliseconds are optional. For use when support for seconds and/or millisecond is wanted.

  • validateDateTimeFullRelative

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

Same as validateDateTimeFull with added support for relative time in the format ±24d10h30m15s. Days, hours, minutes and seconds are optional as long as one of them is present.

  • validateDate

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

This function validates a date in the YYYY-MM-DD format.

  • validateDateRelative

Parameter: a string representing the date to validate.
Returns: true if the date is valid. A string for the error message if the date is invalid.

Same as validateDate with added support for relative time in the format ±24d.

  • parseDateTime

Parameter: a string representing the date to validate.
Returns: A Date object for the date, interpreted in the end user local timezone. An invalid date Date object is returned in case of an error.

This functions parses a date that has been validated with one of the six previous functions.

  • It accepts all three formats supported by the three validation functions as well as relative time…
  • It interprets the date in the local time zone and returns the corresponding Date object.
  • Conversion from 12 hours to 24 hours is performed when needed.
  • Relative time is interpreted as an offset from when the function is called.

The data should be validated. It will return an invalid Date object if an error occurs but the input is not otherwise validated. If you have doubts about data quality validate first.

The code

Permission is granted to everyone to use this code.

    //=======================================
    // Date Validation and Parsing Functions
    //=======================================

   //=======================================
    // All time validation functions and the parsing function accept
    // YYYY-MM-DD 24:00 to mean next day at 00:00
    // According to wikipedia this is part of the 24 hours time comvention
    //=======================================

    //=======================================
    // This group of functions nails the format to YYYY-MM-DD something
    //=======================================
    // Error messages
    const errorWrongDateTimeFormat = 'Use YYYY-MM-DD HH:MM [24h, 12h]';
    const errorWrongDateTimeRelativeFormat = 'Use YYYY-MM-DD HH:MM [24h, 12h]<br>Or +10d3h45m or -4h12h30m<br>+- needed, rest may be omitted';
    const errorWrongDateTimeFullFormat = 'Use YYYY-MM-DD HH:MM:SS.mmm<br>Seconds and milliseconds optional';
    const errorWrongDateTimeFullRelativeFormat = 'Use YYYY-MM-DD HH:MM:SS.mmm<br>Seconds and milliseconds optional<br>Or +10d3h45m12s -4h12h30m10s<br>+- needed, rest may be omitted';
    const errorWrongDateFormat = 'Invalid date - Use YYYY-MM-DD';
    const errorWrongDateRelativeFormat = 'Invalid date - Use YYYY-MM-DD<br>Or +10d or -2d';
    const errorOutOfRange = 'Number out of range';

    //=======================================
    // Validates datetime in YYYY-MM-DD HH:MM format
    // Accepts both 24h and 12h formats (am pm)
    // Accepts YYYY-MM-DD (HH:MM omitted)
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDateTime(dateString, config){
        dateString = dateString.trim();
        if (dateString.length > 18){
           return errorWrongDateTimeFormat;
        } else {
            let result = validateDate(dateString.slice(0,10), config);
            if (result === errorOutOfRange) return errorOutOfRange;
            if (result !== true) return errorWrongDateTimeFormat;
            if (dateString.length === 10) return true; //Valid YYY-MM-DD and nothing else
            result = validateTime(dateString.slice(0,16));
            if (result === errorOutOfRange) return errorOutOfRange;
            if (result !== true) return errorWrongDateTimeFormat;
            if (dateString.length === 16){
                return true
            } else {
                if (dateString.length === 18){
                    let suffix = dateString.slice(16)
                    if (suffix === 'am' || suffix === 'pm'){
                        let hh = Number(dateString.slice(11, 13))
                        if (hh < 1 || hh > 12){return errorOutOfRange}
                        return true
                    } else {
                        return errorWrongDateTimeFormat;
                    }
                }
                return errorWrongDateTimeFormat;
            };
        };
        return errorWrongDateTimeFormat;
    };

    //=======================================
    // Validates datetime in YYYY-MM-DD HH:MM format or relative time format
    // Accepts both 24h and 12h formats (am pm)
    // Accepts YYYY-MM-DD (HH:MM omitted)
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDateTimeRelative(dateString, config){
        dateString = dateString.trim();
        if (dateString.match(/^([+-])(?:(\d+)[dD])?(?:(\d+)[hH])?(?:(\d+)[mM])?$/) !== null){
            if (dateString === '+' || dateString === '-') return errorWrongDateTimeRelativeFormat
            return true;
        } else {
            let result = validateDateTime(dateString, config)
            if (result === true || result === errorOutOfRange) return result;
            return errorWrongDateTimeRelativeFormat;
        }
    };

    //=======================================
    // Validate datetime in YYYY-MM-DD HH:MM:SS.mmm format
    // Seconds and milliseconds are optional
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDateTimeFull(dateString, config){
        dateString = dateString.trim();
        let result = validateDateTime(dateString.slice(0, 16), config);
        if (result === errorOutOfRange){
            return errorOutOfRange;
        } else if (result !== true){
            return errorWrongDateTimeFullFormat;
        } else if (dateString.length <= 16){
            return true // seconds and milliseconds omitted
        } else {
            var regEx = /^:(\d{2}|\d{2}\.\d{3})$/;
            if(!dateString.slice(16).match(regEx)) return errorWrongDateTimeFullFormat; // Invalid format
            let d = new Date(dateString);
            let dNum = d.getTime();
            if(!dNum && dNum !== 0) return errorOutOfRange; // NaN value, Invalid date
            return true
        }
    }

    //=======================================
    // Validate datetime in YYYY-MM-DD HH:MM:SS.mmm format or relative format
    // Seconds and milliseconds are optional
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDateTimeFullRelative(dateString, config){
        dateString = dateString.trim();
        if (dateString.match(/^([+-])(?:(\d+)[dD])?(?:(\d+)[hH])?(?:(\d+)[mM])?(?:(\d+)[sS])?$/) !== null){
            if (dateString === '+' || dateString === '-') return errorWrongDateTimeFullRelativeFormat
            return true;
        } else {
            let result = validateDateTimeFull(dateString, config)
            if (result === true || result === errorOutOfRange) return result;
            return errorWrongDateTimeFullRelativeFormat;
        }
    };

    //=======================================
    // Validates dates in YYYY-MM-DD format
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDate(dateString, config, keyword) {
        dateString = dateString.trim();
        let regEx = /^\d{4}-\d{2}-\d{2}$/;
        if(!dateString.match(regEx)) return errorWrongDateFormat; // Invalid format
        let d = new Date(dateString);
        let dNum = d.getTime();
        if(!dNum && dNum !== 0) return errorOutOfRange; // NaN value, Invalid date
        let r = d.toISOString().slice(0,10) === dateString;
        if (r) {
            return true
        } else {
            return errorOutOfRange
        };
    }

    //=======================================
    // Validates dates in YYYY-MM-DD format or relative format
    // Bissextile years are properly processed
    // Suitable for use as validate callback in a text component of a setting
    function validateDateRelative(dateString, config){
        dateString = dateString.trim();
        if (dateString.match(/^([+-])(?:(\d+)[dD])?$/) !== null){
            if (dateString === '+' || dateString === '-') return errorWrongDateRelativeFormat
            return true;
        } else {
            let result = validateDate(dateString, config)
            if (result === true || result === errorOutOfRange) return result;
            return errorWrongDateRelativeFormat;
        }
    };

    //=======================================
    // Helper function to validate time in HH:MM format
    // It should not be publicly exposed
    function validateTime(timeString) {
      let regEx = /^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}$/;
      if(!timeString.match(regEx)) return 'No match'; // Invalid format
      let d = new Date(timeString);
      let dNum = d.getTime();
      if(!dNum && dNum !== 0) return errorOutOfRange; // NaN value, Invalid date
      return true
    }

    //=======================================
    // Parses a validated date in YYYY-MM-DD format
    // Also parse a validated datetime in YYYY-MM-DD HH:MM format
    // Parses datetime in both 12h and 24h formats
    // Parses optional seconds and milliseconds
    // Returns the corresponding date object for this date/datetime in the local time zone
    // May return an invalid date if presented with empty or invalid data - but not always
    // If there is doubt about the quality of the data, validate first
    // Suitable to parse a validated date from a text component in a setting
    function parseDateTime(dateString) {
        dateString = dateString.trim(); // validation allows leading and trailing blanks
        try {
            if (dateString === '') return new Date('###'); // returns an invalid date
            let match = dateString.match(/^([+-])(?:(\d+)[dD])?(?:(\d+)[hH])?(?:(\d+)[mM])?(?:(\d+)[sS])?$/);
            if (match !== null){
                if (dateString === '+' || dateString === '-') return new Date('###'); // returns an invalid date
                let date = Date.now();
                let sign = (match[1] === '+' ? 1 : -1);
                let days = (match[2] || 0) * 86400000;
                let hrs = (match[3] || 0) * 3600000;
                let min = (match[4] || 0) * 60000;
                let sec = (match[5] || 0) * 1000;
                return new Date(date + sign * (days + hrs + min + sec));
            }
            // new Date() uses local time zone when the parameters are separated
            let YY = Number(dateString.substring(0, 4));
            let MM = Number(dateString.substring(5, 7))-1;
            let DD = Number(dateString.substring(8, 10));
            let hh = (dateString.length >= 13) ? Number(dateString.substring(11, 13)) : 0;
            let mm = (dateString.length >= 16) ? Number(dateString.substring(14, 16)) : 0;
            let ss = (dateString.length >= 19) ? Number(dateString.substring(17, 19)) : 0;
            let ml = (dateString.length === 23) ? Number(dateString.substring(20, 23)) : 0;

            let suffix = (dateString.length === 18) ? dateString.substring(16, 18) : ''
            if (suffix === 'am' || suffix === 'pm'){ // if 12 hours format, convert to 24 hours
                if (hh === 12) hh = 0;
                if (suffix === 'pm') hh += 12;
            }
            return new Date(YY, MM, DD, hh, mm, ss, ml);
        } catch (e) {
            return new Date('###'); // returns an invalid date in case of error
        }
    }

Date.parse()

In discussions with @Kumirei and @rfindley the question was raised of why not use a plain Date.parse() or its equivalent new Date(). It was argued that Date.parse() is convenient, simple to use, suffice to most needs and supports locale datetime formats. Well, in my opinion we can’t use this for these reasons.

  • The validation functions are meant to be used as validate called back in WKOF settings dialog. We need a wrapper around Date.parse() to comply with the framework requirements.
  • A wrapper is also needed when we want to support relative time.
  • Date.parse() accepts integers as valid dates and makes dates out of them. This data type is not a date. (as tested on Chrome)
  • Date.parse() makes a date out of 2/2/2020 although it is not clear whether the day or the month comes first.
  • Date.parse() makes a date out of septejdhdf,!!! 2:;.. 1995?? 9 without reporting an error. (Tested on Chrome)
  • Mozilla.org recommends against it.

It is not recommended to use Date.parse as until ES5, parsing of strings was entirely implementation dependent. There are still many differences in how different hosts parse date strings, therefore date strings should be manually parsed (a library can help if many different formats are to be accommodated).

For me the real killers are septejdhdf,!!! 2:;.. 1995?? 9 and the ambiguity around 2/2/2020. This is an issue of data quality. God only knows what other kind of nonsense Date.parse() will accept as a date. I can’t trust Date.parse() to produce the date the user intended to type without further validation.

More Evidence

I looked further into the locale datetime support of Date.parse(). Mozilla reports the following. (same link as above)

However, invalid values in date strings not recognized as simplified ISO format as defined by ECMA-262 may or may not result in NaN , depending on the browser and values provided, e.g.:

// Non-ISO string with invalid date values
new Date('23/25/2014');

will be treated as a local date of 25 November, 2015 in Firefox 30 and an invalid date in Safari 7.

This means we can’t trust a NaN test of Date.parse() to provide the correct result in every browser. There is more.

The string " 10 06 2014 " is an example of a non-conforming ISO format and thus falls back to a custom routine. See also this rough outline on how the parsing works.

new Date('10 06 2014');

will be treated as a local date of 6 October, 2014, and not 10 June, 2014.

This is a problem. In some locale the month comes first and others puts the day first. You can’t use a one format fits all approach and get correct results. For example in Canada the English put the month first and the French put the day first. The browser should use the computer settings to determine what a locally valid date is. Chrome does not. I am set up for YYYY-MM-DD and Date.parse() accepted 2/2/2020. For a long date my computer is set up for 2 septembre 1995 and Chrome accepted septejdhdf,!!! 2:;.. 1995?? 9 which is a weid typo for september 2 1995.

And then, still according to Mozilla, there is this.

given a simplification of the ISO 8601 calendar date extended format such as " 2014-03-07 ", it will assume a time zone of UTC (ES5 and ECMAScript 2015).

This is a problem. In Canada the locale format for a date is YYYY-MM-DD and it will always be interpreted as UTC by Date.parse().

For all these reasons, I conclude that the locale support of Date.parse() is broken.

Acknowledgement

@rfindley kindly provided reference code for the relative datetime format.

3 Likes

I think for most cases something like !isNaN(Date.parse(date)) is good enough for me, but I’ll keep this in mind

1 Like

We must keep in mind that ‘Date.parse(date)’ makes a date out of many sorts of strings. I got it to parse 02/02/2020 even though I don’t know what that date means. Does the month comes first? Or is it the day? There is a user interface issue if the user doesn’t understand the date the same as the software. My functions enforce the format exactly and inform the user of what is expected of them.

1 Like

Yeah, that’s why I say in most cases

1 Like

Following on a suggestion of @rfindley I have added the support of relative time to my functions. Thanks to @rfindley for providing some reference code.

I have updated the top post accordingly.