Even More JavaScript Extensions: Dates
by Jason S. Kerchner on Apr 20, 2009 (filed in Development)
Continuing with JavaScript extensions, I’ve created a set of extensions to the Date data type. These include some functions for date calculations, comparisons and formatting. I’ve also added a new function to the String data type that will convert a string into a Date object.
Extensions to the Date object
Let’s start with the simpler one first, the Date object. Most of the functions are pretty self-explanatory, but there are some notable things I should point out.
First, there are the static arrays that have been added to the Date data type.
1 2 3 4 5 6 7 | Date.dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; Date.shortDayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; Date.dayChars = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; Date.monthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; Date.shortMonthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; Date.monthChars = ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; |
Lines 1 to 3 contain the names of the days of the week, and lines 5 to 7 contain the names of the months. You can override these to provide localized names and abbreviations.
These are used to convert a weekday number or month number to a string version. So, for example, I could use Date.monthNames[3] to get the value “April”. But sometimes I need to be able to go the other way as well, like when converting a date string to a Date object. The next block of code sets up for this type of conversion.
1 2 3 4 5 6 | Date.monthNumbers = { }; Date.shortMonthNumbers = { }; for (var i = 0; i < 12; i++) { Date.monthNumbers[Date.monthNames[i]] = i; Date.shortMonthNumbers[Date.shortMonthNames[i]] = i; } |
The above code creates two objects, one for long month names and one for short month names. I saw no need to convert week day names to numbers, so those are not included here. Once the empty objects are set up, it loops through the month names, adding each one as a property to the empty month numbers objects, and setting its value to the corresponding month number. This allows me to look up a month number from the name by simply using Date.monthNumbers['January']. Remember that month numbers in JavaScript are zero-based, so January is 0, not 1. I’m doing this to make it easier and faster to convert month names to numbers. I’m using the loop to build the objects so that I only have to set the month names one time (in the arrays I created earlier). This loop uses those values, saving me from having to re-enter them. This means I can localize the date strings in one place.
Another function of note that I added is the copy function. When you assign one date variable to another, JavaScript creates a pointer to that date object. This means that both variables will point to the same date object, which is not always desirable. The copy function allows you to quickly create a copy of a date.
1 2 3 | var d1 = new Date(2009, 03, 20); // April 20, 2009 var d2 = d1; // Creates pointer to d1 var d3 = d1.copy(); // Creates a copy of d1 |
The last function of interest is the toFormat function. This allows you to easily convert a Date object to a string representation.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | Date.prototype.toFormat = function(format) { var pad = function(val) { return (val > 9 ? '' : '0')+val; }; var result = ''; for(var i = 0, len = format.length; i < len; i++) { var c = format.charAt(i); switch(c) { case 'Y': result += this.getFullYear(); break; case 'y': result += (this.getFullYear()+'').substr(2,2); break; case 'M': result += pad(this.getMonth()+1); break; case 'm': result += this.getMonth()+1; break; case 'N': result += Date.monthNames[this.getMonth()]; break; case 'n': result += Date.shortMonthNames[this.getMonth()]; break; case 'D': result += pad(this.getDate()); break; case 'd': result += this.getDate(); break; case 'W': result += Date.dayNames[this.getDay()]; break; case 'w': result += Date.shortDayNames[this.getDay()]; break; case 'H': var hour = this.getHours() % 12; result += pad((hour ? hour : 12)); break; case 'h': var hour = this.getHours() % 12; result += (hour ? hour : 12); break; case 'R': result += pad(this.getHours()); break; case 'r': result += this.getHours(); break; case 'I': result += pad(this.getMinutes()); break; case 'i': result += this.getMinutes().toString(); break; case 'S': result += pad(this.getSeconds()); break; case 's': result += this.getSeconds().toString(); break; case 'A': result += (this.getHours() < 12 ? 'AM' : 'PM'); break; case 'a': result += (this.getHours() < 12 ? 'am' : 'pm'); break; default: result += (c == '^' ? format.charAt(++i) : c); } } return result; }; |
The function takes a single format string and replaces certain characters within that string with the actual date values. Lines 3 to 5 are a small function used to left pad a single digit with a zero. I’m using this version rather than the String.leftPad method (part of the string extensions) simply for performance reasons. This little pad function assumes that the source value is a number and uses type coercion to convert the value, padded or not, to a string. It will also only pad with a single digit. These assumptions make it faster than the String.leftPad method.
Next, the code simply loops through each character of the format string, looking for those special characters that it needs to replace with the date values. Those special characters are given in the following table:
| Field | Long form | Short Form |
|---|---|---|
| Year | Y (4 digit) | y (2 digit) |
| Month | M (2 digit) | m (1 or 2 digit) |
| Month Name | N (full name) | n (abbreviation) |
| Day of Month | D (2 digit) | d (1 or 2 digit) |
| Day Name | W (full name) | w (abbreviation) |
| Hour (1-12) | H (2 digit) | h (1 or 2 digit) |
| Hour (0-23) | R (2 digit) | r (1 or 2 digit) |
| Minute | I (2 digit) | i (1 or 2 digit) |
| Second | S (2 digit) | s (1 or 2 digit) |
| AM/PM | A (upper case) | y (lower case) |
| literal | ^ (insert next character, do not replace) | |
So, for example, you can use the following formats:
1 2 3 4 5 6 7 8 9 10 11 12 | var birthDate = new Date(1980, 0, 1); // Januatry 1, 1980 // Alerts: January 1, 1980 alert(birthDate.toFormat('N, d, Y')); // Alerts: 01/01/80 alert(birthDate.toFormat('M/D/y')); // Alerts: Birthday is Jan 01 '80 alert(birthDate.toFormat("B^i^rt^h^d^a^y is n, D 'y")); // This is probably a better way to do the above: alert(String.toFormat('Birthday is %s', birthDate.toFormat("n, D 'y")); |
Note that I decided to use the caret ^ for literals rather than the more typical backslash. This is because JavaScript already uses the backslash as an escape character. So, to insert a backslash into a JavaScript string you would always need to insert two backslashes, which I felt would be annoying, not very readable, and easy to forget. For example, you would have to enter a string such as ‘\\D\\ate: M/D/Y’. Each double backslash is interpreted by JavaScript as a single backslash. If you entered it as ‘\D\ate: M/D/Y’, then JavaScript would try to escape the first ‘D’ and the ‘a’.
The other included Date object extension functions are pretty self-explanatory, and the code should be fairly easy to interpret.
More Extensions to the String Object
I’ve also added a new function to the String object, toDate. This method will read the value of the string and convert it into a Date object. As you might imagine, this is a bit more complicated than converting a Date to a String. The basic premise is to use regular expressions to extract the parts of the string that represent the various date part that we are looking for. Once we have all the parts, we convert them as necessary into numeric values that we can use to create a Date object.
The first part of the function sets up a number of variables. I’ll discuss the Pos variables (yearPos, monthPos, dayPos, etc.) in a moment. Just note that they are all set to -1.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // Default values set to midnight Jan 1 of the current year. var year = new Date().getFullYear(); var month = 0; var day = 1; var hours = 0; var minutes = 0; var seconds = 0; // Positions of each date element within the source string. Use to know // which backreference to check after a successful match. var yearPos = -1; var monthPos = -1; var dayPos = -1; var hoursPos = -1; var minutesPos = -1; var secondsPos = -1; var amPmPos = -1; var monthStyle = 'm'; // How we interpret the month, digits (M/m) or names (N/n) var hoursStyle = 'h'; // How we interpret the hours, 12-hour (h) or 24-hour (r) var position = 1; // Position of the current date element (year, month, day, etc.) in the source string var pattern = ''; // Date pattern to be matched. // Remove extraneous whitespace from source string and format string. var str = this.replace(/\s+/g, ' '); format = format.replace(/\s+/g, ' '); |
The Pos variables in lines 11 to 17 will be set to the position within the source string where a particular value is found. This will be used to retrieve the value from the matches array that is returned when the regular expression is executed. The last thing this block of code does is remove any extraneous whitespace from the source and format strings. I thought it would be safe to do that, and probably helpful when interpreting input from a user which may not be as clean as I would like.
The next block of code, shown below, loops through the format string and builds the regular expression string that will be used to extract the date values we need. This is where we set the value of the Pos variables (yearPos, monthPos, dayPos, etc.). Note that for ‘W’ and ‘w’ in the format string (weekday names) we will match them in the regular expression, just to ensure that the string is the correct format, but we don’t actually need them to create a Date object, so we will ignore their matched values. The format string uses the same notation as the Date.toFormat function discussed above.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 | // Loop throught the format string, and build the regex pattern // for extracting the date elements. for (var i = 0, len = format.length; i < len; i++) { var c = format.charAt(i) switch (c) { case 'Y' : pattern += '(\\d{4})'; yearPos = position++; break; case 'y' : pattern += '(\\d{2})'; yearPos = position++; break; case 'M' : case 'm' : pattern += '(\\d{1,2})'; monthPos = position++; monthStyle = 'm' break; case 'N' : pattern += '(' + Date.monthNames.join('|') + ')'; monthPos = position++; monthStyle = 'N'; break; case 'n' : pattern += '(' + Date.shortMonthNames.join('|') + ')'; monthPos = position++; monthStyle = 'n'; break; case 'D' : case 'd' : pattern += '(\\d{1,2})'; dayPos = position++; break; case 'W' : // We'll match W, but won't do anything with it. pattern += '(' + Date.dayNames.join('|') + ')'; position++; break; case 'w' : // We'll match w, but won't do anything with it. pattern += '(' + Date.shortDayNames.join('|') + ')'; position++; break; case 'H' : case 'h' : pattern += '(\\d{1,2})'; hoursPos = position++; hoursStyle = 'h'; break; case 'R' : case 'r' : pattern += '(\\d{1,2})'; hoursPos = position++; hoursStyle = 'r'; break; case 'I' : case 'i' : pattern += '(\\d{1,2})'; minutesPos = position++; break; case 'S' : case 's' : pattern += '(\\d{1,2})'; secondsPos = position++; break; case 'A' : case 'a' : pattern += '(AM|am|PM|pm)'; amPmPos = position++; break; default : pattern += (c == '^' ? format.charAt(++i) : c); } } |
At the end of all this, the pattern variable will contain our regular expression. We can then use that pattern to check the source string for matches. That’s what the next section of code does, below. If no match is found, we return null, meaning that the source string was not in the format we expected.
1 2 3 4 | // Pull out the date elements from the input string var matches = str.match(new RegExp(pattern)); if (!matches) return null; |
Next we have to interpret all of those parts. If one of the Pos variables has a value greater than -1, then we know that there should be a match for that field. The value will be stored in the matches array that was returned from the regular expression engine. The position of the value within that array is the value of the Pos variable. In other words, if yearPos > -1, then we can find the value of the year field at matches[yearPos].
Now, because we’ve used regular expressions, we know that if we were looking for a number, then we’ve got a number, and we won’t need to worry that we can’t convert it to an actual Number data type. The monthStyle is used to record whether we are looking for a month number or name. That’s the only value that could be returned as both a number or a name.
Where possible, we will also check the ranges of the values, to make certain that the date entered is valid.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 | // Now we have to interpret each of those parts... if (yearPos > -1) { year = parseInt(matches[yearPos], 10); year = (year < 50 ? year + 2000 : (year < 100 ? year + 1900 : year)); } if (monthPos > -1) { switch (monthStyle) { case 'm': month = parseInt(matches[monthPos], 10) - 1; // JavaScript months are zero based, user input generally is not. if (month > 11) return null; break; case 'N': month = parseInt(Date.monthNumbers[matches[monthPos]], 10); if (isNaN(month)) return null; break; case 'n': month = parseInt(Date.shortMonthNumbers[matches[monthPos]], 10); if (isNaN(month)) return null; break; } } if (dayPos > -1) { day = parseInt(matches[dayPos], 10); if ((day < 1) || (day > Date.daysInMonth(month, year))) return null; } if (hoursPos > -1) { hours = parseInt(matches[hoursPos], 10); if (hoursStyle == 'h' && (hours == 0 || hours > 12)) return null; else if (hours > 23) return null; } if (minutesPos > -1) { minutes = parseInt(matches[minutesPos], 10); if (minutes > 59) return null; } if (secondsPos > -1) { seconds = parseInt(matches[secondsPos], 10); if (seconds > 59) return null; } // Convert 12-hour time, if used, to 24-hour time. if (amPmPos > -1) { var amPm = matches[amPmPos]; if ((amPm == 'pm' || amPm == 'PM') && (hours < 12)) hours += 12; } |
An enhancement to this code would be to provide a specific error code and message for a date conversion failure. For example, month is out of range, or February does not have a 29th day in 1999. But that’s for another time.
The last thing to do is take all the values we’ve extracted from the string, and convert them to an actual Date object.
1 | return new Date(year, month, day, hours, minutes, seconds); |
Of course, tests are included for all extension functions as well.
Date Extensions
Date.daysInMonth(month, year)
Returns the number of days in the current month and year. Note that the month is zero-based, so January = 0, February = 1, etc.
Date.prototype.copy()
Creates a copy of the current date object. Assigning one date variable to another simply points both variables to the same date object. This function is useful when you need a unique copy of the date object.
Date.prototype.getDayName()
Returns the full name of the day of the week.
Date.prototype.getShortDayName()
Returns the short name of the day of the week, typically in 3 characters.
Date.prototype.getDayChar()
Returns the single character representation of the day of the week.
Date.prototype.getMonthName()
Returns the full name of the month.
Date.prototype.getShortMonthName()
Returns the short name of the month, typically 3 characters.
Date.prototype.getMonthChar()
Returns the single character representation of the month.
Date.prototype.getMonthNumber()
Returns the normalized numeric representation of the month. (i.e. January = 1, February = 2, …, December = 12)
Date.prototype.daysInMonth()
Returns the number of days in the current month and year, adjusting February for leap years.
Date.prototype.addDays(offset)
Adds the given number of days to the date. To subtract days, pass in a negative value for offset.
Date.prototype.addMonths(offset)
Adds the given number of months to the date. To subtract months, pass in a negative value for offset.
Date.prototype.addYears(offset)
Adds the given number of years to the date. To subtract years, pass in a negative value for offset.
Date.prototype.addHours(offset)
Adds the given number of hours to the time portion of a date. To subtract time, pass in a negative value for offset.
Date.prototype.addMinutes(offset)
Adds the given number of minutes to the time portion of a date. To subtract time, pass in a negative value for offset.
Date.prototype.addSeconds(offset)
Adds the given number of seconds to the time portion of a date. To subtract time, pass in a negative value for offset.
Date.prototype.clearTime()
Clear the time portion of a date object.
Date.prototype.compareTo(date, ignoreTime)
Date comparison functions. If ignoreTime is true, then the time portion will be ignored during the comparison.
Date.prototype.isBefore(date, ignoreTime)
Returns true if this date is before another date.
Date.prototype.isAfter(date, ignoreTime)
Returns true if this date is after another date.
Date.prototype.equals(date, ignoreTime)
Returns true if this date is equal to another date.
Date.prototype.toFormat(format)
Return a date in the given format. Use ^ to force the use of a literal character.
String Extensions (showing new only)
String.prototype.toDate(format)
Attempts to convert a string into a date based on a given format. Fields will match either the long or short form, except in the case of the year, where the string must match either a 2-digit or 4-digit format. Ranges are checked. Day names are expected if they are included in the format string, but are otherwise ignored. Use ^ to force the use of a literal character. In other words, to have the character Y appear insead of the actual year, use ^Y.
April 28th, 2009 on 5:26 pm
Nice work. Some of this has already been done Jörn Zaefferer and Brandon Aaron, with additional functions by Kelvin Luck as part of his work on the jQuery Datepicker plugin. See:
http://jqueryjs.googlecode.com/svn/trunk/plugins/methods/date.js
April 28th, 2009 on 9:32 pm
Yeah, there’s lots of code out there for working with dates. They all have their advantages and disadvantages. I was going for code that was reasonably simple and reasonably efficient. Still, it’s always interesting to see how others have implemented similar functionality. Thanks for the link.