Replacing All External Date Libraries With 300 Lines-Of-Code In AngularJS 1.2.22
Yesterday, I looked at replacing all external date libraries in Angular 11.0.0. And, while that was an exciting post for me personally, it doesn't much help me professionally because, at work, we're still running a massive "legacy app" on AngularJS 1.2.22 (with some parts on 1.7). As such, I wanted to follow up with a quick post that translates my TypeScript-based DateHelper
class into an AngularJS-compatible dateHelper
factory that provides the same date utilities in AngularJS 1.2.22.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
Since this post is just a translation / back-porting of my previous post in Angular 11, I won't go into much detail; this post is really just for personal reference. The main difference between this post and the previous post, other than obvious TypeScript differences, is that AngularJS doesn't have a formatDate()
function. Instead, it has a $filter("date")
pipe function. Luckily, the $filter("date")
pipe function in AngularJS works almost exactly like the formatDate()
function in Angular 11.
Here's my vanilla JavaScript version of the DateHelper
class in my AngularJS module:
(function( ng, app ) {
"use strict";
app.factory( "dateHelper", DateHelperFactory );
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 );
function DateHelperFactory( $filter ) {
// Return the public API.
return({
add: add,
daysInMonth: daysInMonth,
diff: diff,
format: format,
fromNow: fromNow
});
// ---
// PUBLIC METHODS.
// ---
// I add the given date/time delta to the given date. A new date is returned.
function add( part, delta, input ) {
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 "sss":
result.setMilliseconds( result.getMilliseconds() + delta );
break;
// NOTE: Unlike in the TypeScript version, we can't rely on the compiler
// to limit the value of "part" based on our type definitions.
default:
throw( new Error( "Unsupported date part: " + part ) );
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.
function daysInMonth( year, month ) {
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.
function diff( part, leftDateInput, rightDateInput ) {
var delta = ( getTickCount( rightDateInput ) - 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 "sss":
return( delta * multiplier );
break;
// NOTE: Unlike in the TypeScript version, we can't rely on the compiler
// to limit the value of "part" based on our type definitions.
default:
throw( new Error( "Unsupported date part: " + part ) );
break;
}
}
// I proxy the native date $filter.
function format( value, mask ) {
return( $filter( "date" )( value, mask ) );
}
// I return a human-friendly, relative date-string for the given input. This is
// intended to mimic the .fromNow() method in Moment.js:
function fromNow( value ) {
var nowTick = getTickCount();
var valueTick = 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.
function getTickCount( value ) {
if ( value === undefined ) {
value = Date.now();
}
// 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() );
}
}
})( angular, app );
As you can see, this dateHelper
implementation is almost exactly like the TypeScript version of my DateHelper
class in the previous post. Mostly, I just stripped-out the type annotation and added slightly more error-handling (namely, a throw()
on invalid date-parts).
And, to test this, I duplicated the same App "component" in AngularJS:
<!doctype html>
<html lang="en" ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Replacing All External Date Libraries With 300 Lines-Of-Code In AngularJS 1.2.22
</title>
<link rel="stylesheet" type="text/css" href="./demo.css">
</head>
<body ng-controller="appController">
<h1>
Replacing All External Date Libraries With 300 Lines-Of-Code In AngularJS 1.2.22
</h1>
<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
type="range"
min="-100"
max="100"
ng-model="yearDelta"
class="slider__input"
/>
<div class="slider__value">
{{ yearDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Month
</div>
<input
type="range"
min="-100"
max="100"
ng-model="monthDelta"
class="slider__input"
/>
<div class="slider__value">
{{ monthDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Day
</div>
<input
type="range"
min="-100"
max="100"
ng-model="dayDelta"
class="slider__input"
/>
<div class="slider__value">
{{ dayDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Hour
</div>
<input
type="range"
min="-100"
max="100"
ng-model="hourDelta"
class="slider__input"
/>
<div class="slider__value">
{{ hourDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Minute
</div>
<input
type="range"
min="-100"
max="100"
ng-model="minuteDelta"
class="slider__input"
/>
<div class="slider__value">
{{ minuteDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Second
</div>
<input
type="range"
min="-100"
max="100"
ng-model="secondDelta"
class="slider__input"
/>
<div class="slider__value">
{{ secondDelta }}
</div>
</div>
<div class="slider">
<div class="slider__label">
Millis
</div>
<input
type="range"
min="-100"
max="100"
ng-model="millisecondDelta"
class="slider__input"
/>
<div class="slider__value">
{{ millisecondDelta }}
</div>
</div>
<!-- ---------------------------------------------------------------------------- -->
<!-- ---------------------------------------------------------------------------- -->
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.22.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
</script>
<script type="text/javascript" src="./date-helper.js"></script>
<script type="text/javascript">
app.controller( "appController", AppController );
function AppController( $scope, dateHelper ) {
$scope.baseDate = new Date();
$scope.dayDelta = 0;
$scope.hourDelta = 0;
$scope.millisecondDelta = 0;
$scope.minuteDelta = 0;
$scope.monthDelta = 0;
$scope.relativeDate = "";
$scope.secondDelta = 0;
$scope.yearDelta = 0;
$scope.dateMask = "yyyy-MM-dd HH:mm:ss.sss";
$scope.formattedDate = "";
$scope.relativeDate = "";
// 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.
$scope.$watch(
function evaluateExpression() {
var result = $scope.baseDate;
// The .add() function returns a NEW date each time, so we have to
// keep saving and reusing the result of each call.
// --
// CAUTION: Notice that we are casting the String value of the
// ngModel to Numbers.
result = dateHelper.add( "year", +$scope.yearDelta, result );
result = dateHelper.add( "month", +$scope.monthDelta, result );
result = dateHelper.add( "day", +$scope.dayDelta, result );
result = dateHelper.add( "hour", +$scope.hourDelta, result );
result = dateHelper.add( "minute", +$scope.minuteDelta, result );
result = dateHelper.add( "second", +$scope.secondDelta, result );
result = dateHelper.add( "millisecond", +$scope.millisecondDelta, result );
// NOTE: We have to return the TIME - not the result - or we run into
// an infinite-digest problem. We need to give Angular a simple value
// so that the new/old values become equivalent when no change has
// taken place.
return( result.getTime() );
},
function handleModelChange( newValue, oldValue ) {
var result = new Date( newValue );
$scope.formattedDate = dateHelper.format( result, $scope.dateMask );
$scope.relativeDate = dateHelper.fromNow( result );
console.group( "Angular Digest" );
console.log( "Date:", $scope.formattedDate );
console.log( "Relative Date:", $scope.relativeDate );
// Log the diff() for the given date - how much it is LESS THAN the base date.
console.log(
"Diff:",
dateHelper.diff( "year", result, $scope.baseDate ),
dateHelper.diff( "month", result, $scope.baseDate ),
dateHelper.diff( "day", result, $scope.baseDate ),
dateHelper.diff( "hour", result, $scope.baseDate ),
dateHelper.diff( "minute", result, $scope.baseDate ),
dateHelper.diff( "second", result, $scope.baseDate ),
dateHelper.diff( "millisecond", result, $scope.baseDate )
);
// Log the days in the given month.
console.log(
"Days in month:",
dateHelper.daysInMonth( result.getFullYear(), result.getMonth() )
);
console.groupEnd();
}
);
}
</script>
</body>
</html>
Just as with the previous post, I'm hooking into the AngularJS change-detection digest using $scope.$watch()
. And, whenever the tick-count of the composite date changes, I update the formatted dates and console.log()
some information about the resultant date deltas. Translating the Angular 11 version into the AngularJS 1.2.22 version was actually quite painless. This whole demo took me about 45-minutes to put together.
And, when we run the above AngularJS code in the browser, we get the following output:
As you can see, it works like a charm! I'm able to adjust the date using .add()
, format it using .format()
, get the relative duration using .fromNow()
, and get the differences using .diff()
. This doesn't cover everything that a Date library would need to cover; but, it covers just about everything that I need to do in my AngularJS 1.2.22 applications.
And, don't forget, the Date()
object in JavaScript is pretty dang powerful. In fact, half of the functionality in my dateHelper
factory function is just leaning on the underlying Date()
object. So, not everything date-related will even need to be in this factory - some things will just use the native Date()
behaviors.
Want to use code from this post? Check out the license.
Reader Comments