import angular from 'angular' import { DateTime } from 'luxon' // This type is necessary because the directive link function expects something // conforming to ng.IController, but we want the types from ng.INgModelController // and that model controller doesn't extend the base IController type. type PatchedNgModelController = ng.IController & ng.INgModelController angular.module('app').directive('mflyDateTimeZonePicker', ['dateTimeZoneFactory', function mflyDateTimeZonePicker(dateTimeZoneFactory) { return { restrict: 'E', require: 'ngModel', scope: { format: '@', inputId: '@', isDisabled: '=', isRequired: '@', onOpen: '&', type: '=', }, template: require('./date-time-zone-picker.html'), link($scope: ScopeKeyValuePair, _element, _attr, ngModelCtrl: PatchedNgModelController) { // Init function creates DateTime dates from our utc string provided by the API // These DateTime dates are bound to the datepicker and timepicker (the // timezone object is also created but is not a DateTime) // Each component only alters its respective DateTime date, then tells this // component when a change is made // When a change is made, this component combines the 3 datetime elements // and sends a utc string back to the api // ------------------------------------------------------------------ function initParentLuxonFromAPI(value) { // Create a base DateTime and bound copies for each child component. if (value instanceof Date) { $scope.parentLuxon = DateTime.fromJSDate(value) } else if (typeof value === 'string') { // Parse with setZone: true to preserve the offset from the string const dt = DateTime.fromISO(value, { setZone: true }) // Find the matching time zone object const matchedZone = dateTimeZoneFactory.getTimeZone(dt) // If found, set the zone on the DateTime object if (matchedZone) { // Format offset as UTC±HH:MM for non-integer offsets const absOffset = Math.abs(matchedZone.value) const hours = Math.floor(absOffset) const minutes = Math.round((absOffset - hours) * 60) const sign = matchedZone.value >= 0 ? '+' : '-' const zoneString = minutes === 0 ? `UTC${sign}${hours}` : `UTC${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` $scope.parentLuxon = dt.setZone(zoneString, { keepLocalTime: true }) $scope.timeZone = matchedZone } else { // Fallback: just use the parsed DateTime $scope.parentLuxon = dt $scope.timeZone = null } } else { throw new Error(`Invalid value type ${typeof value} (${value})`) } $scope.timeLuxon = DateTime.fromObject({ hour: $scope.parentLuxon.hour, minute: $scope.parentLuxon.minute, second: 0, millisecond: 0 }) $scope.dateLuxon = DateTime.fromObject({ year: $scope.parentLuxon.year, month: $scope.parentLuxon.month, day: $scope.parentLuxon.day }) } function initParentLuxonFromDatePicker(newDate) { // when selecting a date from null or for the first time we need to // initialize the parentLuxon $scope.parentLuxon = DateTime.now() // the dateLuxon is set by the datepicker, but we want to update the parent // luxon to have the same date $scope.parentLuxon = $scope.parentLuxon.set({ month: newDate.month, year: newDate.year, day: newDate.day }) // we then init the timeLuxon with a default of 12:00am $scope.timeLuxon = DateTime.fromFormat('12:00 AM', 'hh:mm a') // parent luxon will have the now, but should also initially have 12:00am $scope.parentLuxon = $scope.parentLuxon.set({ hour: $scope.timeLuxon.hour, minute: $scope.timeLuxon.minute, second: 0, millisecond: 0, }) // and init the time zone from the users's local time zone as we did in the initFromAPI function $scope.timeZone = dateTimeZoneFactory.getTimeZone($scope.parentLuxon) } // Drop every DateTime we derive from the model. Nulling only the dateLuxon left a // live parentLuxon/timeLuxon/timeZone behind, which then got silently reused the // next time a date was picked. function clearLuxonState() { $scope.parentLuxon = null $scope.dateLuxon = null $scope.timeLuxon = null $scope.timeZone = null } // Formatter runs when incoming values come from the model - this is // where we initialize our DateTime. ngModelCtrl.$formatters.push(function (modelValue) { if (modelValue) { initParentLuxonFromAPI(modelValue) } else { // If we don't have a value, we should insure we clear the control clearLuxonState() } return modelValue }) ngModelCtrl.$parsers.push(function (modelValue) { return modelValue }) function updateAPIFromLuxon() { if ($scope.parentLuxon) { ngModelCtrl.$setViewValue($scope.parentLuxon.toISO()) } else { ngModelCtrl.$setViewValue(null) } } $scope.updateDate = function (newDate) { // This is fired when the datepicker has updated the date portion of the DateTime // Update the parentLuxon with only the date change if (newDate) { // Update dateLuxon to reflect the new date (needed for template visibility) $scope.dateLuxon = newDate if ($scope.parentLuxon) { // if we have a parent luxon update it $scope.parentLuxon = $scope.parentLuxon.set({ month: newDate.month, year: newDate.year, day: newDate.day }) } else { // if we do not have a parent luxon b/c no previous date existed initParentLuxonFromDatePicker(newDate) } // changing between DST could alter our UTC offset // this adjustment happens in updateTimeZone which we can call here if ($scope.timeZone) { $scope.updateTimeZone($scope.timeZone.value) } } else { clearLuxonState() } updateAPIFromLuxon() } $scope.updateTime = function (newTime) { if (!$scope.parentLuxon) { return } // This is fired when the timepicker has updated the time portion of the DateTime // Update the parentLuxon with only the time change $scope.parentLuxon = $scope.parentLuxon.set({ hour: newTime.hour, minute: newTime.minute, second: 0, millisecond: 0, }) updateAPIFromLuxon() } $scope.updateTimeZone = function (zoneOffset) { if ($scope.parentLuxon) { // The zone offset displays with a dst offset (see date-time-zone-factory for details) // When we save back to the API we need to re-account for that offset // Create a DateTime to determine if we are dst const isDST = DateTime.fromISO($scope.parentLuxon.toISO()).isInDST // adjust the zoneOffset passed from the UI if the date is dst zoneOffset = (isDST) ? zoneOffset + 1 : zoneOffset // Convert fractional hours to HH:MM format for Luxon // Luxon expects UTC offsets in the format "UTC+HH:MM" or "UTC-HH:MM" const sign = zoneOffset >= 0 ? '+' : '-' const absOffset = Math.abs(zoneOffset) const hours = Math.floor(absOffset) const minutes = Math.round((absOffset - hours) * 60) let zoneString if (minutes === 0) { // For whole hours, we can use the simple format like "UTC+3" or "UTC-5" zoneString = `UTC${sign}${hours}` } else { // For fractional hours, we need the HH:MM format like "UTC+03:30" or "UTC-03:30" zoneString = `UTC${sign}${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}` } $scope.parentLuxon = $scope.parentLuxon.setZone(zoneString, { keepLocalTime: true }) } updateAPIFromLuxon() } $scope.pickerOpened = function () { $scope.onOpen() } } } }])