Recently I was working on testing code for a JavaScript project, and it involved working with and comparing different timestamps. Out of the box, JS does let you construct Date
objects such as new Date('19 May 2013 12:00')
. However, having a lot of those full dates all over my test code makes my tests verbose, so I wanted to be able to write more-readable timestamps like 15m ago
.
With JavaScript regular expressions, it was more straightforward than I expected to throw this together, coming out to just 25 lines of code, so in this tutorial I'm going to show how we can make a relative date parser in JavaScript.
The format we're parsing
The format we are going to make is going to be based on the Go time.Duration
string format; a duration such as 1 hour and 23 minutes in Go would be represented as 1h23m
. So we're representing our timestamps in the past in a format like 1h23m ago
, or in the future with the format 1h30m25s later
.
Let's start with just getting a timestamp that is some number of minutes in the past or future. We would be parsing a regular expression that looks something like:
let relTimeRegex = /\d+m (ago|later)/;
The first part, \d+
means "one or more digits" since \d
in regex means "digit" and +
means "one or more of them". "m" afterwards just means literally a lowercase m, so "30m" or "5m" would match the first part of the regex.
The second part, (ago|later)
means "after the space, either the literal string 'ago', or the literal string 'later'".
So taken together, a string like 30m ago
or 5m later
would match this regular expression, which you can see if you run relTimeRegex
's test()
method, which returns a boolean telling you whether or not the string you passed in matches the regex.
> relTimeRegex.test('30m ago');
true
> relTimeRegex.test('5m later');
true
> relTimeRegex.test('20m in the future');
false
Getting parts of the regular expression
We now have a regular expression for reading strings that are in our timestamp, but the next thing we'll need is a way to retrieve how many minutes in the past or future this timestamp is, as well as whether this was that number of minutes ago or later this is.
We could just use parseNumber()
to retrieve the number of minutes in either direction, since parseNumber("15m ago")
would give us the number 15 and parseNumber("3 toed sloth")
would give us 3. However, in the final product we want to have hour, second, and millisecond components in the timestamp as well; if we had a timestamp like 30m20s ago
, the parseNumber
function would give us back the 30, but not the 20 for the number of seconds.
So instead of getting the minute component of our timestamp with parseNumber
, we can put some parentheses around the \d+m
to make \d+m
into a capture group.
+ let relTimeRegex = /(\d+m) (ago|later)/;
- let relTimeRegex = /\d+m (ago|later)/;
So what does making a new capture group do, exactly? Allow the String.match()
function, which you can use for matching a string with a regex, to show you!
> '30m ago'.match(relTimeRegex);
[ '30m ago', '30m', 'ago', index: 0, input: '30m ago' ]
> '30m in the future'.match(relTimeRegex);
null
String.match()
gives us back a special kind of array, a RegExpMatchArray
, to be exact. That kind of array tells us which parts of our string matched with each capture group of our regular expression. So when we're matching (\d+m) (ago|later)
, the array:
[ '30m ago', '30m', 'ago' ]
tells us that:
- The string "30m ago", which is our whole string, is what matched the whole regular expression
- "30m" is the part of our string that matched the
\d+m
capture group - "ago" is the part of our string that matched the
ago|later
capture group.
While meanwhile, the string '30m in the future'
doesn't match the whole regular expression, so '30m in the future'.match(relTimeRegex)
just gives us back null.
So if we have an array of each capture group in the regular expression, that means in our function for parsing these timestamps, we could put those capture groups into variables like:
// match[0] is unused since it's the whole match
let minutes = match[1];
let direction = match[2];
Or, to use ES6 features like the cool kids, why not do a destructuring let to get the strings for each capture group? 😎
// Can you say radical? 😎
// We assign the whole match to _ because the whole-match part of the regex is
// not gnarly enough for the cool variables!
let [_, minutes, direction] = match;
Rocket Power slang aside, we've got our regular expression and our capture groups, and a way to turn them into variables, so let's try turning this all into the first draft of our function!
let relTimeRegex = /(\d+m) (ago|later)/;
function relativeTime(timeStr) {
let match = timeStr.match(relTimeRegex);
// If we didn't have a match, then just return the current time
if (!match) {
return new Date();
}
let [_, minutes, direction] = match;
// JavaScript dates are in milliseconds, so convert the number of minutes to
// milliseconds by multiplying them by 60000.
let totalMilliseconds = parseInt(minutes) * 60 * 1000;
// Add or subtract our duration, depending on which direction this timestamp
// is in.
let d = Date.now();
if (direction == 'later') {
return new Date(d + totalMilliseconds);
} else {
return new Date(d - totalMilliseconds);
}
}
We see if the string passed in matches the regex, returning the current time if it doesn't. Then, we get how many milliseconds in the past or future this timestamp is in, and finally we add or the number of milliseconds from our current date in order to get the date in the timestamp. So at 3:25 PM on May 27, 2019, running relativeTime('30m later')
would get us a Date
object for 3:55 PM that day.
Now we've got minutes, so let's add seconds.
Adding in seconds
We could retrieve the number of minutes in our duration with a capture group, so the way we'd get the number of seconds is with another capture group.
+ let relTimeRegex = /(\d+m)(\d+s) (ago|later)/;
- let relTimeRegex = /(\d+m) (ago|later)/;
Just like with the minutes component, we add parentheses to make a capture group for the seconds component, \d+s
. And if we run code like '3m43s'.match(relTimeRegex)
, we would get:
[ '3m43s ago', '3m', '43s', 'ago', index: 0, input: '3m43s ago' ]
The RegExpMatchArray
for Hicham El-Guerrouj's world-record one-mile time, 3 minutes and 43 seconds. (This is coincidentally also Velcro the Sloth's record in the one-meter dash, but that was revoked by the International Sloth Athletic Association in 2005 due to the usage of performance-enhancing radioactive hibiscus flowers ☢️🌺).
So we could put the seconds into a variable like this
let [_, minutes, seconds, direction] = match;
There's one problem, though. Now strings of just the minute component, or just the second component wouldn't match our regular expression. To parse the duration "30m ago"
, we would need to pass in "30m0s"
, which is cumbersome. But luckily, in regular expressions we can make capture groups optional to match with the ?
character!
+ let relTimeRegex = /(\d+m)?(\d+s)? (ago|later)/;
- let relTimeRegex = /(\d+m)(\d+s) (ago|later)/;
Now, "30m ago"
would match, and the returned RegExpMatchArray
would be:
[ '30m ago', '30m', undefined, 'ago', index: 0, input: '30m ago' ]
Our whole match is "30m ago"
, the minute component is "30m"
, the direction component is "ago"
, and the second component is now undefined
. If one of the optional capture groups in our regular expression doesn't match anything, then its slot in the returned match array will be undefined!
So now, like before, we can use let [_, minutes, seconds, direction] = match;
to get each component out of the regex, but now we'd need to check each component to be sure it actually matched something before we add it to the timestamp; parseInt(undefined)
is NaN
, so that would break our returned Date.
let totalMilliseconds = 0
if (minutes) {
totalMilliseconds += parseInt(minutes) * 60 * 1000;
}
if (seconds) {
totalMilliseconds += parseInt(seconds) * 1000;
}
With those checks in place, we can now parse a timestamp's minutes and seconds, and either component is optional!
Adding in hours and milliseconds
The hours and milliseconds components follow the same pattern as the minutes and seconds components did; they're \d+h
and \d+ms
, respectively, and their capture groups are also optional.
+ let relTimeRegex = /(\d+h)?(\d+m)?(\d+s)?(\d+ms)? (ago|later)/;
- let relTimeRegex = /(\d+m)?(\d+s)? (ago|later)/;
Which now brings the size of our RegExpMatchArray to 6, the whole match, plus five capture groups, so our destructuring let would now look like this:
let [_, hours, minutes, seconds, milliseconds, direction] = match;
With our regex now matching every component of the timestamp, let's take a look at the final product:
let relTimeRegex = /(\d+h)?(\d+m)?(\d+s)?(\d+ms)? (ago|later)/;
function relativeTime(timeStr) {
let match = timeStr.match(relTimeRegex);
// If we didn't have a match, just return the current time
if (!match) {
return new Date();
}
// Add each component of our timestamp to the number of milliseconds in
// the duration.
let [_, hours, minutes, seconds, milliseconds, direction] = match;
let totalMilliseconds = 0;
if (hours) { totalMilliseconds += parseInt(hours)*60*60*1000; }
if (minutes) { totalMilliseconds += parseInt(minutes)*60*1000; }
if (seconds) { totalMilliseconds += parseInt(seconds)*1000; }
if (milliseconds) { totalMilliseconds += parseInt(milliseconds); }
// Add or subtract our duration from the current time, depending on which
// direction this timestamp is in.
let d = Date.now();
if (direction == 'later') {
return new Date(d + totalMilliseconds);
} else {
return new Date(d - totalMilliseconds);
}
}
With this function and regular expression set up, we now are able to parse a relative timestamp's hours, minutes, seconds, and milliseconds, and every capture group in the duration part is optional. Cool stuff! If you're experimenting with regular expressions and want to try them out quickly, by the way, I recommend also checking out https://regex101.com/, which is really convenient not just to see which strings match your regular expression, but also to see which parts of the string would be picked up by each capture group.
Until next time,
STAY SLOTHFUL!
Sloth picture is from Marissa Strniste and is licensed CC-By-2.0
Top comments (0)