Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

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

By Ben Nadel on

After writing about the formatDate() function in Angular 10.2.3, I had mentioned to Mikko Ville Valjento that roughly 95% of my Moment.js usage consists of the .fromNow() method. This method takes a given date/time stamp and returns a human-friendly, relative String. For example, given Oct-12, 2020, the .fromNow() method would return the String, "a month ago". Now that I can use the native formatDate() function in Angular, I wanted to see if I could replace the Moment.js library by creating my own .fromNow() method in Angular 10.2.3.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

The Moment.js .fromNow() method works by bucketing a relative time into a collection of static and dynamic results. These buckets are documented on the Moment.js site:

  • 0 to 44 seconds → "a few seconds ago".
  • 45 to 89 seconds → "a minute ago".
  • 90 seconds to 44 minutes → "2 minutes ago" ... "44 minutes ago".
  • 45 to 89 minutes → "an hour ago".
  • 90 minutes to 21 hours → "2 hours ago" ... "21 hours ago".
  • 22 to 35 hours → "a day ago".
  • 36 hours to 25 days → "2 days ago" ... "25 days ago".
  • 26 to 45 days → "a month ago".
  • 45 to 319 days → "2 months ago" ... "10 months ago".
  • 320 to 547 days (1.5 years) → "a year ago".
  • 548 days+ → "2 years ago" ... "20 years ago".

While each one of these buckets deals with a different unit of time - seconds, minutes, days, hours, months, years - if we squint hard-enough, we can roughly recreate these buckets using a single time-unit: milliseconds. If we assume that a month can roughly be represented as 30-days; and that a year can roughly be represented as 365 days; and that a day roughly has 86,400,000 milliseconds in it; then, we can easily calculate the high/low offset of each one of these .fromNow() buckets:

var MS_SECOND = 1000;
var MS_MINUTE = ( MS_SECOND * 60 );
var MS_HOUR = ( MS_MINUTE * 60 );
var MS_DAY = ( MS_HOUR * 24 );
var MS_MONTH = ( MS_DAY * 30 ); // Rough estimate.
var MS_YEAR = ( MS_DAY * 365 ); // Rough estimate.

var FROM_NOW_JUST_NOW = ( MS_SECOND * 44 );
var FROM_NOW_MINUTE = ( MS_SECOND * 89 );
var FROM_NOW_MINUTES = ( MS_MINUTE * 44 );
var FROM_NOW_HOUR = ( MS_MINUTE * 89 );
var FROM_NOW_HOURS = ( MS_HOUR * 21 );
var FROM_NOW_DAY = ( MS_HOUR * 35 );
var FROM_NOW_DAYS = ( MS_DAY * 25 );
var FROM_NOW_MONTH = ( MS_DAY * 45 );
var FROM_NOW_MONTHS = ( MS_DAY * 319 );
var FROM_NOW_YEAR = ( MS_DAY * 547 );

Each one of the values in that latter block represents the "maximum" value - in milliseconds - for a given Moment.js bucket. Given these buckets, we just have to figure out the delta - in milliseconds - between "now" and a given time-stamp, and then figure out the closest maximum cut-off.

To do this, I've created a DateHelper class which wraps the Angular formatDate() function with a LOCAL_ID partial-application and provides a .fromNow() method:

// Import the core angular services.
import { formatDate as ngFormatDate } from "@angular/common";
import { Inject } from "@angular/core";
import { Injectable } from "@angular/core";
import { LOCALE_ID } from "@angular/core";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

// CAUTION: Numbers are implicitly assumed to be milliseconds since epoch and strings are
// implicitly assumed to be valid for the Date() constructor.
type DateInput = Date | number | string;

var MS_SECOND = 1000;
var MS_MINUTE = ( MS_SECOND * 60 );
var MS_HOUR = ( MS_MINUTE * 60 );
var MS_DAY = ( MS_HOUR * 24 );
var MS_MONTH = ( MS_DAY * 30 ); // Rough estimate.
var MS_YEAR = ( 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.
// --
// https://momentjs.com/docs/#/displaying/fromnow/
// 0 to 44 seconds --> a few seconds ago
// 45 to 89 seconds --> a minute ago
// 90 seconds to 44 minutes --> 2 minutes ago ... 44 minutes ago
// 45 to 89 minutes --> an hour ago
// 90 minutes to 21 hours --> 2 hours ago ... 21 hours ago
// 22 to 35 hours --> a day ago
// 36 hours to 25 days --> 2 days ago ... 25 days ago
// 26 to 45 days --> a month ago
// 45 to 319 days --> 2 months ago ... 10 months ago
// 320 to 547 days (1.5 years) --> a year ago
// 548 days+ --> 2 years ago ... 20 years ago
// --
// Here are the bucket delimiters in milliseconds:
var FROM_NOW_JUST_NOW = ( MS_SECOND * 44 );
var FROM_NOW_MINUTE = ( MS_SECOND * 89 );
var FROM_NOW_MINUTES = ( MS_MINUTE * 44 );
var FROM_NOW_HOUR = ( MS_MINUTE * 89 );
var FROM_NOW_HOURS = ( MS_HOUR * 21 );
var FROM_NOW_DAY = ( MS_HOUR * 35 );
var FROM_NOW_DAYS = ( MS_DAY * 25 );
var FROM_NOW_MONTH = ( MS_DAY * 45 );
var FROM_NOW_MONTHS = ( MS_DAY * 319 );
var FROM_NOW_YEAR = ( MS_DAY * 547 );


@Injectable({
	providedIn: "root"
})
export class DateHelper {

	private localID: string;

	// I initialize the date-helper with the given localization token.
	constructor( @Inject( LOCALE_ID ) localID: string ) {

		this.localID = localID;

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I return a human-friendly, relative date-string for the given input. This is
	// intended to mimic the .fromNow() method in Moment.js:
	public fromNow( value: DateInput ) : string {

		var nowTick = this.getTickCount();
		var valueTick = this.getTickCount( value );
		var delta = ( nowTick - valueTick );

		// NOTE: We are using Math.ceil() 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 <= FROM_NOW_JUST_NOW ) {

			return( "a few seconds ago" );

		} else if ( delta <= FROM_NOW_MINUTE ) {

			return( "a minute ago" );

		} else if ( delta <= FROM_NOW_MINUTES ) {

			return( Math.ceil( delta / MS_MINUTE ) + " minutes ago" );

		} else if ( delta <= FROM_NOW_HOUR ) {

			return( "an hour ago" );

		} else if ( delta <= FROM_NOW_HOURS ) {

			return( Math.ceil( delta / MS_HOUR ) + " hours ago" );

		} else if ( delta <= FROM_NOW_DAY ) {

			return( "a day ago" );

		} else if ( delta <= FROM_NOW_DAYS ) {

			return( Math.ceil( delta / MS_DAY ) + " days ago" );

		} else if ( delta <= FROM_NOW_MONTH ) {

			return( "a month ago" );

		} else if ( delta <= FROM_NOW_MONTHS ) {

			return( Math.ceil( delta / MS_MONTH ) + " months ago" );

		} else if ( delta <= FROM_NOW_YEAR ) {

			return( "a year ago" );

		} else {

			return( Math.ceil( delta / MS_YEAR ) + " years ago" );

		}

	}


	// I proxy the native formatDate() function with a partial application of the
	// LOCALE_ID that is being used in the application.
	public formatDate( value: DateInput, mask: string ) : string {

		return( ngFormatDate( value, mask, this.localID ) );

	}

	// ---
	// PRIVATE METHODS.
	// ---

	// I return the milliseconds since epoch for the given value.
	private getTickCount( value: DateInput = Date.now() ) : number {

		// If the passed-in value is a number, we're going to assume it's already a
		// tick-count value (milliseconds since epoch).
		if ( typeof( value ) === "number" ) {

			return( value );

		}

		return( new Date( value ).getTime() );

	}

}

As you can see, once we have our Moment.js buckets calculated in milliseconds, translating a date into a "relative string" becomes little more than a large if-else block.

To test my .fromNow() translations, I put together a small Angular component with four different "demos". Since the .fromNow() method works with varying degrees of granularity, I wanted to create different demos with different time-spans so that I could more easily see the different buckets in action. In the following code, each demo has an increasingly recent "min" value:

// Import the core angular services.
import { Component } from "@angular/core";

// Import the application components and services.
import { DateHelper } from "./date-helper";

// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //

interface Demo {
	min: number;
	minLabel: string;
	max: number;
	maxLabel: string;
	value: number;
	fromNow: string;
}

@Component({
	selector: "app-root",
	styleUrls: [ "./app.component.less" ],
	templateUrl: "./app.component.html"
})
export class AppComponent {

	public demos: Demo[];

	private dateHelper: DateHelper;

	// I initialize the app component.
	constructor( dateHelper: DateHelper ) {

		this.dateHelper = dateHelper;

		var now = Date.now();
		var yearsAgo = new Date( now - ( 1000 * 60 * 60 * 24 * 365 * 10 ) );
		var yearAgo = new Date( now - ( 1000 * 60 * 60 * 24 * 365 ) );
		var dayAgo = new Date( now - ( 1000 * 60 * 60 * 24 ) );
		var hourAgo = new Date( now - ( 1000 * 60 * 60 ) );

		// Since the Moment.js .fromNow() method is all about relative date-times, let's
		// create several demos with increasingly small time-spans. This way, we can more
		// easily see how the range-input affects the output.
		this.demos = [
			{
				min: yearsAgo.getTime(),
				minLabel: this.dateHelper.formatDate( yearsAgo, "yyyy-MM-dd" ),
				max: now,
				maxLabel: "Now",
				value: now,
				fromNow: this.dateHelper.fromNow( now )
			},
			{
				min: yearAgo.getTime(),
				minLabel: this.dateHelper.formatDate( yearAgo, "yyyy-MM-dd" ),
				max: now,
				maxLabel: "Now",
				value: now,
				fromNow: this.dateHelper.fromNow( now )
			},
			{
				min: dayAgo.getTime(),
				minLabel: this.dateHelper.formatDate( dayAgo, "yyyy-MM-dd" ),
				max: now,
				maxLabel: "Now",
				value: now,
				fromNow: this.dateHelper.fromNow( now )
			},
			{
				min: hourAgo.getTime(),
				minLabel: this.dateHelper.formatDate( hourAgo, "HH:mm:ss" ),
				max: now,
				maxLabel: "Now",
				value: now,
				fromNow: this.dateHelper.fromNow( now )
			}
		];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	// I update the given demo to use the given tick-count.
	public updateFromNow( demo: Demo, value: string ) : void {

		demo.value = +value;
		demo.fromNow = this.dateHelper.fromNow( demo.value );

	}

}

Each one of these demos then corresponds to an input[ type=range ] HTML control:

<div *ngFor="let demo of demos" class="demo">

	<div class="demo__slider slider">
		<div class="slider__label">
			{{ demo.minLabel }}
		</div>
		<div class="slider__range">
			<input
				#rangeRef
				type="range"
				[min]="demo.min"
				[max]="demo.max"
				[value]="demo.value"
				(input)="updateFromNow( demo, rangeRef.value )"
				class="slider__input"
			/>
		</div>
		<div class="slider__label">
			{{ demo.maxLabel }}
		</div>
	</div>

	<p class="demo__label">
		{{ demo.fromNow }}
	</p>

</div>

As you can see, every time you change the input value, we call updateFromNow() with the new millisecond value. This, in turn, calls the DateHelper.fromNow() method in order to update the label for the given demo. And, if we run this in the browser, we get the following output:

Several date-sliders being used to calculate fromNow() time strings in Angular.

As you can see, it works like a charm! Since the resultant String values are pretty fuzzy to begin with, it ends up being totally acceptable that we roughly represent Months and Years as static millisecond values. Since our detail is very course, we can hand-wave over things like leap-years, leap-seconds, and months of varying lengths.



Reader Comments

@All,

In the vein of creating date/time functionality, here's a small post on adding and subtracting date/time "parts" in Angular:

www.bennadel.com/blog/3925-adjusting-dates-by-adding-date-time-parts-in-angular-11-0-0.htm

This creates an API like, .add( part, delta, date ), that we are used to seeing in our server-side languages. JavaScript actually supports this functionality out-of-the-box - you just have to know where to look.

Reply to this Comment

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
NEW: Some basic markdown formatting is now supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.