Building A Moment-Inspired .fromNow() Date Formatting Method In ColdFusion
I'm working on a small personal tool for incident triage and management. And, one of the things that the tool does is render a timeline of status updates in reverse chronological order. I'm been wrestling with how to best render such a timeline when everyone lives in different timezones. One thought that I have is to use a Moment.js-style "from now" format where the dates are labeled relative to the current time. I've done this in Angular before; but, I've never done this in ColdFusion. As such, I wanted to try porting my logic over to the server-side.
The .fromNow()
method works by creating a static list of offset buckets. For example, here are some of the smaller buckets:
0 seconds
...44 seconds
44 seconds
...89 seconds
89 seconds
...44 minutes
44 minutes
...89 minutes
Then, for any given date, the logic calculates the delta between the current time and the given date; and, attempts to fit that delta into the smallest bucket. And, once a bucket is found, it formats the delta specifically for that bucket. For example, if the delta fits into the first bucket, it may be formatted as, in a few seconds
. And, if the delta fits into the second bucket, it may be formatted as, in a minute
.
The sign of the calculated delta also comes into play. If the given date is in the future, the deltas are formatted using the in
prefix (as in, in 3 months
). And, if the given data is in the past, the deltas are formatted using the ago
suffix (as in, 3 months ago
). We can keep this logic relatively generic by calculating the prefix, suffix, and infix for any formatting operation.
In ColdFusion, I'm encapsulating this logic in a ColdFusion component called, Clock.cfc
. Part of the initialization of this component is calculating and caching the milliseconds-based bucket delimiters. These are, essentially, hard-coded values that only need to be calculated once. In this case, I've chosen to store them in the public scope (this
); but, I could have just as easily stored them in the private scope (variables
).
component
output = false
hint = "I provide utility methods around date/time access and formatting."
{
/**
* I initialize the clock.
*/
public void function init() {
// Note: I've decided to put all of these constants in the public scope since they
// might be helpful elsewhere. But, they could have just as easily been put in the
// private scope and locked-down to consumption within this component.
this.MS_SECOND = 1000;
this.MS_MINUTE = ( this.MS_SECOND * 60 );
this.MS_HOUR = ( this.MS_MINUTE * 60 );
this.MS_DAY = ( this.MS_HOUR * 24 );
this.MS_MONTH = ( this.MS_DAY * 30 ); // Rough estimate.
this.MS_YEAR = ( this.MS_DAY * 365 ); // Rough estimate.
// The Moment.js library documents the "buckets" into which the "FROM NOW" deltas
// fall. To mimic this logic using milliseconds since epoch, let's calculate rough
// estimates of all the offsets. Then, we simply need to find the lowest matching
// bucket for a given date.
// --
// Read more: https://momentjs.com/docs/#/displaying/fromnow/
this.FROM_NOW_JUST_NOW = ( this.MS_SECOND * 44 );
this.FROM_NOW_MINUTE = ( this.MS_SECOND * 89 );
this.FROM_NOW_MINUTES = ( this.MS_MINUTE * 44 );
this.FROM_NOW_HOUR = ( this.MS_MINUTE * 89 );
this.FROM_NOW_HOURS = ( this.MS_HOUR * 21 );
this.FROM_NOW_DAY = ( this.MS_HOUR * 35 );
this.FROM_NOW_DAYS = ( this.MS_DAY * 25 );
this.FROM_NOW_MONTH = ( this.MS_DAY * 45 );
this.FROM_NOW_MONTHS = ( this.MS_DAY * 319 );
this.FROM_NOW_YEAR = ( this.MS_DAY * 547 );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I return the number of milliseconds since January 1, 1970, 00:00:00 GMT represented
* by this given date/time value.
*/
public numeric function dateGetTime( required any input ) {
if ( isInstanceOf( input, "java.util.Date" ) ) {
return input.getTime();
}
return dateAdd( "d", 0, input ).getTime();
}
/**
* I format the given date relative to now - all dates assumed to be in UTC.
*/
public string function fromNow( required date input ) {
var nowTick = dateGetTime( utcNow() );
var inputTick = dateGetTime( input );
var delta = ( nowTick - inputTick );
var prefix = "";
var infix = "";
var suffix = " ago"; // Assume past-dates by default.
// If we're dealing with a future date, we need to flip the delta so that our
// buckets can be used in a consistent manner. We will compensate for this change
// by using a different prefix/suffix.
if ( delta < 0 ) {
delta = abs( delta );
prefix = "in ";
suffix = "";
}
// NOTE: We are using ceiling() in the following calculations so that we never
// round-down to a "singular" number that may clash with a plural identifier (ex,
// "days"). All singular numbers are handled by explicit delta-buckets.
if ( delta <= this.FROM_NOW_JUST_NOW ) {
infix = "a few seconds";
} else if ( delta <= this.FROM_NOW_MINUTE ) {
infix = "a minute";
} else if ( delta <= this.FROM_NOW_MINUTES ) {
infix = ( ceiling( delta / this.MS_MINUTE ) & " minutes" );
} else if ( delta <= this.FROM_NOW_HOUR ) {
infix = "an hour";
} else if ( delta <= this.FROM_NOW_HOURS ) {
infix = ( ceiling( delta / this.MS_HOUR ) & " hours" );
} else if ( delta <= this.FROM_NOW_DAY ) {
infix = "a day";
} else if ( delta <= this.FROM_NOW_DAYS ) {
infix = ( ceiling( delta / this.MS_DAY ) & " days" );
} else if ( delta <= this.FROM_NOW_MONTH ) {
infix = "a month";
} else if ( delta <= this.FROM_NOW_MONTHS ) {
infix = ( ceiling( delta / this.MS_MONTH ) & " months" );
} else if ( delta <= this.FROM_NOW_YEAR ) {
infix = "a year";
} else {
infix = ( ceiling( delta / this.MS_YEAR ) & " years" );
}
return ( prefix & infix & suffix );
}
/**
* I return the current date/time in UTC.
*/
public date function utcNow() {
return dateConvert( "local2utc", now() );
}
}
As you can see in the .fromNow()
method, there's nothing clever being done. I'm just brute-force checking each bucket in turn until I find one that holds the calculated delta between the two timestamps.
To test that this is working, I'm looping over some hard-coded delta values that span from several years in the past to several years in the future. And, for each delta value, I output the relative formatting:
<cfscript>
clock = new core.lib.util.Clock();
MS_SECOND = 1000;
MS_MINUTE = ( MS_SECOND * 60 );
MS_HOUR = ( MS_MINUTE * 60 );
MS_DAY = ( MS_HOUR * 24 );
// In order to exercise the various buckets, I'm hard-coding values that I know will
// fall into buckets in both "before" and "after" timelines.
deltas = [
// In the past.
( MS_DAY * -800 ),
( MS_DAY * -500 ),
( MS_DAY * -300 ),
( MS_DAY * -40 ),
( MS_DAY * -20 ),
( MS_HOUR * -30 ),
( MS_HOUR * -20 ),
( MS_MINUTE * -80 ),
( MS_MINUTE * -40 ),
( MS_SECOND * -80 ),
( MS_SECOND * -10 ),
0, // Now - will be called out specifically in the UI.
// In the future.
( MS_SECOND * 10 ),
( MS_SECOND * 80 ),
( MS_MINUTE * 40 ),
( MS_MINUTE * 80 ),
( MS_HOUR * 20 ),
( MS_HOUR * 30 ),
( MS_DAY * 20 ),
( MS_DAY * 40 ),
( MS_DAY * 300 ),
( MS_DAY * 500 ),
( MS_DAY * 800 )
];
</cfscript>
<cfoutput>
<ul>
<cfloop array="#deltas#" index="deltaInMs">
<!--- Call out the current date specifically so we can see it in the UI. --->
<cfif ! deltaInMs>
<li>
<mark>Now</mark>
</li>
<cfcontinue />
</cfif>
<!---
Note: I'm converting the milliseconds delta into seconds, because
ColdFusion will complain about the delta not fitting into an integer on
some of the date-add calls.
--->
<cfset input = clock.utcNow().add( "s", ( deltaInMs / 1000 ) ) />
<li>
<time datetime="#input.dateTimeFormat( 'iso' )#">
#clock.fromNow( input )#
</time>
</li>
</cfloop>
</ul>
</cfoutput>
For each delta, I'm adding the given number of seconds to the current date; and then, I'm formatting that time relative to the current date. And, when we run this ColdFusion code, we get the following output:
As you can see, these hard-coded deltas exercise all of the different buckets defined within the Clock.cfc
ColdFusion component.
I don't always love the idea of using relative date formatting. But, for a timeline that encapsulates a sense of progress during incident triage and remediation, I think it might just work.
Milliseconds vs. Seconds For Buckets
My ColdFusion logic centers around milliseconds. I tend to default to the use of milliseconds because it's a prevalent unit on both the ColdFusion side (via getTickCount()
and date.getTime()
) and on the JavaScript side (via Date.now()
and date.getTime()
). That said, using milliseconds can cause issues because millisecond values don't always fit nicely into an Integer. This is why I'm dividing by 1000
in my rendering logic.
To work a little more seamlessly, this code could be updated to use seconds for both buckets and for delta calculations. This would also allow me to use ColdFusion's dateDiff()
function (since dateDiff()
supports seconds as a datepart, but not milliseconds). Which would, in turn, mean that I don't have to make use of the underlying—and undocumented—call to .getTime()
in the Java layer.
Want to use code from this post? Check out the license.
Reader Comments
Did you know this was baked into the web platform and you don't need a library?
https://developer.mozilla.org/.../Intl/RelativeTimeFormat
I've got a few CodePens that show this. I presented on Intl at the Mid-Michigan CFUG. Video will be live soon.
That isn't quite the same as doing it server-side of course, but I'd do it on the browser myself. :)
Are you interested in more moment.js functionality for ColdFusion? Check out the 7yr old momentcfc library (from Adam Tuttle) that's still compatible w/CF10 & 11. It has a
fromNow
method. I'm not sure about millisecond support (that something that I care about too.)@Raymond,
It's very cool! I've heard of the International stuff on various podcasts, but I haven't tried it out myself. Believe it or not, I've been living in a world where I had to support IE11 until only like 2 years ago! And, at that point, you live in an application that has so much existing code and solutions for things like this, there's no need to bring in new native APIs to accomplish what you're already doing with custom APIs. Such is the double-edge sword of working on the same product for so long.
re: server-side vs client-side, I was just listening to a podcast - I think it was the Syntax podcast - about custom elements, and one of the examples they brought up with an element that does nothing but format time-stamps. I think maybe they said it was GitHub that was doing that. It made me think of Alpine.js - seems like it would be great to have an Alpine.js
x-data
component, perhaps on the<time>
element, that does exactly what you're suggesting.As for my particular use-case, I'm actually generating a Slack message that someone can copy-paste (into Slack); so, all of the text content has to be generated on the server-side.
@James,
Ha ha, classic Adam! And looking at his implementation, he's using
dateDiff()
, which is what I was saying I could use if I did things in seconds instead of milliseconds. Though, he's using it with various dateparts. More or less we're doing the same thing - finding the smallest grouping of time that the date will fit into.Somewhat on track, somewhat off track, I just ran into an interesting edge-case in my application (where I'm formatting dates in this manner). The
datetime
field in which I'm storing a date in the database only has seconds precision. Which means, when I go to insert the date, some rounding / truncation occurs.Consider me inserting this date into the database:
2024-11-16 06:34:00.659
Because I'm in the back-half of the given second, the date actually gets stored in the database as:
2024-11-16 06:34:01
-- rounded up to:01
Now, if I immediately read that date out of the database and use the
.fromNow()
method to format it, I'll get the result:... because the date in the database was rounded-up into the future when compared to the
utcNow()
call in the ColdFusion server.To accommodate this edge-case in my application, I've added another function,
.fromNowDB()
, which just subtracts a second or two from the given date before formatting it:This just ensures that the rounding/truncation of a database date never accidentally puts the given input into the future. This is very application specific; and, you can probably define a database column to have milliseconds precision; but, I don't need that it my application.
@Raymond,
So, I'm looking at the
Intl
module, and I'm not sure how much mileage I'm going to get out of it. It seems that you have to be very specific with what you want to get out of it. Meaning, with the.fromNow()
method, I'm using different units (seconds, hours, days, months, etc) based on the magnitude of the delta. But, it seems that with theIntl.RelativeTimeFormat
class, you have to give is specifically which units you are dealing with. So, it seems like I would have to keep all the existing bucketing logic in place, but use theIntl
calls just for the last mile to define the string.I'll have to do some more research; but, I had higher hopes - I thought maybe it would just magically replace my entire need for
.fromNow()
; but, it seems like maybe it's just a low-level implementation detail.@Ben Nadel,
Yeah, I shoulda been more clear. Both the relative and duration features of Intl are cool, but require you to specify values, and it can be tricky. For example, if my web site is doing a countdown till Christmas, I'd use a relative time unit of days, as that's common ("X days till Christmas!"), but in other scenarios, I may want months, years, or maybe minutes and seconds.
No worries - it's still interesting stuff. I'll have to look deeper into it. I haven't historically given much thought to internationalization (I'm still struggling to make my code accessible - it's a learning journey). Will just add it to the list of things I need to learn.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →