From 92aa799e89199f48c0977f9a535ae62adecd382b Mon Sep 17 00:00:00 2001 From: Ian Dunn Date: Wed, 14 Oct 2020 18:19:43 +0000 Subject: [PATCH] Community Events: Display dates and times in the user's time zone. Fixes #51130 Props sippis, hlashbrooke, audrasjb, Rarst, iandunn git-svn-id: https://develop.svn.wordpress.org/trunk@49146 602fd350-edb4-49c9-b593-d223f7449a82 --- src/js/_enqueues/wp/dashboard.js | 200 ++++++++++++++++ .../includes/class-wp-community-events.php | 13 +- src/wp-admin/includes/dashboard.php | 6 +- src/wp-includes/script-loader.php | 4 +- tests/qunit/index.html | 6 + tests/qunit/wp-admin/js/dashboard.js | 219 ++++++++++++++++++ 6 files changed, 443 insertions(+), 5 deletions(-) create mode 100644 tests/qunit/wp-admin/js/dashboard.js diff --git a/src/js/_enqueues/wp/dashboard.js b/src/js/_enqueues/wp/dashboard.js index 581746bb45..87a8493128 100644 --- a/src/js/_enqueues/wp/dashboard.js +++ b/src/js/_enqueues/wp/dashboard.js @@ -266,6 +266,11 @@ jQuery( function( $ ) { 'use strict'; var communityEventsData = window.communityEventsData || {}, + dateI18n = wp.date.dateI18n, + format = wp.date.format, + sprintf = wp.i18n.sprintf, + __ = wp.i18n.__, + _x = wp.i18n._x, app; /** @@ -441,6 +446,7 @@ jQuery( function( $ ) { .fail( function() { app.renderEventsTemplate({ 'location' : false, + 'events' : [], 'error' : true }, initiatedBy ); }); @@ -465,6 +471,11 @@ jQuery( function( $ ) { $locationMessage = $( '#community-events-location-message' ), $results = $( '.community-events-results' ); + templateParams.events = app.populateDynamicEventFields( + templateParams.events, + communityEventsData.time_format + ); + /* * Hide all toggleable elements by default, to keep the logic simple. * Otherwise, each block below would have to turn hide everything that @@ -576,6 +587,195 @@ jQuery( function( $ ) { } else { app.toggleLocationForm( 'show' ); } + }, + + /** + * Populate event fields that have to be calculated on the fly. + * + * These can't be stored in the database, because they're dependent on + * the user's current time zone, locale, etc. + * + * @since 5.6.0 + * + * @param {Array} rawEvents The events that should have dynamic fields added to them. + * @param {string} timeFormat A time format acceptable by `wp.date.dateI18n()`. + * + * @returns {Array} + */ + populateDynamicEventFields: function( rawEvents, timeFormat ) { + // Clone the parameter to avoid mutating it, so that this can remain a pure function. + var populatedEvents = JSON.parse( JSON.stringify( rawEvents ) ); + + $.each( populatedEvents, function( index, event ) { + var timeZone = app.getTimeZone( event.start_unix_timestamp * 1000 ); + + event.user_formatted_date = app.getFormattedDate( + event.start_unix_timestamp * 1000, + event.end_unix_timestamp * 1000, + timeZone + ); + + event.user_formatted_time = dateI18n( + timeFormat, + event.start_unix_timestamp * 1000, + timeZone + ); + + event.timeZoneAbbreviation = app.getTimeZoneAbbreviation( event.start_unix_timestamp * 1000 ); + } ); + + return populatedEvents; + }, + + /** + * Returns the user's local/browser time zone, in a form suitable for `wp.date.i18n()`. + * + * @since 5.6.0 + * + * @param startTimestamp + * + * @returns {string|number} + */ + getTimeZone: function( startTimestamp ) { + /* + * Prefer a name like `Europe/Helsinki`, since that automatically tracks daylight savings. This + * doesn't need to take `startTimestamp` into account for that reason. + */ + var timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + /* + * Fall back to an offset for IE11, which declares the property but doesn't assign a value. + */ + if ( 'undefined' === typeof timeZone ) { + /* + * It's important to use the _event_ time, not the _current_ + * time, so that daylight savings time is accounted for. + */ + timeZone = app.getFlippedTimeZoneOffset( startTimestamp ); + } + + return timeZone; + }, + + /** + * Get intuitive time zone offset. + * + * `Data.prototype.getTimezoneOffset()` returns a positive value for time zones + * that are _behind_ UTC, and a _negative_ value for ones that are ahead. + * + * See https://stackoverflow.com/questions/21102435/why-does-javascript-date-gettimezoneoffset-consider-0500-as-a-positive-off. + * + * @since 5.6.0 + * + * @param {number} startTimestamp + * + * @returns {number} + */ + getFlippedTimeZoneOffset: function( startTimestamp ) { + return new Date( startTimestamp ).getTimezoneOffset() * -1; + }, + + /** + * Get a short time zone name, like `PST`. + * + * @since 5.6.0 + * + * @param {number} startTimestamp + * + * @returns {string} + */ + getTimeZoneAbbreviation: function( startTimestamp ) { + var timeZoneAbbreviation, + eventDateTime = new Date( startTimestamp ); + + /* + * Leaving the `locales` argument undefined is important, so that the browser + * displays the abbreviation that's most appropriate for the current locale. For + * some that will be `UTC{+|-}{n}`, and for others it will be a code like `PST`. + * + * This doesn't need to take `startTimestamp` into account, because a name like + * `America/Chicago` automatically tracks daylight savings. + */ + var shortTimeStringParts = eventDateTime.toLocaleTimeString( undefined, { timeZoneName : 'short' } ).split( ' ' ); + + if ( 3 === shortTimeStringParts.length ) { + timeZoneAbbreviation = shortTimeStringParts[2]; + } + + if ( 'undefined' === typeof timeZoneAbbreviation ) { + /* + * It's important to use the _event_ time, not the _current_ + * time, so that daylight savings time is accounted for. + */ + var timeZoneOffset = app.getFlippedTimeZoneOffset( startTimestamp ), + sign = -1 === Math.sign( timeZoneOffset ) ? '' : '+'; + + // translators: Used as part of a string like `GMT+5` in the Events Widget. + timeZoneAbbreviation = _x( 'GMT', 'Events widget offset prefix' ) + sign + ( timeZoneOffset / 60 ); + } + + return timeZoneAbbreviation; + }, + + /** + * Format a start/end date in the user's local time zone and locale. + * + * @since 5.6.0 + * + * @param {int} startDate The Unix timestamp in milliseconds when the the event starts. + * @param {int} endDate The Unix timestamp in milliseconds when the the event ends. + * @param {string} timeZone A time zone string or offset which is parsable by `wp.date.i18n()`. + * + * @returns {string} + */ + getFormattedDate: function( startDate, endDate, timeZone ) { + var formattedDate; + + /* + * The `date_format` option is not used because it's important + * in this context to keep the day of the week in the displayed date, + * so that users can tell at a glance if the event is on a day they + * are available, without having to open the link. + * + * The case of crossing a year boundary is intentionally not handled. + * It's so rare in practice that it's not worth the complexity + * tradeoff. The _ending_ year should be passed to + * `multiple_month_event`, though, just in case. + */ + /* translators: Date format for upcoming events on the dashboard. Include the day of the week. See https://www.php.net/manual/datetime.format.php */ + var singleDayEvent = __( 'l, M j, Y' ), + /* translators: Date string for upcoming events. 1: Month, 2: Starting day, 3: Ending day, 4: Year. */ + multipleDayEvent = __( '%1$s %2$d–%3$d, %4$d' ), + /* translators: Date string for upcoming events. 1: Starting month, 2: Starting day, 3: Ending month, 4: Ending day, 5: Ending year. */ + multipleMonthEvent = __( '%1$s %2$d – %3$s %4$d, %5$d' ); + + // Detect single-day events. + if ( ! endDate || format( 'Y-m-d', startDate ) === format( 'Y-m-d', endDate ) ) { + formattedDate = dateI18n( singleDayEvent, startDate, timeZone ); + + // Multiple day events. + } else if ( format( 'Y-m', startDate ) === format( 'Y-m', endDate ) ) { + formattedDate = sprintf( + multipleDayEvent, + dateI18n( _x( 'F', 'upcoming events month format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), endDate, timeZone ), + dateI18n( _x( 'Y', 'upcoming events year format' ), endDate, timeZone ) + ); + + // Multi-day events that cross a month boundary. + } else { + formattedDate = sprintf( + multipleMonthEvent, + dateI18n( _x( 'F', 'upcoming events month format' ), startDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), startDate, timeZone ), + dateI18n( _x( 'F', 'upcoming events month format' ), endDate, timeZone ), + dateI18n( _x( 'j', 'upcoming events day format' ), endDate, timeZone ), + dateI18n( _x( 'Y', 'upcoming events year format' ), endDate, timeZone ) + ); + } + + return formattedDate; } }; diff --git a/src/wp-admin/includes/class-wp-community-events.php b/src/wp-admin/includes/class-wp-community-events.php index 37c71be412..6f4a101067 100644 --- a/src/wp-admin/includes/class-wp-community-events.php +++ b/src/wp-admin/includes/class-wp-community-events.php @@ -77,6 +77,8 @@ class WP_Community_Events { * mitigates possible privacy concerns. * * @since 4.8.0 + * @since 5.6.0 Response no longer contains formatted date field. They're added + * in `wp.communityEvents.populateDynamicEventFields()` now. * * @param string $location_search Optional. City name to help determine the location. * e.g., "Seattle". Default empty string. @@ -165,7 +167,6 @@ class WP_Community_Events { $this->cache_events( $response_body, $expiration ); $response_body['events'] = $this->trim_events( $response_body['events'] ); - $response_body = $this->format_event_data_time( $response_body ); return $response_body; } @@ -344,6 +345,8 @@ class WP_Community_Events { * Gets cached events. * * @since 4.8.0 + * @since 5.6.0 Response no longer contains formatted date field. They're added + * in `wp.communityEvents.populateDynamicEventFields()` now. * * @return array|false An array containing `location` and `events` items * on success, false on failure. @@ -355,7 +358,7 @@ class WP_Community_Events { $cached_response['events'] = $this->trim_events( $cached_response['events'] ); } - return $this->format_event_data_time( $cached_response ); + return $cached_response; } /** @@ -372,6 +375,12 @@ class WP_Community_Events { * @return array The response with dates and times formatted. */ protected function format_event_data_time( $response_body ) { + _deprecated_function( + __METHOD__, + '5.6.0', + 'This is no longer used by Core, and only kept for backwards-compatibility.' + ); + if ( isset( $response_body['events'] ) ) { foreach ( $response_body['events'] as $key => $event ) { $timestamp = strtotime( $event['date'] ); diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index b36ef18dce..971a60d77b 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1389,9 +1389,11 @@ function wp_print_community_events_templates() {
- {{ event.formatted_date }} + {{ event.user_formatted_date }} <# if ( 'meetup' === event.type ) { #> - {{ event.formatted_time }} + + {{ event.user_formatted_time }} {{ event.timeZoneAbbreviation }} + <# } #>
diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 9f616ef5e7..e889d41a62 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -1308,7 +1308,8 @@ function wp_default_scripts( $scripts ) { $scripts->add( 'wp-color-picker', "/wp-admin/js/color-picker$suffix.js", array( 'iris' ), false, 1 ); $scripts->set_translations( 'wp-color-picker' ); - $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-util', 'wp-a11y' ), false, 1 ); + $scripts->add( 'dashboard', "/wp-admin/js/dashboard$suffix.js", array( 'jquery', 'admin-comments', 'postbox', 'wp-util', 'wp-a11y', 'wp-date' ), false, 1 ); + $scripts->set_translations( 'dashboard' ); $scripts->add( 'list-revisions', "/wp-includes/js/wp-list-revisions$suffix.js" ); @@ -1755,6 +1756,7 @@ function wp_localize_community_events() { array( 'nonce' => wp_create_nonce( 'community_events' ), 'cache' => $events_client->get_cached_events(), + 'time_format' => get_option( 'time_format' ), 'l10n' => array( 'enter_closest_city' => __( 'Enter your closest city to find nearby events.' ), diff --git a/tests/qunit/index.html b/tests/qunit/index.html index 3f8f1b8567..e06ddc4bcb 100644 --- a/tests/qunit/index.html +++ b/tests/qunit/index.html @@ -85,7 +85,12 @@ + + + + + @@ -142,6 +147,7 @@ + diff --git a/tests/qunit/wp-admin/js/dashboard.js b/tests/qunit/wp-admin/js/dashboard.js new file mode 100644 index 0000000000..035d6e929f --- /dev/null +++ b/tests/qunit/wp-admin/js/dashboard.js @@ -0,0 +1,219 @@ +/* global wp, sinon, JSON */ +var communityEventsData, dateI18n, pagenow; + +jQuery( document ).ready( function () { + var getFormattedDate = wp.communityEvents.getFormattedDate, + getTimeZone = wp.communityEvents.getTimeZone, + getTimeZoneAbbreviation = wp.communityEvents.getTimeZoneAbbreviation, + populateDynamicEventFields = wp.communityEvents.populateDynamicEventFields, + startDate = 1600185600 * 1000, // Tue Sep 15 9:00:00 AM PDT 2020 + HOUR_IN_MS = 60 * 60 * 1000, + DAY_IN_MS = HOUR_IN_MS * 24, + WEEK_IN_MS = DAY_IN_MS * 7; + + QUnit.module( 'dashboard', function( hooks ) { + hooks.beforeEach( function() { + this.oldDateI18n = dateI18n; + this.oldPagenow = pagenow; + + dateI18n = wp.date.dateI18n; + pagenow = 'dashboard'; + + communityEventsData = { + time_format: 'g:i a', + l10n: { + date_formats: { + single_day_event: 'l, M j, Y', + multiple_day_event: '%1$s %2$d–%3$d, %4$d', + multiple_month_event: '%1$s %2$d – %3$s %4$d, %5$d' + } + } + }; + } ); + + hooks.afterEach( function() { + dateI18n = this.oldDateI18n; + pagenow = this.oldPagenow; + } ); + + QUnit.module( 'communityEvents.populateDynamicEventFields', function() { + QUnit.test( 'dynamic fields should be added', function( assert ) { + var timeFormat = communityEventsData.time_format; + + var getFormattedDateStub = sinon.stub( wp.communityEvents, 'getFormattedDate' ), + getTimeZoneStub = sinon.stub( wp.communityEvents, 'getTimeZone' ), + getTimeZoneAbbreviationStub = sinon.stub( wp.communityEvents, 'getTimeZoneAbbreviation' ); + + getFormattedDateStub.returns( 'Tuesday, Sep 15, 2020' ); + getTimeZoneStub.returns( 'America/Chicago' ); + getTimeZoneAbbreviationStub.returns( 'CDT' ); + + var rawEvents = [ + { + start_unix_timestamp: 1600185600, + end_unix_timestamp: 1600189200 + }, + + { + start_unix_timestamp: 1602232400, + end_unix_timestamp: 1602236000 + } + ]; + + var expected = JSON.parse( JSON.stringify( rawEvents ) ); + expected[0].user_formatted_date = 'Tuesday, Sep 15, 2020'; + expected[0].user_formatted_time = '11:00 am'; + expected[0].timeZoneAbbreviation = 'CDT'; + + expected[1].user_formatted_date = 'Tuesday, Sep 15, 2020'; // This is expected to be the same as item 0, because of the stub. + expected[1].user_formatted_time = '3:33 am'; + expected[1].timeZoneAbbreviation = 'CDT'; + + var actual = populateDynamicEventFields( rawEvents, timeFormat ); + + assert.strictEqual( + JSON.stringify( actual ), + JSON.stringify( expected ) + ); + + getFormattedDateStub.restore(); + getTimeZoneStub.restore(); + getTimeZoneAbbreviationStub.restore(); + } ); + } ); + + + QUnit.module( 'communityEvents.getFormattedDate', function() { + QUnit.test( 'single month event should use corresponding format', function( assert ) { + var actual = getFormattedDate( + startDate, + startDate + HOUR_IN_MS, + 'America/Vancouver', + communityEventsData.l10n.date_formats + ); + + assert.strictEqual( actual, 'Tuesday, Sep 15, 2020' ); + } ); + + QUnit.test( 'multiple day event should use corresponding format', function( assert ) { + var actual = getFormattedDate( + startDate, + startDate + ( 2 * DAY_IN_MS ), + 'America/Vancouver', + communityEventsData.l10n.date_formats + ); + + assert.strictEqual( actual, 'September 15–17, 2020' ); + } ); + + QUnit.test( 'multiple month event should use corresponding format', function( assert ) { + var actual = getFormattedDate( + startDate, + startDate + ( 3 * WEEK_IN_MS ), + 'America/Vancouver', + communityEventsData.l10n.date_formats + ); + + assert.strictEqual( actual, 'September 15 – October 6, 2020' ); + } ); + + QUnit.test( 'undefined end date should be treated as a single-day event', function( assert ) { + var actual = getFormattedDate( + startDate, + undefined, + 'America/Vancouver', + communityEventsData.l10n.date_formats + ); + + assert.strictEqual( actual, 'Tuesday, Sep 15, 2020' ); + } ); + + QUnit.test( 'empty end date should be treated as a single-day event', function( assert ) { + var actual = getFormattedDate( + startDate, + '', + 'America/Vancouver', + communityEventsData.l10n.date_formats + ); + + assert.strictEqual( actual, 'Tuesday, Sep 15, 2020' ); + } ); + } ); + + + QUnit.module( 'communityEvents.getTimeZone', function() { + QUnit.test( 'modern browsers should return a time zone name', function( assert ) { + // Simulate a modern browser. + var stub = sinon.stub( Intl.DateTimeFormat.prototype, 'resolvedOptions' ); + stub.returns( { timeZone: 'America/Chicago' } ); + + var actual = getTimeZone( startDate ); + + stub.restore(); + + assert.strictEqual( actual, 'America/Chicago' ); + } ); + + QUnit.test( 'older browsers should fallback to a raw UTC offset', function( assert ) { + // Simulate IE11. + var resolvedOptionsStub = sinon.stub( Intl.DateTimeFormat.prototype, 'resolvedOptions' ); + var getTimezoneOffsetStub = sinon.stub( Date.prototype, 'getTimezoneOffset' ); + + resolvedOptionsStub.returns( { timeZone: undefined } ); + + getTimezoneOffsetStub.returns( 300 ); + var actual = getTimeZone( startDate ); + assert.strictEqual( actual, -300, 'negative offset' ); // Intentionally opposite, see `getTimeZone()`. + + getTimezoneOffsetStub.returns( 0 ); + actual = getTimeZone( startDate ); + assert.strictEqual( actual, 0, 'no offset' ); + + getTimezoneOffsetStub.returns( -300 ); + actual = getTimeZone( startDate ); + assert.strictEqual( actual, 300, 'positive offset' ); // Intentionally opposite, see `getTimeZone()`. + + resolvedOptionsStub.restore(); + getTimezoneOffsetStub.restore(); + } ); + } ); + + + QUnit.module( 'communityEvents.getTimeZoneAbbreviation', function() { + QUnit.test( 'modern browsers should return a time zone abbreviation', function( assert ) { + // Modern browsers append a short time zone code to the time string. + var stub = sinon.stub( Date.prototype, 'toLocaleTimeString' ); + stub.returns( '4:00:00 PM CDT' ); + + var actual = getTimeZoneAbbreviation( startDate ); + + stub.restore(); + + assert.strictEqual( actual, 'CDT' ); + } ); + + QUnit.test( 'older browsers should fallback to a formatted UTC offset', function( assert ) { + var toLocaleTimeStringStub = sinon.stub( Date.prototype, 'toLocaleTimeString' ); + var getTimezoneOffsetStub = sinon.stub( Date.prototype, 'getTimezoneOffset' ); + + // IE 11 doesn't add the abbreviation like modern browsers do. + toLocaleTimeStringStub.returns( '4:00:00 PM' ); + + getTimezoneOffsetStub.returns( 300 ); + var actual = getTimeZoneAbbreviation( startDate ); + assert.strictEqual( actual, 'GMT-5', 'negative offset' ); // Intentionally opposite, see `getTimeZone()`. + + getTimezoneOffsetStub.returns( 0 ); + actual = getTimeZoneAbbreviation( startDate ); + assert.strictEqual( actual, 'GMT+0', 'no offset' ); + + getTimezoneOffsetStub.returns( -300 ); + actual = getTimeZoneAbbreviation( startDate ); + assert.strictEqual( actual, 'GMT+5', 'positive offset' ); // Intentionally opposite, see `getTimeZone()`. + + toLocaleTimeStringStub.restore(); + getTimezoneOffsetStub.restore(); + } ); + } ); + } ); +} );