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
This commit is contained in:
Ian Dunn 2020-10-14 18:19:43 +00:00
parent 92eba9720e
commit 92aa799e89
6 changed files with 443 additions and 5 deletions

View File

@ -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;
}
};

View File

@ -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'] );

View File

@ -1389,9 +1389,11 @@ function wp_print_community_events_templates() {
</div>
<div class="event-date-time">
<span class="event-date">{{ event.formatted_date }}</span>
<span class="event-date">{{ event.user_formatted_date }}</span>
<# if ( 'meetup' === event.type ) { #>
<span class="event-time">{{ event.formatted_time }}</span>
<span class="event-time">
{{ event.user_formatted_time }} {{ event.timeZoneAbbreviation }}
</span>
<# } #>
</div>
</li>

View File

@ -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.' ),

View File

@ -85,7 +85,12 @@
</div>
<!-- Tested files -->
<script src="../../build/wp-admin/js/dashboard.js"></script>
<script src="../../build/wp-admin/js/password-strength-meter.js"></script>
<script src="../../build/wp-admin/js/postbox.js"></script>
<script src="../../build/wp-includes/js/dist/vendor/moment.js"></script>
<script src="../../build/wp-includes/js/dist/date.js"></script>
<script src="../../build/wp-includes/js/dist/i18n.js"></script>
<script src="../../build/wp-includes/js/customize-base.js"></script>
<script src="../../build/wp-includes/js/customize-models.js"></script>
<script src="../../build/wp-includes/js/shortcode.js"></script>
@ -142,6 +147,7 @@
<script src="wp-admin/js/password-strength-meter.js"></script>
<script src="wp-admin/js/customize-base.js"></script>
<script src="wp-admin/js/customize-header.js"></script>
<script src="wp-admin/js/dashboard.js"></script>
<script src="wp-includes/js/shortcode.js"></script>
<script src="wp-includes/js/api-request.js"></script>
<script src="wp-includes/js/wp-api.js"></script>

View File

@ -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 1517, 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();
} );
} );
} );
} );