21 September 2015

Dates with REST services

No time, just a date. When I need a date (e.g. birth date), I would like to avoid dealing with timezones at all, because it doesn't matter what the time zone is on a date, as long as it displays the same date everywhere. I don't want the person in CST to see the date as different from the person in GMT. But the reality is that neither .NET nor Javascript have just a Date object without time and therefore time zone. So I want to use UTC midnight at all levels so that there is no chance of conversions causing a change in the way the date is saved or displayed.


Angular / Javascript


As near as I can tell, there is no way to make a Javascript Date object that is in UTC (unless the computer's local time is UTC). JS Dates are always in local time regardless of how they were created. So the first thing I need to do is give up on the Javascript Date object. It's worthless. The only way to make sure you are transmitting UTC to the server is to keep the value in ISO format string.

So as a result, I made an angular directive to accept/validate a formatted date and save it to the model in ISO format UTC. It also works with form validation. For now it only does US date format, but you are welcome to change it. If you do, I would put the format on the directive's attribute (e.g. date-format="M/d/yyyy"). No guarantees that this code has most efficient means of doing things, but it works for me.

(function () {
    angular
        .module('myModule')
        .directive('dateFormat', dateFormat);
    dateFormat.$inject = [];
    var isoUtcDateFmt = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T[0\:\.]*Z?$/;
    var inputDateFmt = /^(\d{1,2})[^0-9A-Za-z](\d{1,2})[^0-9A-Za-z](\d{4,})$/;
    var dayFn = {
        1: 31,
        // Gregorian rules
        // 29 days if divisible by 4 but not 100 unless divisible by 400
        2: function (y) { return y % 4 === 0 && (% 100 !== 0 || y % 400 === 0) ? 29 : 28; },
        3: 31,
        4: 30,
        5: 31,
        6: 30,
        7: 31,
        8: 31,
        9: 30,
        10: 31,
        11: 30,
        12: 31
    };
    function loadUtc(iso) {
        if (iso === undefined || iso === null || !isoUtcDateFmt.test(iso))
            return '';
        var month, day, year;
        iso.replace(isoUtcDateFmt, function (match, y, m, d) {
            month = m * 1;
            day = d * 1;
            year = y * 1;
            return '';
        });
        return '{0}/{1}/{2}'.format(month, day, year);
    }
    function leftPad(char, len, value) {
        var s = ('' + value);
        while (s.length < len)
            s = char + s;
        return s;
    }
    function saveUtc(ymd) {
        if (ymd === null)
            return null;
        var y = leftPad('0', 4, ymd.year), m = leftPad('0', 2, ymd.month), d = leftPad('0', 2, ymd.day);
        return '{0}-{1}-{2}T00:00:00Z'.format(y, m, d);
    }
    function inputToYmd(input) {
        if (input === null || input === undefined || !inputDateFmt.test(input))
            return null;
        var month, day, year;
        input.replace(inputDateFmt, function (match, m, d, y) {
            month = m * 1;
            day = d * 1;
            year = y * 1;
            return '';
        });
        return { year: year, month: month, day: day };
    }
    function validate(ymd, maxAge, minAge) {
        if (ymd === null)
            return [null];
        var year = ymd.year, month = ymd.month, day = ymd.day;
        var errors = [];
        var maxDays = 31;
        // basic checks
        var monthValid = 1 <= month && month <= 12;
        var dayValid = false;
        // calculate max days in month
        if (monthValid) {
            var maxDaysFn = dayFn[month];
            maxDays = angular.isNumber(maxDaysFn)
                ? maxDaysFn
                : (angular.isFunction(maxDaysFn)
                    ? maxDaysFn(year)
                    : maxDays);
        }
        dayValid = 1 <= day && day <= maxDays;
        if (!monthValid)
            errors.push('Month must be 1 to 12');
        if (!dayValid)
            errors.push('Day must be 1 to {0}'.format(maxDays));
        // min/max range checking
        if (errors.length === 0) {
            var now = new Date();
            var d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
            var todayTime = d.getTime();
            var todayYears = d.getFullYear();
            var minTime = d.setFullYear(todayYears - maxAge);
            var maxTime = d.setFullYear(todayYears - minAge);
            var testTime = new Date(year, month - 1, day).getTime();
            var dateValidMin = minTime <= testTime;
            var dateValidMax = testTime <= maxTime;
            if (!dateValidMin)
                errors.push('Max age is {0} years old'.format(maxAge));
            if (!dateValidMax)
                errors.push('Minimum age is {0} years old'.format(minAge));
        }
        return errors;
    }
    function dateFormat() {
        var me = this;
        return {
            scope: { dateErrors: '=' },
            require: 'ngModel',
            link: function (scope, element, attrs, ctrl) {
                var maxAge = attrs.maxAge || 1000;
                var minAge = attrs.minAge || -1000;
                //View -> Model
                ctrl.$parsers.push(function (data) {
                    if (data !== undefined && data !== null && data !== '') {
                        var ymd = inputToYmd(data);
                        var errors = validate(ymd, maxAge, minAge);
                        // set validity if possible
                        if (attrs.name)
                            ctrl.$setValidity(attrs.name, errors.length === 0);
                        // set errors if possible
                        if (scope.dateErrors)
                            scope.dateErrors = errors;
                        // send ISO date string to model
                        return saveUtc(ymd);
                    }
                    else {
                        // set errors if possible
                        if (scope.dateErrors)
                            scope.dateErrors = [];
                        return null;
                    }
                });
                //Model -> View
                ctrl.$formatters.push(function (_) {
                    var data = ctrl.$modelValue;
                    if (data !== undefined && data !== null && data !== '') {
                        var inputText = loadUtc(ctrl.$modelValue); // load from ISO date string
                        var ymd = inputToYmd(inputText);
                        var errors = validate(ymd, maxAge, minAge);
                        // set validity if possible
                        if (attrs.name)
                            ctrl.$setValidity(attrs.name, errors.length === 0);
                        // set errors if possible
                        if (scope.dateErrors)
                            scope.dateErrors = errors;
                        // send input to view
                        return inputText;
                    }
                    else {
                        // set errors if possible
                        if (scope.dateErrors)
                            scope.dateErrors = [];
                        return '';
                    }
                });
            }
        };
    }
})();

The input looks something like this:

    <input name="dob" type="text"
        required date-format max-age="110" min-age="14" date-errors="ctrl.dobErrors"
        ng-model="ctrl.dob" />
    <div ng-repeat="obj in ctrl.dobErrors track by $id(obj)">{{obj}}</div>

.NET


So when I get to the server side, I am using DateTimeOffset. The problem is that when I go to reserialize this date to JSON, the standard serializer (Newtonsoft.Json) plays dumb, only serializing to local time. Even changing the serializer settings to DateTimeZoneHandling.Utc doesn't fix it. Considering it was deserialized from UTC, the right thing would be to reserialize to UTC. JSON.NET just doesn't do the right thing here. But there is an included converter that will do the right thing with some nudging. That is link found here.