Project import generated by Copybara.
GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
new file mode 100644
index 0000000..2fa1dc2
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.js
@@ -0,0 +1,109 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+const DEFAULT_DATE_LOCALE = 'en-US';
+
+// Creating the datetime formatter costs ~1.5 ms, so when formatting
+// multiple timestamps, it's more performant to reuse the formatter object.
+// Export FORMATTER and SHORT_FORMATTER for testing. The return value differs
+// based on time zone and browser, so we can't use static strings for testing.
+// We can't stub out the method because it's native code and can't be modified.
+// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat/format#Avoid_comparing_formatted_date_values_to_static_values
+export const FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+ weekday: 'short',
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit',
+ timeZoneName: 'short',
+});
+
+export const SHORT_FORMATTER = new Intl.DateTimeFormat(DEFAULT_DATE_LOCALE, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+});
+
+export const MS_PER_MINUTE = 60 * 1000;
+export const MS_PER_HOUR = MS_PER_MINUTE * 60;
+export const MS_PER_DAY = MS_PER_HOUR * 24;
+export const MS_PER_MONTH = MS_PER_DAY * 30;
+
+/**
+ * Helper to determine if a Date was less than a month ago.
+ * @param {Date} date The date to check.
+ * @return {boolean} Whether the date was less than a
+ * month ago.
+ */
+function isLessThanAMonthAgo(date) {
+ const now = new Date();
+ const msDiff = Math.abs(Math.floor((now.getTime() - date.getTime())));
+ return msDiff < MS_PER_MONTH;
+}
+
+/**
+ * Displays timestamp in a standardized format to be re-used.
+ * @param {Date} date
+ * @return {string}
+ */
+export function standardTime(date) {
+ if (!date) return;
+ const absoluteTime = FORMATTER.format(date);
+
+ let timeAgoBit = '';
+ if (isLessThanAMonthAgo(date)) {
+ // Only show relative time if the time is less than a
+ // month ago because otherwise, it's not as useful.
+ timeAgoBit = ` (${relativeTime(date)})`;
+ }
+ return `${absoluteTime}${timeAgoBit}`;
+}
+
+/**
+ * Displays a timestamp in a format that's easy for a human to immediately
+ * reason about, based on long ago the time was.
+ * @param {Date} date native JavaScript Data Object.
+ * @return {string} Human-readable string of the date.
+ */
+export function relativeTime(date) {
+ if (!date) return;
+
+ const now = new Date();
+ let msDiff = now.getTime() - date.getTime();
+
+ // Use different wording depending on whether the time is in the
+ // future or past.
+ const pastOrPresentSuffix = msDiff < 0 ? 'from now' : 'ago';
+ msDiff = Math.abs(msDiff);
+
+ if (msDiff < MS_PER_MINUTE) {
+ // Less than a minute.
+ return 'just now';
+ } else if (msDiff < MS_PER_HOUR) {
+ // Less than an hour.
+ const minutes = Math.floor(msDiff / MS_PER_MINUTE);
+ if (minutes === 1) {
+ return `a minute ${pastOrPresentSuffix}`;
+ }
+ return `${minutes} minutes ${pastOrPresentSuffix}`;
+ } else if (msDiff < MS_PER_DAY) {
+ // Less than an day.
+ const hours = Math.floor(msDiff / MS_PER_HOUR);
+ if (hours === 1) {
+ return `an hour ${pastOrPresentSuffix}`;
+ }
+ return `${hours} hours ${pastOrPresentSuffix}`;
+ } else if (msDiff < MS_PER_MONTH) {
+ // Less than a month.
+ const days = Math.floor(msDiff / MS_PER_DAY);
+ if (days === 1) {
+ return `a day ${pastOrPresentSuffix}`;
+ }
+ return `${days} days ${pastOrPresentSuffix}`;
+ }
+
+ // A month or more ago. Better to show an exact date at this point.
+ return SHORT_FORMATTER.format(date);
+}
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
new file mode 100644
index 0000000..5fe344b
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp-helpers.test.js
@@ -0,0 +1,112 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert} from 'chai';
+import {FORMATTER, MS_PER_MONTH, standardTime,
+ relativeTime} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let clock;
+
+describe('chops-timestamp-helpers', () => {
+ beforeEach(() => {
+ // Set clock to the Epoch.
+ clock = sinon.useFakeTimers({
+ now: new Date(0),
+ shouldAdvanceTime: false,
+ });
+ });
+
+ afterEach(() => {
+ clock.restore();
+ });
+
+ describe('standardTime', () => {
+ it('shows relative timestamp when less than a month ago', () => {
+ const date = new Date();
+ assert.equal(standardTime(date), `${FORMATTER.format(date)} (just now)`);
+ });
+
+ it('no relative time when more than a month in the future', () => {
+ const date = new Date(1548808276 * 1000);
+ assert.equal(standardTime(date), 'Tue, Jan 29, 2019, 4:31 PM PST');
+ });
+
+ it('no relative time when more than a month in the past', () => {
+ // Jan 29, 2019, 4:31 PM PST
+ const now = 1548808276 * 1000;
+ clock.tick(now);
+
+ const date = new Date(now - MS_PER_MONTH);
+ assert.equal(standardTime(date), 'Sun, Dec 30, 2018, 4:31 PM PST');
+ });
+ });
+
+ it('relativeTime future', () => {
+ assert.equal(relativeTime(new Date()), `just now`);
+
+ assert.equal(relativeTime(new Date(59 * 1000)), `just now`);
+
+ assert.equal(relativeTime(new Date(60 * 1000)), `a minute from now`);
+ assert.equal(relativeTime(new Date(2 * 60 * 1000)),
+ `2 minutes from now`);
+ assert.equal(relativeTime(new Date(59 * 60 * 1000)),
+ `59 minutes from now`);
+
+ assert.equal(relativeTime(new Date(60 * 60 * 1000)), `an hour from now`);
+ assert.equal(relativeTime(new Date(2 * 60 * 60 * 1000)),
+ `2 hours from now`);
+ assert.equal(relativeTime(new Date(23 * 60 * 60 * 1000)),
+ `23 hours from now`);
+
+ assert.equal(relativeTime(new Date(24 * 60 * 60 * 1000)),
+ `a day from now`);
+ assert.equal(relativeTime(new Date(2 * 24 * 60 * 60 * 1000)),
+ `2 days from now`);
+ assert.equal(relativeTime(new Date(29 * 24 * 60 * 60 * 1000)),
+ `29 days from now`);
+
+ assert.equal(relativeTime(new Date(30 * 24 * 60 * 60 * 1000)),
+ 'Jan 30, 1970');
+ });
+
+ it('relativeTime past', () => {
+ const baseTime = 234234 * 1000;
+
+ clock.tick(baseTime);
+
+ assert.equal(relativeTime(new Date()), `just now`);
+
+ assert.equal(relativeTime(new Date(baseTime - 59 * 1000)),
+ `just now`);
+
+ assert.equal(relativeTime(new Date(baseTime - 60 * 1000)),
+ `a minute ago`);
+ assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 1000)),
+ `2 minutes ago`);
+ assert.equal(relativeTime(new Date(baseTime - 59 * 60 * 1000)),
+ `59 minutes ago`);
+
+ assert.equal(relativeTime(new Date(baseTime - 60 * 60 * 1000)),
+ `an hour ago`);
+ assert.equal(relativeTime(new Date(baseTime - 2 * 60 * 60 * 1000)),
+ `2 hours ago`);
+ assert.equal(relativeTime(new Date(baseTime - 23 * 60 * 60 * 1000)),
+ `23 hours ago`);
+
+ assert.equal(relativeTime(new Date(
+ baseTime - 24 * 60 * 60 * 1000)), `a day ago`);
+ assert.equal(relativeTime(new Date(
+ baseTime - 2 * 24 * 60 * 60 * 1000)), `2 days ago`);
+ assert.equal(relativeTime(new Date(
+ baseTime - 29 * 24 * 60 * 60 * 1000)), `29 days ago`);
+
+ assert.equal(relativeTime(new Date(
+ baseTime - 30 * 24 * 60 * 60 * 1000)), 'Dec 4, 1969');
+ });
+});
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
new file mode 100644
index 0000000..b7f157f
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.js
@@ -0,0 +1,93 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html} from 'lit-element';
+
+import {standardTime, relativeTime} from './chops-timestamp-helpers.js';
+
+/**
+ * `<chops-timestamp>`
+ *
+ * This element shows a time in a human readable form.
+ *
+ * @customElement
+ */
+export class ChopsTimestamp extends LitElement {
+ /** @override */
+ render() {
+ return html`
+ ${this._displayedTime}
+ `;
+ }
+
+ /** @override */
+ static get properties() {
+ return {
+ /** The data for the time which can be in any format readable by
+ * Date.parse.
+ */
+ timestamp: {type: String},
+ /** When true, a shorter version of the date will be displayed. */
+ short: {type: Boolean},
+ /**
+ * The Date object, which is stored in UTC, to be converted to a string.
+ */
+ _date: {type: Object},
+ };
+ }
+
+ /**
+ * @return {string} Human-readable timestamp.
+ */
+ get _displayedTime() {
+ const date = this._date;
+ const short = this.short;
+ // TODO(zhangtiff): Add logic to dynamically re-compute relative time
+ // based on set intervals.
+ if (!date) return;
+ if (short) {
+ return relativeTime(date);
+ }
+ return standardTime(date);
+ }
+
+ /** @override */
+ update(changedProperties) {
+ if (changedProperties.has('timestamp')) {
+ this._date = this._parseTimestamp(this.timestamp);
+ this.setAttribute('title', standardTime(this._date));
+ }
+ super.update(changedProperties);
+ }
+
+ /**
+ * Turns a timestamp string into a native JavaScript Date Object.
+ * @param {string} timestamp Timestamp string in either an ISO format or
+ * Unix timestamp format. If Unix time, the function expects the time in
+ * seconds, not milliseconds.
+ * @return {Date}
+ */
+ _parseTimestamp(timestamp) {
+ if (!timestamp) return;
+
+ let unixTimeMs = 0;
+ // Make sure to do Date.parse before Number.parseInt because parseInt
+ // will parse numbers within a string.
+ if (/^\d+$/.test(timestamp)) {
+ // Check if a string contains only digits before guessing it's
+ // unix time. This is necessary because Number.parseInt will parse
+ // number strings that contain non-numbers.
+ unixTimeMs = Number.parseInt(timestamp) * 1000;
+ } else {
+ // Date.parse will parse strings with only numbers as though those
+ // strings were truncated ISO formatted strings.
+ unixTimeMs = Date.parse(timestamp);
+ if (Number.isNaN(unixTimeMs)) {
+ throw new Error('Timestamp is in an invalid format.');
+ }
+ }
+ return new Date(unixTimeMs);
+ }
+}
+customElements.define('chops-timestamp', ChopsTimestamp);
diff --git a/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
new file mode 100644
index 0000000..21c227d
--- /dev/null
+++ b/static_src/elements/chops/chops-timestamp/chops-timestamp.test.js
@@ -0,0 +1,88 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assert, expect} from 'chai';
+import {ChopsTimestamp} from './chops-timestamp.js';
+import {FORMATTER, SHORT_FORMATTER} from './chops-timestamp-helpers.js';
+import sinon from 'sinon';
+
+// The formatted date strings differ based on time zone and browser, so we can't
+// use static strings for testing. We can't stub out the format method because
+// it's native code and can't be modified. So just use the FORMATTER object.
+
+let element;
+let clock;
+
+describe('chops-timestamp', () => {
+ beforeEach(() => {
+ element = document.createElement('chops-timestamp');
+ document.body.appendChild(element);
+
+ // Set clock to the Epoch.
+ clock = sinon.useFakeTimers({
+ now: new Date(0),
+ shouldAdvanceTime: false,
+ });
+ });
+
+ afterEach(() => {
+ document.body.removeChild(element);
+ clock.restore();
+ });
+
+ it('initializes', () => {
+ assert.instanceOf(element, ChopsTimestamp);
+ });
+
+ it('changing timestamp changes date', async () => {
+ const timestamp = 1548808276;
+ element.timestamp = String(timestamp);
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ FORMATTER.format(new Date(timestamp * 1000)));
+ });
+
+ it('parses ISO dates', async () => {
+ const timestamp = '2016-11-11';
+ element.timestamp = timestamp;
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ FORMATTER.format(new Date(timestamp)));
+ });
+
+ it('invalid timestamp format', () => {
+ expect(() => {
+ element._parseTimestamp('random string');
+ }).to.throw('Timestamp is in an invalid format.');
+ });
+
+ it('short time renders shorter time', async () => {
+ element.short = true;
+ element.timestamp = '5';
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ `just now`);
+
+ element.timestamp = '60';
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ `a minute from now`);
+
+ const timestamp = 1548808276;
+ element.timestamp = String(timestamp);
+
+ await element.updateComplete;
+
+ assert.include(element.shadowRoot.textContent,
+ SHORT_FORMATTER.format(timestamp * 1000));
+ });
+});