Replacing All External Date Libraries With 300 Lines-Of-Code In Angular 11.0.0
Once I discovered that Angular ships with a formatDate()
function, I had the sense that I could eliminate all external Date libraries from my Angular applications going forward. All I would need to do is fill in the gaps for features like Moment.js
and its .fromNow()
function; and maybe a dateAdd()
function and a daysInMonth()
function to round things out. What I ended up with was a 300-line DateHelper
class which, more or less, covers 99% of all date-related actions that I need to take in my Angular 11.0.0 applications.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
This post is really just an aggregation of my last few posts; so, I won't go into too much detail. All to say, Angular's formatDate()
function is doing most of the heavy-lifting for me. I'm just rounding out some of the missing functionality that formatting-alone won't give me. Specifically, I have additional methods for:
- Adding deltas to a date (ex, add 3-days to a date).
- Diffing two dates by a given unit.
- Determining the relative delta of a given date (the
Moment.js
stuff). - Determining the days in a given month.
But You're Not Considering All The Edge-Cases!
You may be thinking, "But, if you try to do this yourself, you're going to miss edge-case logic." And, you're probably right. But, that's the difference between building a library to be used in any application and a Class to be used in my application. Library authors have to worry about edge-cases because they don't own the applications in which their library is going to be consumed. As such, they have to make their libraries as robust and flexible as possible.
Me, on the other hand, I own my application. I don't have to worry about edge-cases because I control the edge-cases. As such, I can make assumptions about simplicity and fuzziness that a library author cannot make. For example, my diff()
method is a rough estimate when it comes to Year, Month, and Day deltas. But, for me, that's acceptable because I'm not building the type of applications where a rounding error in a diff()
calculation is going to cause any problems.
ASIDE: This same type of reasoning is exactly how John-David Dalton keeps Lodash so fast. While he has functions that look like some of the native functions in JavaScript, he does not account for every single edge-case that a native function might have to account for. This allows him to make assumptions that can dramatically speed up the execution time of his library.
The 300 Lines-of-Code That Cover 99% Of My Date/Time Use-Cases
With that said, here's my DateHelper
class. Half of it just leans on the formatDate()
function that ships with Angular; and, half of it is adding some functionality that I used to get from other libraries:
// 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.
export type DateInput = Date | number | string;
// The single-character values here are meant to match the mask placeholders used in the
// native formatDate() function.
export type DatePart =
| "y" | "year"
| "M" | "month"
| "d" | "day"
| "h" | "hour"
| "m" | "minute"
| "s" | "second"
| "S" | "millisecond"
;
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 add the given date/time delta to the given date. A new date is returned.
public add( part: DatePart, delta: number, input: DateInput ) : Date {
var result = new Date( input );
switch ( part ) {
case "year":
case "y":
result.setFullYear( result.getFullYear() + delta );
break;
case "month":
case "M":
result.setMonth( result.getMonth() + delta );
break;
case "day":
case "d":
result.setDate( result.getDate() + delta );
break;
case "hour":
case "h":
result.setHours( result.getHours() + delta );
break;
case "minute":
case "m":
result.setMinutes( result.getMinutes() + delta );
break;
case "second":
case "s":
result.setSeconds( result.getSeconds() + delta );
break;
case "millisecond":
case "S":
result.setMilliseconds( result.getMilliseconds() + delta );
break;
}
return( result );
}
// I return the number of days in the given month. The year must be included since
// the days in February change during a leap-year.
public daysInMonth( year: number, month: number ) : number {
var lastDayOfMonth = new Date(
year,
// Go to the "next" month. This is always safe to do; if the next month is
// beyond the boundary of the current year, it will automatically become the
// appropriate month of the following year.
( month + 1 ),
// Go to the "zero" day of the "next" month. Since days range from 1-31, the
// "0" day will automatically roll back to the last day of the previous
// month. And, since we did ( month + 1 ) above, it will be ( month + 1 - 1 )
// ... or simply, the last day of the "month" in question.
0
);
return( lastDayOfMonth.getDate() );
}
// I determine the mount by which the first date is less than the second date using
// the given date part. Returns an INTEGER that rounds down.
// --
// CAUTION: The Year / Month / Day diff'ing is a ROUGH ESTIMATE that should be good
// enough for the vast majority of User Interface (UI) cases, especially since we're
// rounding the differences in general. If you need something more accurate, that
// would be the perfect reason to pull-in an external date library.
public diff( part: DatePart, leftDateInput: DateInput, rightDateInput: DateInput ) : number {
var delta = ( this.getTickCount( rightDateInput ) - this.getTickCount( leftDateInput ) );
var multiplier = 1;
// We always want the delta to be a positive number so that the .floor()
// operation in the following switch truncates the value in a consistent way. We
// will compensate for the normalization by using a dynamic multiplier.
if ( delta < 0 ) {
delta = Math.abs( delta );
multiplier = -1;
}
switch ( part ) {
case "year":
case "y":
// CAUTION: Rough estimate.
return( Math.floor( delta / MS_YEAR ) * multiplier );
break;
case "month":
case "M":
// CAUTION: Rough estimate.
return( Math.floor( delta / MS_MONTH ) * multiplier );
break;
case "day":
case "d":
// CAUTION: Rough estimate.
return( Math.floor( delta / MS_DAY ) * multiplier );
break;
case "hour":
case "h":
return( Math.floor( delta / MS_HOUR ) * multiplier );
break;
case "minute":
case "m":
return( Math.floor( delta / MS_MINUTE ) * multiplier );
break;
case "second":
case "s":
return( Math.floor( delta / MS_SECOND ) * multiplier );
break;
case "millisecond":
case "S":
return( delta * multiplier );
break;
}
}
// I proxy the native formatDate() function with a partial application of the
// LOCALE_ID that is being used in the application.
public format( value: DateInput, mask: string ) : string {
return( ngFormatDate( value, mask, this.localID ) );
}
// 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 );
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 = Math.abs( delta );
prefix = "in ";
suffix = "";
}
// 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 ) {
infix = "a few seconds";
} else if ( delta <= FROM_NOW_MINUTE ) {
infix = "a minute";
} else if ( delta <= FROM_NOW_MINUTES ) {
infix = ( Math.ceil( delta / MS_MINUTE ) + " minutes" );
} else if ( delta <= FROM_NOW_HOUR ) {
infix = "an hour";
} else if ( delta <= FROM_NOW_HOURS ) {
infix = ( Math.ceil( delta / MS_HOUR ) + " hours" );
} else if ( delta <= FROM_NOW_DAY ) {
infix = "a day";
} else if ( delta <= FROM_NOW_DAYS ) {
infix = ( Math.ceil( delta / MS_DAY ) + " days" );
} else if ( delta <= FROM_NOW_MONTH ) {
infix = "a month";
} else if ( delta <= FROM_NOW_MONTHS ) {
infix = ( Math.ceil( delta / MS_MONTH ) + " months" );
} else if ( delta <= FROM_NOW_YEAR ) {
infix = "a year";
} else {
infix = ( Math.ceil( delta / MS_YEAR ) + " years" );
}
return( prefix + infix + suffix );
}
// ---
// 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, this DateHelper
class is basically just a bunch of Math
and Date
class-method invocations. But, that's enough to cover pretty much all of my Date/Time needs in an Angular application. And, if I can be extra transparent, I threw in the diff()
, add()
, and daysInMonth()
methods just for fun; in reality, the format()
and fromNow()
methods really cover 98% of all my Date/Time needs in Angular.
To see this in action, I basically just took the demo from my last post and added some additional console-logging - here, I have a Date/Time value that can be adjusted and compared to the current date:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { DateHelper } from "./date-helper";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public baseDate: Date;
public dayDelta: number;
public formattedDate: string;
public hourDelta: number;
public millisecondDelta: number;
public minuteDelta: number;
public monthDelta: number;
public relativeDate: string;
public secondDelta: number;
public yearDelta: number;
private dateHelper: DateHelper;
private dateMask: string;
// I initialize the app component.
constructor( dateHelper: DateHelper ) {
this.dateHelper = dateHelper;
this.baseDate = new Date();
this.dayDelta = 0;
this.hourDelta = 0;
this.millisecondDelta = 0;
this.minuteDelta = 0;
this.monthDelta = 0;
this.relativeDate = "";
this.secondDelta = 0;
this.yearDelta = 0;
this.dateMask = "yyyy-MM-dd HH:mm:ss.SSS";
this.formattedDate = "";
this.relativeDate = "";
}
// ---
// PUBLIC METHODS.
// ---
// I get called on every digest.
// --
// NOTE: Rather than have an explicit function that has to get called every time a
// date-delta is adjusted, we're just going to hook into the digest since we know
// that a new digest will be triggered on every (input) event.
public ngDoCheck() : void {
var result = this.baseDate;
// The .add() function returns a NEW date each time, so we have to keep saving
// and reusing the result of each call.
result = this.dateHelper.add( "year", this.yearDelta, result );
result = this.dateHelper.add( "month", this.monthDelta, result );
result = this.dateHelper.add( "day", this.dayDelta, result );
result = this.dateHelper.add( "hour", this.hourDelta, result );
result = this.dateHelper.add( "minute", this.minuteDelta, result );
result = this.dateHelper.add( "second", this.secondDelta, result );
result = this.dateHelper.add( "millisecond", this.millisecondDelta, result );
this.formattedDate = this.dateHelper.format( result, this.dateMask );
this.relativeDate = this.dateHelper.fromNow( result );
console.group( "Angular Digest" );
console.log( "Date:", this.formattedDate );
console.log( "Relative Date:", this.relativeDate );
// Log the diff() for the given date - how much it is LESS THAN the base date.
console.log(
"Diff:",
this.dateHelper.diff( "year", result, this.baseDate ),
this.dateHelper.diff( "month", result, this.baseDate ),
this.dateHelper.diff( "day", result, this.baseDate ),
this.dateHelper.diff( "hour", result, this.baseDate ),
this.dateHelper.diff( "minute", result, this.baseDate ),
this.dateHelper.diff( "second", result, this.baseDate ),
this.dateHelper.diff( "millisecond", result, this.baseDate )
);
// Log the days in the given month.
console.log(
"Days in month:",
this.dateHelper.daysInMonth( result.getFullYear(), result.getMonth() )
);
console.groupEnd();
}
}
The various "deltas" in this logic are being controlled by some input[type=range]
elements in the App component's HTML template:
<div class="result">
<span class="result__absolute">
{{ formattedDate }}
</span>
<span class="result__relative">
{{ relativeDate }}
</span>
</div>
<div class="slider">
<div class="slider__label">
Year
</div>
<input
#yearRef
type="range"
min="-100"
max="100"
[value]="yearDelta"
(input)="( yearDelta = +yearRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ yearDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Month
</div>
<input
#monthRef
type="range"
min="-100"
max="100"
[value]="monthDelta"
(input)="( monthDelta = +monthRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ monthDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Day
</div>
<input
#dayRef
type="range"
min="-100"
max="100"
[value]="dayDelta"
(input)="( dayDelta = +dayRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ dayDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Hour
</div>
<input
#hourRef
type="range"
min="-100"
max="100"
[value]="hourDelta"
(input)="( hourDelta = +hourRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ hourDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Minute
</div>
<input
#minuteRef
type="range"
min="-100"
max="100"
[value]="minuteDelta"
(input)="( minuteDelta = +minuteRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ minuteDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Second
</div>
<input
#secondRef
type="range"
min="-100"
max="100"
[value]="secondDelta"
(input)="( secondDelta = +secondRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ secondDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Millis
</div>
<input
#millisecondRef
type="range"
min="-100"
max="100"
[value]="millisecondDelta"
(input)="( millisecondDelta = +millisecondRef.value )"
class="slider__input"
/>
<div class="slider__value">
{{ millisecondDelta }}
</div>
</div>
And now, when we run this Angular 11 application in the browser and adjust some of the range-inputs, we get the following output:
Again, my DateHelper
class probably doesn't cover every single use-case; but, it does cover 99% of my use-cases, which is good enough for me. And, if I can cut out some external dependencies in my Angular 11.0.0 application, cutting down on bundle-size and decreasing load-times, that's an investment I'm excited to make.
Want to use code from this post? Check out the license.
Reader Comments
Great job, Ben! I shall use this in my Angular projects.
By the way, I have been using your PatternMatcher.cfc, a lot recently. It is fabulous. The way it allows one to loop over a Regex capture and replace groups incrementally is awesome. It beats using the native REFindNoCase(), although I must say, this method now has an extra argument called SCOPE, which returns an array of matches and an extra item called MATCH [matched text]. So, it has improved somewhat in ACF2016 & Lucee 5.3
Anyway, I digress. Coldfusion is always on my mind...:)
@Charles,
Ha ha, that's awesome! Regular Expressions are just so powerful! Glad that some of the unending madness that comes out of my brain is useful :D
@All,
While I love Angular personally, the reality is that my work is still on AngularJS 1.2.22. As such, I wanted to follow up this post with a quick translation of the
DateHelper
TypeScript class into a vanilla JavaScript class that is compatible withAngularJS
:www.bennadel.com/blog/3927-replacing-all-external-date-libraries-with-300-lines-of-code-in-angularjs-1-2-22.htm
It was quite a pain-free translation. Mostly, I just stripped-out the type-annotations and replaced
formatDate()
with the$format( "date" )
pipe filter.