From a47d78907eb089c9160863068f1b5cd8099dacdc Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Mon, 4 Apr 2022 08:58:23 -0500 Subject: [PATCH] Switch to using Luxon to parse dates for the date time picker. Also, instead of using javascript Intl formats for date/times, use format strings chosen to match the perl DateTime::Locale formats. We have to use formats in any case to correctly parse strings back to a unix timestamp. So we might as well use them both ways, and make the perl display match that of the javascript better. There is only one exception. That is for the Korean language. The perl DateTime::Locale does not translate the AM/PM meridian, and the javascript does. There isn't much that can be done about that except for fixing the perl DateTime::Locale data. --- htdocs/js/apps/DatePicker/datepicker.js | 70 +++++++++++------- .../js/apps/ProblemSetList/problemsetlist.js | 72 ++++++++++++------- htdocs/package.json | 1 + htdocs/third-party-assets.json | 1 + .../Instructor/ProblemSetDetail.pm | 1 + .../Instructor/ProblemSetList.pm | 1 + .../ContentGenerator/Instructor/UserDetail.pm | 1 + 7 files changed, 94 insertions(+), 53 deletions(-) diff --git a/htdocs/js/apps/DatePicker/datepicker.js b/htdocs/js/apps/DatePicker/datepicker.js index 4e3ba560a6..e77ae78c3f 100644 --- a/htdocs/js/apps/DatePicker/datepicker.js +++ b/htdocs/js/apps/DatePicker/datepicker.js @@ -1,4 +1,24 @@ (() => { + // Date/time formats for the languages supported by webwork. + // Note that these formats are chosen to match the perl DateTime::Locale formats. + // Make sure that anytime a new language is added, its format is added here. + const datetimeFormats = { + en: 'L/d/yy, h:mm a', + 'en-US': 'L/d/yy, h:mm a', + 'cs-CZ': 'dd.LL.yy H:mm', + de: 'dd.LL.yy, HH:mm', + es: 'd/L/yy H:mm', + 'fr-CA': "yyyy-LL-dd HH 'h' mm", + fr: 'dd/LL/yyyy HH:mm', + 'he-IL': 'd.L.yyyy, H:mm', + hu: 'yyyy. LL. dd. H:mm', + ko: 'yy. L. d. a h:mm', + 'ru-RU': 'dd.LL.yyyy, HH:mm', + tr: 'd.LL.yyyy HH:mm', + 'zh-CN': 'yyyy/L/d ah:mm', + 'zh-HK': 'yyyy/L/d ah:mm' + }; + document.querySelectorAll('.datepicker-group').forEach((open_rule) => { const name = open_rule.name.replace('.open_date', ''); @@ -24,6 +44,8 @@ for (const rule of groupRules) { const orig_value = rule.value; + luxon.Settings.defaultLocale = rule.dataset.locale?.replaceAll(/_/g, '-') ?? 'en'; + // Compute the time difference between the current browser timezone and the the course timezone. // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. // Note that this is converted to seconds. @@ -40,6 +62,8 @@ minuteIncrement: 1, altInput: true, dateFormat: 'U', + altFormat: datetimeFormats[luxon.Settings.defaultLocale], + ariaDateFormat: datetimeFormats[luxon.Settings.defaultLocale], defaultDate: orig_value, defaultHour: 0, locale: rule.dataset.locale ? rule.dataset.locale.substring(0, 2) : 'en', @@ -48,10 +72,6 @@ wrap: true, plugins: [ new confirmDatePlugin({ confirmText: rule.dataset.doneText ?? 'Done', showAlways: true }) ], onChange(selectedDates) { - // If the altInput field has been emptied, then the formatDate method still sets the hidden input. - // So set that back to empty again. - if (!selectedDates.length) this.input.value = ''; - if (this.input.value === orig_value) this.altInput.classList.remove('changed'); else this.altInput.classList.add('changed'); }, @@ -63,34 +83,32 @@ this.altInput.after(this.input); this.altInput.addEventListener('blur', update); - - // If the inital value is empty, then the formatDate method still sets the hidden input. - // So set that back to empty again. - if (!selectedDates.length) this.input.value = ''; }, parseDate(datestr, format) { - // Deal with the case of a unix timestamp on initial load. At this time the timezone needs to be - // adjusted backward as flatpickr is going to use the browser's time zone. + // Deal with the case of a unix timestamp. The timezone needs to be adjusted back as this is for + // the unix timestamp stored in the hidden input whose value will be sent to the server. if (format === 'U') return new Date(parseInt(datestr) * 1000 - timezoneAdjustment); - // Next attempt to parse the datestr with the current format. This should not be adjusted. - const date = new Date(Date.parse(datestr, format)); - if (!isNaN(date.getTime())) return date; - // Finally, fall back to the previous value in the original input if that failed. This also needs - // to be adjusted back since the adjusted timestamp is saved in the input. - return new Date(parseInt(rule.value) * 1000 - timezoneAdjustment); + + // Next attempt to parse the datestr with the current format. This should not be adjusted. It is + // for display only. + const date = luxon.DateTime.fromFormat(datestr, format); + if (date.isValid) return date.toJSDate(); + + // Finally, fall back to the previous value in the original input if that failed. This is the case + // that the user typed a time that isn't in the valid format. So fallback to the last valid time + // that was displayed. This also should not be adjusted. + return new Date(this.lastFormattedDate.getTime()); }, - formatDate(date) { - // flatpickr gives the date in the browser's time zone. So it needs to be adjusted to the timezone - // of the course. + formatDate(date, format) { + // Save this date for the fallback in parseDate. + this.lastFormattedDate = date; - // Flatpickr sets the value of the original input to the parsed time. - // So set that back to the unix timestamp. - rule.value = date.getTime() / 1000 + timezoneAdjustment / 1000; + // In this case the date provided is in the browser's time zone. So it needs to be adjusted to the + // timezone of the course. + if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000; - // Return the localized time string. - return Intl.DateTimeFormat(rule.dataset.locale?.replaceAll(/_/g, '-') ?? 'en', - { dateStyle: 'short', timeStyle: 'short', timeZone: rule.dataset.timezone ?? 'UTC' }) - .format(new Date(date.getTime() + timezoneAdjustment)); + return luxon.DateTime.fromMillis(date.getTime()).toFormat( + datetimeFormats[luxon.Settings.defaultLocale]); } }); } diff --git a/htdocs/js/apps/ProblemSetList/problemsetlist.js b/htdocs/js/apps/ProblemSetList/problemsetlist.js index 83c20d3409..c5df52913e 100644 --- a/htdocs/js/apps/ProblemSetList/problemsetlist.js +++ b/htdocs/js/apps/ProblemSetList/problemsetlist.js @@ -41,9 +41,32 @@ }); } + // Date/time formats for the languages supported by webwork. + // Note that these formats are chosen to match the perl DateTime::Locale formats. + // Make sure that anytime a new language is added, its format is added here. + const datetimeFormats = { + en: 'L/d/yy, h:mm a', + 'en-US': 'L/d/yy, h:mm a', + 'cs-CZ': 'dd.LL.yy H:mm', + de: 'dd.LL.yy, HH:mm', + es: 'd/L/yy H:mm', + 'fr-CA': "yyyy-LL-dd HH 'h' mm", + fr: 'dd/LL/yyyy HH:mm', + 'he-IL': 'd.L.yyyy, H:mm', + hu: 'yyyy. LL. dd. H:mm', + ko: 'yy. L. d. a h:mm', + 'ru-RU': 'dd.LL.yyyy, HH:mm', + tr: 'd.LL.yyyy HH:mm', + 'zh-CN': 'yyyy/L/d ah:mm', + 'zh-HK': 'yyyy/L/d ah:mm' + }; + // Initialize the date/time picker for the import form. const importDateShift = document.getElementById('import_date_shift'); if (importDateShift) { + + luxon.Settings.defaultLocale = importDateShift.dataset.locale?.replaceAll(/_/g, '-') ?? 'en'; + // Compute the time difference between the current browser timezone and the the course timezone. // flatpickr gives the time in the browser's timezone, and this is used to adjust to the course timezone. // Note that this is converted to microseconds. @@ -60,6 +83,8 @@ minuteIncrement: 1, altInput: true, dateFormat: 'U', + altFormat: datetimeFormats[luxon.Settings.defaultLocale], + ariaDateFormat: datetimeFormats[luxon.Settings.defaultLocale], defaultHour: 0, locale: importDateShift.dataset.locale ? importDateShift.dataset.locale.substring(0, 2) : 'en', clickOpens: false, @@ -71,39 +96,32 @@ // bootstrap input group styling. So move the now hidden original input after the created alternate // input to fix that. this.altInput.after(this.input); - - // If the inital value is empty, then the formatDate method still sets the hidden input. - // So set that back to empty again. - if (!selectedDates.length) this.input.value = ''; - }, - onChange(selectedDates) { - // If the altInput field has been emptied, then the formatDate method still sets the hidden input. - // So set that back to empty again. - if (!selectedDates.length) this.input.value = ''; }, parseDate(datestr, format) { - // Deal with the case of a unix timestamp on initial load. At this time the timezone needs to be - // adjusted backward as flatpickr is going to use the browser's time zone. + // Deal with the case of a unix timestamp. The timezone needs to be adjusted back as this is for + // the unix timestamp stored in the hidden input whose value will be sent to the server. if (format === 'U') return new Date(parseInt(datestr) * 1000 - timezoneAdjustment); - // Next attempt to parse the datestr with the current format. This should not be adjusted. - const date = new Date(Date.parse(datestr, format)); - if (!isNaN(date.getTime())) return date; - // Finally, fall back to the previous value in the original input if that failed. This also needs - // to be adjusted back since the adjusted timestamp is saved in the input. - return new Date(parseInt(importDateShift.value) * 1000 - timezoneAdjustment); + + // Next attempt to parse the datestr with the current format. This should not be adjusted. It is + // for display only. + const date = luxon.DateTime.fromFormat(datestr, format); + if (date.isValid) return date.toJSDate(); + + // Finally, fall back to the previous value in the original input if that failed. This is the case + // that the user typed a time that isn't in the valid format. So fallback to the last valid time + // that was displayed. This also should not be adjusted. + return new Date(this.lastFormattedDate.getTime()); }, - formatDate(date) { - // flatpickr gives the date in the browser's time zone. So it needs to be adjusted to the timezone - // of the course. + formatDate(date, format) { + // Save this date for the fallback in parseDate. + this.lastFormattedDate = date; - // Flatpickr sets the value of the original input to the parsed time. - // So set that back to the unix timestamp. - importDateShift.value = date.getTime() / 1000 + timezoneAdjustment / 1000; + // In this case the date provided is in the browser's time zone. So it needs to be adjusted to the + // timezone of the course. + if (format === 'U') return (date.getTime() + timezoneAdjustment) / 1000; - // Return the localized time string. - return Intl.DateTimeFormat(importDateShift.dataset.locale?.replaceAll(/_/g, '-') ?? 'en', - { dateStyle: 'short', timeStyle: 'short', timeZone: importDateShift.dataset.timezone ?? 'UTC' }) - .format(new Date(date.getTime() + timezoneAdjustment)); + return luxon.DateTime.fromMillis(date.getTime()).toFormat( + datetimeFormats[luxon.Settings.defaultLocale]); } }); } diff --git a/htdocs/package.json b/htdocs/package.json index a5f6e74a95..d759d10e90 100644 --- a/htdocs/package.json +++ b/htdocs/package.json @@ -18,6 +18,7 @@ "iframe-resizer": "^4.3.2", "jquery": "^3.6.0", "jquery-ui-dist": "^1.13.1", + "luxon": "^2.3.1", "mathjax": "^3.2.0", "sortablejs": "^1.14.0" }, diff --git a/htdocs/third-party-assets.json b/htdocs/third-party-assets.json index 701a677372..89957c826b 100644 --- a/htdocs/third-party-assets.json +++ b/htdocs/third-party-assets.json @@ -98,6 +98,7 @@ "node_modules/jquery-ui-dist/jquery-ui.min.css": "https://cdn.jsdelivr.net/npm/jquery-ui-dist@1.13.1/jquery-ui.min.css", "node_modules/jquery-ui-dist/jquery-ui.min.js": "https://cdn.jsdelivr.net/npm/jquery-ui-dist@1.13.1/jquery-ui.min.js", "node_modules/jquery/dist/jquery.min.js": "https://cdn.jsdelivr.net/npm/jquery@3.6.0/dist/jquery.min.js", + "node_modules/luxon/build/global/luxon.min.js": "https://cdn.jsdelivr.net/npm/luxon@2.3.1/build/global/luxon.min.js", "node_modules/mathjax/es5/tex-chtml.js": "https://cdn.jsdelivr.net/npm/mathjax@3.2.0/es5/tex-chtml.js", "node_modules/sortablejs/Sortable.min.js": "https://cdn.jsdelivr.net/npm/sortablejs@1.14.0/Sortable.min.js" } diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm index 6d13c9e38b..5f350f16fb 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm @@ -2894,6 +2894,7 @@ sub output_JS { rel => 'stylesheet', href => getAssetURL($ce, 'node_modules/flatpickr/dist/plugins/confirmDate/confirmDate.css') }); + print CGI::script({ src => getAssetURL($ce, 'node_modules/luxon/build/global/luxon.min.js'), defer => undef }, ''); print CGI::script({ src => getAssetURL($ce, 'node_modules/flatpickr/dist/flatpickr.min.js'), defer => undef }, ''); if ($ce->{language} !~ /^en/) { print CGI::script( diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm index 986aebf87c..5bcb070bc6 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm @@ -2744,6 +2744,7 @@ sub output_JS { rel => 'stylesheet', href => getAssetURL($ce, 'node_modules/flatpickr/dist/plugins/confirmDate/confirmDate.css') }); + print CGI::script({ src => getAssetURL($ce, 'node_modules/luxon/build/global/luxon.min.js'), defer => undef }, ''); print CGI::script({ src => getAssetURL($ce, 'node_modules/flatpickr/dist/flatpickr.min.js'), defer => undef }, ''); if ($ce->{language} !~ /^en/) { print CGI::script( diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm index e5a24a08c5..2e218828fa 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserDetail.pm @@ -506,6 +506,7 @@ sub output_JS { rel => 'stylesheet', href => getAssetURL($ce, 'node_modules/flatpickr/dist/plugins/confirmDate/confirmDate.css') }); + print CGI::script({ src => getAssetURL($ce, 'node_modules/luxon/build/global/luxon.min.js'), defer => undef }, ''); print CGI::script({ src => getAssetURL($ce, 'node_modules/flatpickr/dist/flatpickr.min.js'), defer => undef }, ''); if ($ce->{language} !~ /^en/) { print CGI::script(