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));
+  });
+});