Human Readable Durations in 20 lines of Vanilla JS
Published 2019-3-07"1h"
A while back I wrote Time Ago in under 50 lines of JavaScript and just recently I decided to tackle the similar problem of human-readable durations.
The goal is to turn a string that's formatted with a number and a duration designator into milliseconds which can be added to or subtracted from a date.
Date.now() + duration("72h")
// 1553465008198
This is particularly helpful for calculating exp
s (expirations) and I'm sure there are a hundred and one other uses as well.
So Simple It Hurts
This is actually amazingly easy. All you need is:
- a very simple RegExp pattern (to match the number and duration unit)
- a fallthrough switch (for unit multiplication)
For our first pass we'll only consider hours, minutes, and seconds:
function duration(str) {
var pattern = /^([0-9]+)([hms])$/;
var matches = str.match(pattern);
if (!matches || !matches[0]) {
throw new Error("invalid duration string: " + str);
}
var n = parseInt(matches[1], 10);
var unit = matches[2];
switch(unit) {
case 'h':
n *= 60;
/*falls through*/
case 'm':
n *= 60;
/*falls through*/
case 's':
n *= 1000;
}
return n;
}
That's it!
Now, let's take a brief moment to talk about each piece individually:
What the RegEx?
Let's just be honest. Even if you're already familiar with Regular Expressions, they're... daunting.
(I was about to say "they're about as hard to read as perl", but then I realized that that would have been redundant)
Let's break this down into bite-sized chunks to make it simpler:
The Pattern
// [0-9]+ matches one or more ascii characters between 0 and 9
// [hms] matches exactly one of h, m, or s
// ^ means "begins with" - no characters come before the pattern
// $ means "ends with" - no characters come after the pattern
// () designates an indexed group
var pattern = /^([0-9]+)([hms])$/;
In fact, [0-9]
would normally be abbreviated as \d
, which would make
the pattern look like /^(\d+)([hms])$/
, but opted to make it a little more readable.
Next let's consider the match object:
The Match
var matches = str.match(pattern);
if (!matches || !matches[0]) {
throw new Error("invalid duration string");
}
var n = parseInt(matches[1], 10);
var unit = matches[2];
If the pattern requires matches (as most do) and there were no maches,
you'll get back null
.
Otherwise you'll get back an array-like object with each group as an element in the array.
The first group, the implicit group 0, is always the full matched string.
In our example we also have an explicit group 1 and a group 2. If we wanted to support decimal numbers (as opposed to integers only) we could add another optional nested group:
var pattern = /^(\d+(\.\d+)?)([hms])$/;
// ...
var n = parseInt(matches[1], 10); // i.e. 15
var unit = matches[3]; // i.e. m
Here ?
is used to mean "zero or one" and .
is escaped as \.
because
.
all by itself means "one of anything" and we want it to mean, literally, "dot".
Note that the nested group 2 is unused and we now have a group 3.
Gotcha: Patterns keep state!
This is a complete sidenote, but it's important to know that you should always use a new instance of a pattern (rather than hoist it to the top of your file and reuse it like you might with a constant number).
Although the pattern object literal looks deceptively similar to a constant literal - like a string - it actually maintains state related to the string on which it's used.
Rather than explaining when it matters and when it doesn't, my advice is simply that if you never reuse pattern instances on different strings, you'll never be surprised.
The Fallthrough Switch
Modern languages either use "auto-break" switches (like Go) or eliminate switches entirely (like Rust).
JavaScript uses the more traditional "auto-fallthrough" switches, which can be very dangerous but, in this case, actually aren't dangereous.
Since every hour is made of minutes and every minute is made of seconds and every second is made of milliseconds, calculating a duration is a rare case where fallthrough is the right fit.
switch(unit) {
case 'h':
n *= 60;
/*falls through*/
case 'm':
n *= 60;
/*falls through*/
case 's':
n *= 1000;
}
You can see that if I pass in 15m
it will first be multiplied by 60 (to get 900 seconds)
and then will be multiplied by 1000 (to get 900,000 milliseconds).
Typically, code tools and linters will yell at you (as they should) when they detect
that you're using a switch in a way that could lead to severe security vulnerabilities.
Since these fallthroughs are intentional and safe, we simply mark each with the special
comment /*falls through*/
that acts as a tag to let your linter (and other readers)
see that you didn't just forget to break
on accident.
Now let's build an even more useful function, expireIn()
:
Calculating Expires In (NIH/YAGNI)
Well now we get to the reason that this all started in the first place:
You see, I've got a very bad case of NIH that I soothe with a heavy helping of YAGNI.
When I see a huge, bloated library for handling JWTs (cough Auth0) and I know that in reality only a tiny little bit of code is actually needed, the gloves come off and I start typing away at a simpler solution that I'm not ashamed of if I have to include it as a dependency.
When I created my own JWT decoder and verifier, I wasn't going to even include something
so silly as human-readable durations... but then it turned out to be very inconvenient
to set exp
(expires at) and nbf
(not before) token claims otherwise.
Those values need to be set in seconds (NOT milliseconds) since the Unix Epoch, so the code looks pretty similar to this:
function expireIn(str) {
return Math.round((Date.now() + duration(str))/1000);
}
function duration(str) {
var pattern = /^(\-?\d+(\.\d+)?)([wdhms]|ms)$/;
var matches = str.match(pattern);
if (!matches || !matches[0]) {
throw new Error("invalid duration string: " + str);
}
var n = parseInt(matches[1], 10);
var unit = matches[3];
switch(unit) {
case 'w':
n *= 7;
/*falls through*/
case 'd':
n *= 24;
/*falls through*/
case 'h':
n *= 60;
/*falls through*/
case 'm':
n *= 60;
/*falls through*/
case 's':
n *= 1000;
/*falls through*/
case 'ms':
n *= 1; // for completeness
}
return n;
}
And it works like this:
expireIn("15m"); // 1553205015
For completeness, I've added 'ms' to the pattern of this example.
The |
acts as an "or" within its RegExp group. It's different from []
in that [hms]
specifies any of the characters h, m, or s while [hms]|ms
specifies
"or exactly the characters 'ms'".
Milliseconds, Months, and Monotonic Time
As long as you're only concerned with monotonic time (boring, old, sequential, countable time), the code stays pretty simple - you're just converting all other units to milliseconds.
But without being concerned with real time (a web of politics and intrigue), you can only go as far as weeks. I don't know that weeks are particularly useful, but I included them above anyway.
Months would seem to be very useful, however, there is no standard unit for months. At least not yet.
They can be 28, 30, or 31 days. That doesn't sound so bad. But wait, no, they can also be 29 days, but only sometimes.
And, of course, there's the ability for "real time" to move both backwards and forwards in an instant - that 2:30am that never happens once a year, and the complementary 2:30am that happens twice.
Adding code to account for "real time" would be expensive, maddening and, well, cruel. Don't make me do it. I refuse. WONTFIX.
(there's also the small issue that we're already using 'm' for minute so, really, let's just not bother)
One Last Thing: Handling Negative Time
You may have noticed that there was one other change to the example above:
var pattern = /^(\-?\d+(\.\d+)?)([wdhms]|ms)$/;
We've added \-
as an escaped -
as an optional first character
to allow for negative time as well, such as -15m
.
You probably only need to handle negative durations, such nbf
or "not before",
occasionally and it may be even more clear to simply create a positive duration
and subtract it. However, I just threw this in here for completeness.
By AJ ONeal
Did I make your day?
Buy me a coffee
(you can learn about the bigger picture I'm working towards on my patreon page )