Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Jackson Dowell
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Jackson Dowell

Building A Moment-Inspired .fromNow() Date Formatting Method In ColdFusion

By
Published in Comments (9)

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:

Screenshot of dates showing both past and future dates formatted relative to the current date.

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

89 Comments

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.)

15,902 Comments

@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.

15,902 Comments

@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.

15,902 Comments

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:

In a few seconds

... 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:

public string function fromNowDB( required date input ) {
	return fromNow( input.add( "s", -1 ) );
}

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.

15,902 Comments

@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 the Intl.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 the Intl 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.

362 Comments

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.

15,902 Comments

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

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel