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 of2/2/2020
although it is not clear whether the day or the month comes first.Date.parse()
makes a date out ofseptejdhdf,!!! 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.