Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.js b/static_src/elements/issue-list/mr-chart/chops-chart.js
new file mode 100644
index 0000000..a74255a
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.js
@@ -0,0 +1,94 @@
+// 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, css} from 'lit-element';
+
+/**
+ * `<chops-chart>`
+ *
+ * Web components wrapper around Chart.js.
+ *
+ */
+export class ChopsChart extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <canvas></canvas>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      type: {type: String},
+      data: {type: Object},
+      options: {type: Object},
+      _chart: {type: Object},
+      _chartConstructor: {type: Object},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.type = 'line';
+    this.data = {};
+    this.options = {};
+  }
+
+  /**
+   * Dynamically chartJs to reduce single EZT bundle size
+   * Move to static import once EZT is deprecated
+   */
+  async connectedCallback() {
+    super.connectedCallback();
+    /* eslint-disable max-len */
+    const {default: Chart} = await import(
+        /* webpackChunkName: "chartjs" */ 'chart.js/dist/Chart.bundle.min.js');
+    this._chartConstructor = Chart;
+  }
+
+  /**
+   * Refetch and rerender chart after property changes
+   * @override
+   * @param {Map} changedProperties
+   */
+  updated(changedProperties) {
+    // Make sure chartJS has loaded before attempting to create a chart
+    if (this._chartConstructor) {
+      if (!this._chart) {
+        const {type, data, options} = this;
+        const ctx = this.shadowRoot.querySelector('canvas').getContext('2d');
+        this._chart = new this._chartConstructor(ctx, {type, data, options});
+      } else if (
+        changedProperties.has('type') ||
+        changedProperties.has('data') ||
+        changedProperties.has('options')) {
+        this._updateChart();
+      }
+    }
+  }
+
+  /**
+   * Sets chartJs options and calls update
+   */
+  _updateChart() {
+    this._chart.type = this.type;
+    this._chart.data = this.data;
+    this._chart.options = this.options;
+
+    this._chart.update();
+  }
+}
+
+customElements.define('chops-chart', ChopsChart);
diff --git a/static_src/elements/issue-list/mr-chart/chops-chart.test.js b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
new file mode 100644
index 0000000..bf05012
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/chops-chart.test.js
@@ -0,0 +1,24 @@
+// 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 {ChopsChart} from './chops-chart.js';
+
+
+let element;
+
+describe('chops-chart', () => {
+  beforeEach(() => {
+    element = document.createElement('chops-chart');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, ChopsChart);
+  });
+});
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.js b/static_src/elements/issue-list/mr-chart/mr-chart.js
new file mode 100644
index 0000000..a4c4189
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.js
@@ -0,0 +1,1041 @@
+// 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, css} from 'lit-element';
+import qs from 'qs';
+import page from 'page';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {linearRegression} from 'shared/math.js';
+import './chops-chart.js';
+import {urlWithNewParams, createObjectComparisonFunc} from 'shared/helpers.js';
+
+const DEFAULT_NUM_DAYS = 90;
+const SECONDS_IN_DAY = 24 * 60 * 60;
+const MAX_QUERY_SIZE = 90;
+const MAX_DISPLAY_LINES = 10;
+const predRangeType = Object.freeze({
+  NEXT_MONTH: 0,
+  NEXT_QUARTER: 1,
+  NEXT_50: 2,
+  HIDE: 3,
+});
+const CHART_OPTIONS = {
+  animation: false,
+  responsive: true,
+  title: {
+    display: true,
+    text: 'Issues over time',
+  },
+  tooltips: {
+    mode: 'x',
+    intersect: false,
+  },
+  hover: {
+    mode: 'x',
+    intersect: false,
+  },
+  legend: {
+    display: true,
+    labels: {
+      boxWidth: 15,
+    },
+  },
+  scales: {
+    xAxes: [{
+      display: true,
+      type: 'time',
+      time: {parser: 'MM/DD/YYYY', tooltipFormat: 'll'},
+      scaleLabel: {
+        display: true,
+        labelString: 'Day',
+      },
+    }],
+    yAxes: [{
+      display: true,
+      ticks: {
+        beginAtZero: true,
+      },
+      scaleLabel: {
+        display: true,
+        labelString: 'Value',
+      },
+    }],
+  },
+};
+const COLOR_CHOICES = ['#00838F', '#B71C1C', '#2E7D32', '#00659C',
+  '#5D4037', '#558B2F', '#FF6F00', '#6A1B9A', '#880E4F', '#827717'];
+const BG_COLOR_CHOICES = ['#B2EBF2', '#EF9A9A', '#C8E6C9', '#B2DFDB',
+  '#D7CCC8', '#DCEDC8', '#FFECB3', '#E1BEE7', '#F8BBD0', '#E6EE9C'];
+
+/**
+ * Set of serialized state this element should update for.
+ * mr-app lowercases all query parameters before putting into store.
+ * @type {Set<string>}
+ */
+export const subscribedQuery = new Set([
+  'start-date',
+  'end-date',
+  'groupby',
+  'labelprefix',
+  'q',
+  'can',
+]);
+
+const queryParamsHaveChanged = createObjectComparisonFunc(subscribedQuery);
+
+/**
+ * Mapping between query param's groupby value and chart application data.
+ * @type {Object}
+ */
+const groupByMapping = {
+  'open': {display: 'Is open', value: 'open'},
+  'owner': {display: 'Owner', value: 'owner'},
+  'comonent': {display: 'Component', value: 'component'},
+  'status': {display: 'Status', value: 'status'},
+};
+
+/**
+ * `<mr-chart>`
+ *
+ * Component rendering the chart view
+ *
+ */
+export default class MrChart extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        display: block;
+        max-width: 800px;
+        margin: 0 auto;
+      }
+      chops-chart {
+        max-width: 100%;
+      }
+      div#options {
+        max-width: 720px;
+        margin: 2em auto;
+        text-align: center;
+      }
+      div#options #unsupported-fields {
+        font-weight: bold;
+        color: orange;
+      }
+      div.align {
+        display: flex;
+      }
+      div.align #frequency, div.align #groupBy {
+        display: inline-block;
+        width: 40%;
+      }
+      div.align #frequency #two-toggle {
+        font-size: 95%;
+        text-align: center;
+        margin-bottom: 5px;
+      }
+      div.align #time, div.align #prediction {
+        display: inline-block;
+        width: 60%;
+      }
+      #dropdown {
+        height: 50%;
+      }
+      div.section {
+        display: inline-block;
+        text-align: center;
+      }
+      div.section.input {
+        padding: 4px 10px;
+      }
+      .menu {
+        min-width: 50%;
+        text-align: left;
+        font-size: 12px;
+        box-sizing: border-box;
+        text-decoration: none;
+        white-space: nowrap;
+        padding: 0.25em 8px;
+        transition: 0.2s background ease-in-out;
+        cursor: pointer;
+        color: var(--chops-link-color);
+      }
+      .menu:hover {
+        background: hsl(0, 0%, 90%);
+      }
+      .choice.transparent {
+        background: var(--chops-white);
+        border-color: var(--chops-choice-color);
+        border-radius: 4px;
+      }
+      .choice.shown {
+        background: var(--chops-active-choice-bg);
+      }
+      .choice {
+        padding: 4px 10px;
+        background: var(--chops-choice-bg);
+        color: var(--chops-choice-color);
+        text-decoration: none;
+        display: inline-block;
+      }
+      .choice.checked {
+        background: var(--chops-active-choice-bg);
+      }
+      p .warning-message {
+        display: none;
+        font-size: 1.25em;
+        padding: 0.25em;
+        background-color: var(--chops-orange-50);
+      }
+      progress {
+        background-color: var(--chops-white);
+        border: 1px solid var(--chops-gray-500);
+        margin: 0 0 1em;
+        width: 100%;
+        visibility: visible;
+      }
+      ::-webkit-progress-bar {
+        background-color: var(--chops-white);
+      }
+      progress::-webkit-progress-value {
+        transition: width 1s;
+        background-color: #00838F;
+      }
+    `;
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (changedProperties.has('queryParams')) {
+      this._setPropsFromQueryParams();
+      this._fetchData();
+    }
+  }
+
+  /** @override */
+  render() {
+    const doneLoading = this.progress === 1;
+    return html`
+      <chops-chart
+        type="line"
+        .options=${CHART_OPTIONS}
+        .data=${this._chartData(this.indices, this.values)}
+      ></chops-chart>
+      <div id="options">
+        <p id="unsupported-fields">
+          ${this.unsupportedFields.length ? `
+            Unsupported fields: ${this.unsupportedFields.join(', ')}`: ''}
+        </p>
+        <progress
+          value=${this.progress}
+          ?hidden=${doneLoading}
+        >Loading chart...</progress>
+        <p class="warning-message" ?hidden=${!this.searchLimitReached}>
+          Note: Some results are not being counted.
+          Please narrow your query.
+        </p>
+        <p class="warning-message" ?hidden=${!this.maxQuerySizeReached}>
+          Your query is too long.
+          Showing ${MAX_QUERY_SIZE} weeks from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.dateRangeNotLegal}>
+          Your requested date range does not exist.
+          Showing ${MAX_QUERY_SIZE} days from end date.
+        </p>
+        <p class="warning-message" ?hidden=${!this.cannedQueryOpen}>
+          Your query scope prevents closed issues from showing.
+        </p>
+        <div class="align">
+          <div id="frequency">
+            <label for="two-toggle">Choose date range:</label>
+            <div id="two-toggle">
+              <chops-button @click="${this._setDateRange.bind(this, 180)}"
+                class="${this.dateRange === 180 ? 'choice checked': 'choice'}">
+                180 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 90)}"
+                class="${this.dateRange === 90 ? 'choice checked': 'choice'}">
+                90 Days
+              </chops-button>
+              <chops-button @click="${this._setDateRange.bind(this, 30)}"
+                class="${this.dateRange === 30 ? 'choice checked': 'choice'}">
+                30 Days
+              </chops-button>
+            </div>
+          </div>
+          <div id="time">
+            <label for="start-date">Choose start and end date:</label>
+            <br />
+            <input
+              type="date"
+              id="start-date"
+              name="start-date"
+              .value=${this.startDate && this.startDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.startDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <input
+              type="date"
+              id="end-date"
+              name="end-date"
+              .value=${this.endDate && this.endDate.toISOString().substr(0, 10)}
+              ?disabled=${!doneLoading}
+              @change=${(e) => this.endDate = MrChart.dateStringToDate(e.target.value)}
+            />
+            <chops-button @click="${this._onDateChanged}" class=choice>
+              Apply
+            </chops-button>
+          </div>
+        </div>
+        <div class="align">
+          <div id="prediction">
+          <label for="two-toggle">Choose prediction range:</label>
+          <div id="two-toggle">
+            ${this._renderPredictChoice('Future Month', predRangeType.NEXT_MONTH)}
+            ${this._renderPredictChoice('Future Quarter', predRangeType.NEXT_QUARTER)}
+            ${this._renderPredictChoice('Future 50%', predRangeType.NEXT_50)}
+            ${this._renderPredictChoice('Hide', predRangeType.HIDE)}
+          </div>
+        </div>
+          <div id="groupBy">
+            <label for="dropdown">Choose group by:</label>
+            <mr-dropdown
+              id="dropdown"
+              ?disabled=${!doneLoading}
+              .text=${this.groupBy.display}
+            >
+              ${this.dropdownHTML}
+            </mr-dropdown>
+          </div>
+        </div>
+      </div>
+    `;
+  }
+
+  /**
+   * Renders a single prediction button.
+   * @param {string} choiceName The text displayed on the button.
+   * @param {number} rangeType An enum-like number specifying which range
+   *   to use.
+   * @return {TemplateResult}
+   */
+  _renderPredictChoice(choiceName, rangeType) {
+    const changePrediction = (_e) => {
+      this.predRange = rangeType;
+      this._fetchData();
+    };
+    return html`
+      <chops-button
+        @click=${changePrediction}
+        class="${this.predRange === rangeType ? 'checked': ''} choice">
+        ${choiceName}
+      </chops-button>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      progress: {type: Number},
+      projectName: {type: String},
+      hotlistId: {type: Number},
+      indices: {type: Array},
+      values: {type: Array},
+      unsupportedFields: {type: Array},
+      dateRangeNotLegal: {type: Boolean},
+      dateRange: {type: Number},
+      frequency: {type: Number},
+      queryParams: {
+        type: Object,
+        hasChanged: queryParamsHaveChanged,
+      },
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.progress = 0.05;
+    this.values = [];
+    this.indices = [];
+    this.unsupportedFields = [];
+    this.predRange = predRangeType.HIDE;
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    if (!this.projectName && !this.hotlistId) {
+      throw new Error('Attribute `projectName` or `hotlistId` required.');
+    }
+    this._setPropsFromQueryParams();
+    this._constructDropdownMenu();
+  }
+
+  /**
+   * Initialize queryParams and set properties from the queryParams.
+   * Since this page exists in both the SPA and ezt they initialize mr-chart
+   * differently, ie in ezt, this.queryParams will be undefined during
+   * connectedCallback. Until ezt is deleted, initialize props here.
+   */
+  _setPropsFromQueryParams() {
+    if (!this.queryParams) {
+      const params = qs.parse(document.location.search.substring(1));
+      // ezt pages used querystring as source of truth
+      // and 'labelPrefix'in query param, but SPA uses
+      // redux store's sitewide.queryParams as source of truth
+      // and lowercases all keys in sitewide.queryParams
+      if (params.hasOwnProperty('labelPrefix')) {
+        const labelPrefixValue = params['labelPrefix'];
+        params['labelprefix'] = labelPrefixValue;
+        delete params['labelPrefix'];
+      }
+      this.queryParams = params;
+    }
+    this.endDate = MrChart.getEndDate(this.queryParams['end-date']);
+    this.startDate = MrChart.getStartDate(
+        this.queryParams['start-date'],
+        this.endDate, DEFAULT_NUM_DAYS);
+    this.groupBy = MrChart.getGroupByFromQuery(this.queryParams);
+  }
+
+  /**
+   * Set dropdown options menu in HTML.
+   */
+  async _constructDropdownMenu() {
+    const response = await this._getLabelPrefixes();
+    let dropdownOptions = ['None', 'Component', 'Is open', 'Status', 'Owner'];
+    dropdownOptions = dropdownOptions.concat(response);
+    const dropdownHTML = dropdownOptions.map((str) => html`
+      <option class='menu' @click=${this._setGroupBy}>
+        ${str}</option>`);
+    this.dropdownHTML = html`${dropdownHTML}`;
+  }
+
+  /**
+   * Call global page.js to change frontend route based on new parameters
+   * @param {Object<string, string>} newParams
+   */
+  _changeUrlParams(newParams) {
+    const newUrl = urlWithNewParams(`/p/${this.projectName}/issues/list`,
+        this.queryParams, newParams);
+    this._page(newUrl);
+  }
+
+  /**
+   * Set start date and end date and trigger url action
+   */
+  _onDateChanged() {
+    const newParams = {
+      'start-date': this.startDate.toISOString().substr(0, 10),
+      'end-date': this.endDate.toISOString().substr(0, 10),
+    };
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Fetch data required to render chart
+   * @fires Event#allDataLoaded
+   */
+  async _fetchData() {
+    this.dateRange = Math.ceil(
+        (this.endDate - this.startDate) / (1000 * SECONDS_IN_DAY));
+
+    // Coordinate different params and flags, protect against illegal queries
+    // Case for start date greater than end date.
+    if (this.dateRange <= 0) {
+      this.frequency = 7;
+      this.dateRangeNotLegal = true;
+      this.maxQuerySizeReached = false;
+      this.dateRange = MAX_QUERY_SIZE;
+    } else {
+      this.dateRangeNotLegal = false;
+      if (this.dateRange >= MAX_QUERY_SIZE * 7) {
+        // Case for date range too long, requires >= MAX_QUERY_SIZE queries.
+        this.frequency = 7;
+        this.maxQuerySizeReached = true;
+        this.dateRange = MAX_QUERY_SIZE * 7;
+      } else {
+        this.maxQuerySizeReached = false;
+        if (this.dateRange < MAX_QUERY_SIZE) {
+          // Case for small date range, displayed in daily frequency.
+          this.frequency = 1;
+        } else {
+          // Case for medium date range, displayed in weekly frequency.
+          this.frequency = 7;
+        }
+      }
+    }
+    // Set canned query flag.
+    this.cannedQueryOpen = (this.queryParams.can === '2' &&
+      this.groupBy.value === 'open');
+
+    // Reset chart variables except indices.
+    this.progress = 0.05;
+
+    let numTimestampsLoaded = 0;
+    const timestampsChronological = MrChart.makeTimestamps(this.endDate,
+        this.frequency, this.dateRange);
+    const tsToIndexMap = new Map(timestampsChronological.map((ts, idx) => (
+      [ts, idx]
+    )));
+    this.indices = MrChart.makeIndices(timestampsChronological);
+    const timestamps = MrChart.sortInBisectOrder(timestampsChronological);
+    this.values = new Array(timestamps.length).fill(undefined);
+
+    const fetchPromises = timestamps.map(async (ts) => {
+      const data = await this._fetchDataAtTimestamp(ts);
+      const index = tsToIndexMap.get(ts);
+      this.values[index] = data.issues;
+      numTimestampsLoaded += 1;
+      const progressValue = numTimestampsLoaded / timestamps.length;
+      this.progress = progressValue;
+
+      return data;
+    });
+
+    const chartData = await Promise.all(fetchPromises);
+
+    // This is purely for testing purposes
+    this.dispatchEvent(new Event('allDataLoaded'));
+
+    // Check if the query includes any field values that are not supported.
+    const flatUnsupportedFields = chartData.reduce((acc, datum) => {
+      if (datum.unsupportedField) {
+        acc = acc.concat(datum.unsupportedField);
+      }
+      return acc;
+    }, []);
+    this.unsupportedFields = Array.from(new Set(flatUnsupportedFields));
+
+    this.searchLimitReached = chartData.some((d) => d.searchLimitReached);
+  }
+
+  /**
+   * fetch data at timestamp
+   * @param {number} timestamp
+   * @return {{date: number, issues: Array<Map.<string, number>>,
+   *   unsupportedField: string, searchLimitReached: string}}
+   */
+  async _fetchDataAtTimestamp(timestamp) {
+    const query = this.queryParams.q;
+    const cannedQuery = this.queryParams.can;
+    const message = {
+      timestamp: timestamp,
+      projectName: this.projectName,
+      query: query,
+      cannedQuery: cannedQuery,
+      hotlistId: this.hotlistId,
+      groupBy: undefined,
+    };
+    if (this.groupBy.value !== '') {
+      message['groupBy'] = this.groupBy.value;
+      if (this.groupBy.value === 'label') {
+        message['labelPrefix'] = this.groupBy.labelPrefix;
+      }
+    }
+    const response = await prpcClient.call('monorail.Issues',
+        'IssueSnapshot', message);
+
+    let issues;
+    if (response.snapshotCount) {
+      issues = response.snapshotCount.reduce((map, curr) => {
+        if (curr.dimension !== undefined) {
+          if (this.groupBy.value === '') {
+            map.set('Issue Count', curr.count);
+          } else {
+            map.set(curr.dimension, curr.count);
+          }
+        }
+        return map;
+      }, new Map());
+    } else {
+      issues = new Map();
+    }
+    return {
+      date: timestamp * 1000,
+      issues: issues,
+      unsupportedField: response.unsupportedField,
+      searchLimitReached: response.searchLimitReached,
+    };
+  }
+
+  /**
+   * Get prefixes from the set of labels.
+   */
+  async _getLabelPrefixes() {
+    // If no project (i.e. viewing a hotlist), return empty list.
+    if (!this.projectName) {
+      return [];
+    }
+
+    const projectRequestMessage = {
+      project_name: this.projectName};
+    const labelsResponse = await prpcClient.call(
+        'monorail.Projects', 'GetLabelOptions', projectRequestMessage);
+    const labelPrefixes = new Set();
+    for (let i = 0; i < labelsResponse.labelOptions.length; i++) {
+      const label = labelsResponse.labelOptions[i].label;
+      if (label.includes('-')) {
+        labelPrefixes.add(label.split('-')[0]);
+      }
+    }
+    return Array.from(labelPrefixes);
+  }
+
+  /**
+   * construct chart data
+   * @param {Array} indices
+   * @param {Array} values
+   * @return {Object} chart data and options
+   */
+  _chartData(indices, values) {
+    // Generate a map of each data line {dimension:string, value:array}
+    const mapValues = new Map();
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        values[i].forEach((value, key, map) => mapValues.set(key, []));
+      }
+    }
+    // Count the number of 0 or undefined data points.
+    let count = 0;
+    for (let i = 0; i < values.length; i++) {
+      if (values[i] !== undefined) {
+        if (values[i].size === 0) {
+          count++;
+        }
+        // Set none-existing data points 0.
+        mapValues.forEach((value, key, map) => {
+          mapValues.set(key, value.concat([values[i].get(key) || 0]));
+        });
+      } else {
+        count++;
+      }
+    }
+    // Legend display set back to default.
+    CHART_OPTIONS.legend.display = true;
+    // Check if any positive valued data exist, if not, draw an array of zeros.
+    if (count === values.length) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: [{
+          label: this.groupBy.labelPrefix,
+          data: Array(indices.length).fill(0),
+          backgroundColor: COLOR_CHOICES[0],
+          borderColor: COLOR_CHOICES[0],
+          showLine: true,
+          fill: false,
+        }],
+      };
+    }
+    // Convert map to a dataset of lines.
+    let arrayValues = [];
+    mapValues.forEach((value, key, map) => {
+      arrayValues.push({
+        label: key,
+        data: value,
+        backgroundColor: COLOR_CHOICES[arrayValues.length %
+          COLOR_CHOICES.length],
+        borderColor: COLOR_CHOICES[arrayValues.length % COLOR_CHOICES.length],
+        showLine: true,
+        fill: false,
+      });
+    });
+    arrayValues = MrChart.getSortedLines(arrayValues, MAX_DISPLAY_LINES);
+    if (this.predRange === predRangeType.HIDE) {
+      return {
+        type: 'line',
+        labels: indices,
+        datasets: arrayValues,
+      };
+    }
+
+    let predictedValues = [];
+    let originalData;
+    let predictedData;
+    let maxData;
+    let minData;
+    let currColor;
+    let currBGColor;
+    // Check if displayed values > MAX_DISPLAY_LINES, hide legend.
+    if (arrayValues.length * 4 > MAX_DISPLAY_LINES) {
+      CHART_OPTIONS.legend.display = false;
+    } else {
+      CHART_OPTIONS.legend.display = true;
+    }
+    for (let i = 0; i < arrayValues.length; i++) {
+      [originalData, predictedData, maxData, minData] =
+        MrChart.getAllData(indices, arrayValues[i]['data'], this.dateRange,
+            this.predRange, this.frequency, this.endDate);
+      currColor = COLOR_CHOICES[i % COLOR_CHOICES.length];
+      currBGColor = BG_COLOR_CHOICES[i % COLOR_CHOICES.length];
+      predictedValues = predictedValues.concat([{
+        label: arrayValues[i]['label'],
+        backgroundColor: currColor,
+        borderColor: currColor,
+        data: originalData,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' prediction'),
+        backgroundColor: currColor,
+        borderColor: currColor,
+        borderDash: [5, 5],
+        data: predictedData,
+        pointRadius: 0,
+        showLine: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' lower error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: minData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: false,
+      }, {
+        label: arrayValues[i]['label'].concat(' upper error'),
+        backgroundColor: currBGColor,
+        borderColor: currBGColor,
+        borderDash: [5, 5],
+        data: maxData,
+        pointRadius: 0,
+        showLine: true,
+        hidden: true,
+        fill: '-1',
+      }]);
+    }
+    return {
+      type: 'scatter',
+      datasets: predictedValues,
+    };
+  }
+
+  /**
+   * Change group by based on dropdown menu selection.
+   * @param {Event} e
+   */
+  _setGroupBy(e) {
+    switch (e.target.text) {
+      case 'None':
+        this.groupBy = {value: undefined};
+        break;
+      case 'Is open':
+        this.groupBy = {value: 'open'};
+        break;
+      case 'Owner':
+      case 'Component':
+      case 'Status':
+        this.groupBy = {value: e.target.text.toLowerCase()};
+        break;
+      default:
+        this.groupBy = {value: 'label', labelPrefix: e.target.text};
+    }
+    this.groupBy['display'] = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').text = e.target.text;
+    this.shadowRoot.querySelector('#dropdown').close();
+
+    const newParams = {
+      'groupby': this.groupBy.value,
+      'labelprefix': this.groupBy.labelPrefix,
+    };
+
+    this._changeUrlParams(newParams);
+  }
+
+  /**
+   * Change date range and frequency based on button clicked.
+   * @param {number} dateRange Number of days in date range
+   */
+  _setDateRange(dateRange) {
+    if (this.dateRange !== dateRange) {
+      this.startDate = new Date(
+          this.endDate.getTime() - 1000 * SECONDS_IN_DAY * dateRange);
+      this._onDateChanged();
+      window.getTSMonClient().recordDateRangeChange(dateRange);
+    }
+  }
+
+  /**
+   * Move first, last, and median to the beginning of the array, recursively.
+   * @param  {Array} timestamps
+   * @return {Array}
+   */
+  static sortInBisectOrder(timestamps) {
+    const arr = [];
+    if (timestamps.length === 0) {
+      return arr;
+    } else if (timestamps.length <= 2) {
+      return timestamps;
+    } else {
+      const beginTs = timestamps.shift();
+      const endTs = timestamps.pop();
+      const medianTs = timestamps.splice(timestamps.length / 2, 1)[0];
+      return [beginTs, endTs, medianTs].concat(
+          MrChart.sortInBisectOrder(timestamps));
+    }
+  }
+
+  /**
+   * Populate array of timestamps we want to fetch.
+   * @param {Date} endDate
+   * @param {number} frequency
+   * @param {number} numDays
+   * @return {Array}
+   */
+  static makeTimestamps(endDate, frequency, numDays=DEFAULT_NUM_DAYS) {
+    if (!endDate) {
+      throw new Error('endDate required');
+    }
+    const endTimeSeconds = Math.round(endDate.getTime() / 1000);
+    const timestampsChronological = [];
+    for (let i = 0; i < numDays; i += frequency) {
+      timestampsChronological.unshift(endTimeSeconds - (SECONDS_IN_DAY * i));
+    }
+    return timestampsChronological;
+  }
+
+  /**
+   * Convert a string '2018-11-03' to a Date object.
+   * @param  {string} dateString
+   * @return {Date}
+   */
+  static dateStringToDate(dateString) {
+    if (!dateString) {
+      return null;
+    }
+    const splitDate = dateString.split('-');
+    const year = Number.parseInt(splitDate[0]);
+    // Month is 0-indexed, so subtract one.
+    const month = Number.parseInt(splitDate[1]) - 1;
+    const day = Number.parseInt(splitDate[2]);
+    return new Date(Date.UTC(year, month, day, 23, 59, 59));
+  }
+
+  /**
+   * Returns a Date parsed from string input, defaults to current date.
+   * @param {string} input
+   * @return {Date}
+   */
+  static getEndDate(input) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    const today = new Date();
+    today.setHours(23);
+    today.setMinutes(59);
+    today.setSeconds(59);
+    return today;
+  }
+
+  /**
+   * Return a Date parsed from string input
+   * defaults to diff days befores endDate
+   * @param {string} input
+   * @param {Date} endDate
+   * @param {number} diff
+   * @return {Date}
+   */
+  static getStartDate(input, endDate, diff) {
+    if (input) {
+      const date = MrChart.dateStringToDate(input);
+      if (date) {
+        return date;
+      }
+    }
+    return new Date(endDate.getTime() - 1000 * SECONDS_IN_DAY * diff);
+  }
+
+  /**
+   * Make indices
+   * @param {Array} timestamps
+   * @return {Array}
+   */
+  static makeIndices(timestamps) {
+    const dateFormat = {year: 'numeric', month: 'numeric', day: 'numeric'};
+    return timestamps.map((ts) => (
+      (new Date(ts * 1000)).toLocaleDateString('en-US', dateFormat)
+    ));
+  }
+
+  /**
+   * Generate predicted future data based on previous data.
+   * @param {Array} values
+   * @param {number} dateRange
+   * @param {number} interval
+   * @param {number} frequency
+   * @param {Date} inputEndDate
+   * @return {Array}
+   */
+  static getPredictedData(
+      values, dateRange, interval, frequency, inputEndDate) {
+    // TODO(weihanl): changes to support frequencies other than 1 and 7.
+    let n;
+    let endDateRange;
+    if (frequency === 1) {
+      // Display in daily.
+      n = values.length;
+      endDateRange = interval;
+    } else {
+      // Display in weekly.
+      n = Math.floor((DEFAULT_NUM_DAYS + 1) / 7);
+      endDateRange = interval * 7 - 1;
+    }
+    const [slope, intercept] = linearRegression(values, n);
+    const endDate = new Date(inputEndDate.getTime() +
+        1000 * SECONDS_IN_DAY * (1 + endDateRange));
+    const timestampsChronological = MrChart.makeTimestamps(
+        endDate, frequency, endDateRange);
+    const predictedIndices = MrChart.makeIndices(timestampsChronological);
+
+    // Obtain future data and past data on the generated line.
+    const predictedValues = [];
+    const generatedValues = [];
+    for (let i = 0; i < interval; i++) {
+      predictedValues.push(Math.round(100*((i + n) * slope + intercept)) / 100);
+    }
+    for (let i = 0; i < n; i++) {
+      generatedValues.push(Math.round(100*(i * slope + intercept)) / 100);
+    }
+    return [predictedIndices, predictedValues, generatedValues];
+  }
+
+  /**
+   * Generate error range lines using +/- standard error
+   * on intercept to original line.
+   * @param {Array} generatedValues
+   * @param {Array} values
+   * @param {Array} predictedValues
+   * @return {Array}
+   */
+  static getErrorData(generatedValues, values, predictedValues) {
+    const diffs = [];
+    for (let i = 0; i < generatedValues.length; i++) {
+      diffs.push(values[values.length - generatedValues.length + i] -
+          generatedValues[i]);
+    }
+    const sqDiffs = diffs.map((v) => v * v);
+    const stdDev = sqDiffs.reduce((sum, v) => sum + v) / values.length;
+    const maxValues = predictedValues.map(
+        (x) => Math.round(100 * (x + stdDev)) / 100);
+    const minValues = predictedValues.map(
+        (x) => Math.round(100 * (x - stdDev)) / 100);
+    return [maxValues, minValues];
+  }
+
+  /**
+   * Format all data using scattered dot representation for a single chart line.
+   * @param {Array} indices
+   * @param {Array} values
+   * @param {humber} dateRange
+   * @param {number} predRange
+   * @param {number} frequency
+   * @param {Date} endDate
+   * @return {Array}
+   */
+  static getAllData(indices, values, dateRange, predRange, frequency, endDate) {
+    // Set the number of data points that needs to be generated based on
+    // future time range and frequency.
+    let interval;
+    switch (predRange) {
+      case predRangeType.NEXT_MONTH:
+        interval = frequency === 1 ? 30 : 4;
+        break;
+      case predRangeType.NEXT_QUARTER:
+        interval = frequency === 1 ? 90 : 13;
+        break;
+      case predRangeType.NEXT_50:
+        interval = Math.floor((dateRange + 1) / (frequency * 2));
+        break;
+    }
+
+    const [predictedIndices, predictedValues, generatedValues] =
+      MrChart.getPredictedData(values, dateRange, interval, frequency, endDate);
+    const [maxValues, minValues] =
+      MrChart.getErrorData(generatedValues, values, predictedValues);
+    const n = generatedValues.length;
+
+    // Format data into an array of {x:"MM/DD/YYYY", y:1.00} to draw chart.
+    const originalData = [];
+    const predictedData = [];
+    const maxData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    const minData = [{
+      x: indices[values.length - 1],
+      y: generatedValues[n - 1],
+    }];
+    for (let i = 0; i < values.length; i++) {
+      originalData.push({x: indices[i], y: values[i]});
+    }
+    for (let i = 0; i < n; i++) {
+      predictedData.push({x: indices[values.length - n + i],
+        y: Math.max(Math.round(100 * generatedValues[i]) / 100, 0)});
+    }
+    for (let i = 0; i < predictedValues.length; i++) {
+      predictedData.push({
+        x: predictedIndices[i],
+        y: Math.max(predictedValues[i], 0),
+      });
+      maxData.push({x: predictedIndices[i], y: Math.max(maxValues[i], 0)});
+      minData.push({x: predictedIndices[i], y: Math.max(minValues[i], 0)});
+    }
+    return [originalData, predictedData, maxData, minData];
+  }
+
+  /**
+   * Sort lines by data in reversed chronological order and
+   * return top n lines with most issues.
+   * @param {Array} arrayValues
+   * @param {number} index
+   * @return {Array}
+   */
+  static getSortedLines(arrayValues, index) {
+    if (index >= arrayValues.length) {
+      return arrayValues;
+    }
+    // Convert data by reversing and starting from last digit and sort
+    // according to the resulting value. e.g. [4,2,0] => 24, [0,4,3] => 340
+    const sortedValues = arrayValues.slice().sort((arrX, arrY) => {
+      const intX = parseInt(
+          arrX.data.map((i) => i.toString()).reverse().join(''));
+      const intY = parseInt(
+          arrY.data.map((i) => i.toString()).reverse().join(''));
+      return intY - intX;
+    });
+    return sortedValues.slice(0, index);
+  }
+
+  /**
+   * Parses queryParams for groupBy property
+   * @param {Object<string, string>} queryParams
+   * @return {Object<string, string>}
+   */
+  static getGroupByFromQuery(queryParams) {
+    const defaultValue = {display: 'None', value: ''};
+
+    const labelMapping = {
+      'label': {
+        display: queryParams.labelprefix,
+        value: 'label',
+        labelPrefix: queryParams.labelprefix,
+      },
+    };
+
+    return groupByMapping[queryParams.groupby] ||
+        labelMapping[queryParams.groupby] ||
+        defaultValue;
+  }
+}
+
+customElements.define('mr-chart', MrChart);
diff --git a/static_src/elements/issue-list/mr-chart/mr-chart.test.js b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
new file mode 100644
index 0000000..8c079fd
--- /dev/null
+++ b/static_src/elements/issue-list/mr-chart/mr-chart.test.js
@@ -0,0 +1,524 @@
+import {assert} from 'chai';
+import sinon from 'sinon';
+
+import MrChart, {
+  subscribedQuery,
+} from 'elements/issue-list/mr-chart/mr-chart.js';
+import {prpcClient} from 'prpc-client-instance.js';
+
+let element;
+let dataLoadedPromise;
+
+const beforeEachElement = () => {
+  if (element && document.body.contains(element)) {
+    // Avoid setting up multiple versions of the same element.
+    document.body.removeChild(element);
+    element = null;
+  }
+  const el = document.createElement('mr-chart');
+  el.setAttribute('projectName', 'rutabaga');
+  dataLoadedPromise = new Promise((resolve) => {
+    el.addEventListener('allDataLoaded', resolve);
+  });
+
+  document.body.appendChild(el);
+  return el;
+};
+
+describe('mr-chart', () => {
+  beforeEach(() => {
+    window.CS_env = {
+      token: 'rutabaga-token',
+      tokenExpiresSec: 0,
+      app_version: 'rutabaga-version',
+    };
+    sinon.stub(prpcClient, 'call').callsFake(async () => {
+      return {
+        snapshotCount: [{count: 8}],
+        unsupportedField: [],
+        searchLimitReached: false,
+      };
+    });
+
+    element = beforeEachElement();
+  });
+
+  afterEach(async () => {
+    // _fetchData is always called when the element is connected, so we have to
+    // wait until all data has been loaded.
+    // Otherwise prpcClient.call will be restored and we will make actual XHR
+    // calls.
+    await dataLoadedPromise;
+
+    document.body.removeChild(element);
+
+    prpcClient.call.restore();
+  });
+
+  describe('initializes', () => {
+    it('renders', () => {
+      assert.instanceOf(element, MrChart);
+    });
+
+    it('sets this.projectname', () => {
+      assert.equal(element.projectName, 'rutabaga');
+    });
+  });
+
+  describe('data loading', () => {
+    beforeEach(() => {
+      // Stub MrChart.makeTimestamps to return 6, not 30 data points.
+      const originalMakeTimestamps = MrChart.makeTimestamps;
+      sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+        return originalMakeTimestamps(endDate, 1, 6);
+      });
+      sinon.stub(MrChart, 'getEndDate').callsFake(() => {
+        return new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+      });
+
+      // Re-instantiate element after stubs.
+      element = beforeEachElement();
+    });
+
+    afterEach(() => {
+      MrChart.makeTimestamps.restore();
+      MrChart.getEndDate.restore();
+    });
+
+    it('makes a series of XHR calls', async () => {
+      await dataLoadedPromise;
+      for (let i = 0; i < 6; i++) {
+        assert.deepEqual(element.values[i], new Map());
+      }
+    });
+
+    it('sets indices and correctly re-orders values', async () => {
+      await dataLoadedPromise;
+
+      const timestampMap = new Map([
+        [1540857599, 0], [1540943999, 1], [1541030399, 2], [1541116799, 3],
+        [1541203199, 4], [1541289599, 5],
+      ]);
+      sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+          async (ts) => ({issues: {'Issue Count': timestampMap.get(ts)}}));
+
+      element.endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+      await element._fetchData();
+
+      assert.deepEqual(element.indices, [
+        '10/29/2018', '10/30/2018', '10/31/2018',
+        '11/1/2018', '11/2/2018', '11/3/2018',
+      ]);
+      for (let i = 0; i < 6; i++) {
+        assert.deepEqual(element.values[i], {'Issue Count': i});
+      }
+      MrChart.prototype._fetchDataAtTimestamp.restore();
+    });
+
+    it('if issue count is null, defaults to 0', async () => {
+      prpcClient.call.restore();
+      sinon.stub(prpcClient, 'call').callsFake(async () => {
+        return {snapshotCount: [{}]};
+      });
+      MrChart.makeTimestamps.restore();
+      sinon.stub(MrChart, 'makeTimestamps').callsFake((endDate) => {
+        return [1234567, 2345678, 3456789];
+      });
+
+      await element._fetchData(new Date());
+      assert.deepEqual(element.values[0], new Map());
+    });
+
+    it('Retrieve data under groupby feature', async () => {
+      const data = new Map([['Type-1', 0], ['Type-2', 1]]);
+      sinon.stub(MrChart.prototype, '_fetchDataAtTimestamp').callsFake(
+          () => ({issues: data}));
+
+      element = beforeEachElement();
+
+      await element._fetchData(new Date());
+      for (let i = 0; i < 3; i++) {
+        assert.deepEqual(element.values[i], data);
+      }
+      MrChart.prototype._fetchDataAtTimestamp.restore();
+    });
+
+    it('_fetchDataAtTimestamp has no default query or can', async () => {
+      await element._fetchData();
+
+      sinon.assert.calledWith(
+          prpcClient.call,
+          'monorail.Issues',
+          'IssueSnapshot',
+          {
+            cannedQuery: undefined,
+            groupBy: undefined,
+            hotlistId: undefined,
+            query: undefined,
+            projectName: 'rutabaga',
+            timestamp: 1540857599,
+          });
+    });
+  });
+
+  describe('start date change detection', () => {
+    it('illegal query: start-date is greater than end-date', async () => {
+      await element.updateComplete;
+
+      element.startDate = new Date('2199-11-06');
+      element._fetchData();
+
+      assert.equal(element.dateRange, 90);
+      assert.equal(element.frequency, 7);
+      assert.equal(element.dateRangeNotLegal, true);
+    });
+
+    it('illegal query: end_date - start_date requires more than 90 queries',
+        async () => {
+          await element.updateComplete;
+
+          element.startDate = new Date('2016-10-03');
+          element._fetchData();
+
+          assert.equal(element.dateRange, 90 * 7);
+          assert.equal(element.frequency, 7);
+          assert.equal(element.maxQuerySizeReached, true);
+        });
+  });
+
+  describe('date change behavior', () => {
+    it('pushes to history API via pageJS', async () => {
+      sinon.stub(element, '_page');
+      sinon.spy(element, '_setDateRange');
+      sinon.spy(element, '_onDateChanged');
+      sinon.spy(element, '_changeUrlParams');
+
+      await element.updateComplete;
+
+      const thirtyButton = element.shadowRoot
+          .querySelector('#two-toggle').children[2];
+      thirtyButton.click();
+
+      sinon.assert.calledOnce(element._setDateRange);
+      sinon.assert.calledOnce(element._onDateChanged);
+      sinon.assert.calledOnce(element._changeUrlParams);
+      sinon.assert.calledOnce(element._page);
+
+      element._page.restore();
+      element._setDateRange.restore();
+      element._onDateChanged.restore();
+      element._changeUrlParams.restore();
+    });
+  });
+
+  describe('progress bar', () => {
+    it('visible based on loading progress', async () => {
+      // Check for visible progress bar and hidden input after initial render
+      await element.updateComplete;
+      const progressBar = element.shadowRoot.querySelector('progress');
+      const endDateInput = element.shadowRoot.querySelector('#end-date');
+      assert.isFalse(progressBar.hasAttribute('hidden'));
+      assert.isTrue(endDateInput.disabled);
+
+      // Check for hidden progress bar and enabled input after fetch and render
+      await dataLoadedPromise;
+      await element.updateComplete;
+      assert.isTrue(progressBar.hasAttribute('hidden'));
+      assert.isFalse(endDateInput.disabled);
+
+      // Trigger another data fetch and render, but prior to fetch complete
+      // Check progress bar is visible again
+      element.queryParams['start-date'] = '2012-01-01';
+      await element.requestUpdate('queryParams');
+      await element.updateComplete;
+      assert.isFalse(progressBar.hasAttribute('hidden'));
+
+      await dataLoadedPromise;
+      await element.updateComplete;
+      assert.isTrue(progressBar.hasAttribute('hidden'));
+    });
+  });
+
+  describe('static methods', () => {
+    describe('sortInBisectOrder', () => {
+      it('orders first, last, median recursively', () => {
+        assert.deepEqual(MrChart.sortInBisectOrder([]), []);
+        assert.deepEqual(MrChart.sortInBisectOrder([9]), [9]);
+        assert.deepEqual(MrChart.sortInBisectOrder([8, 9]), [8, 9]);
+        assert.deepEqual(MrChart.sortInBisectOrder([7, 8, 9]), [7, 9, 8]);
+        assert.deepEqual(
+            MrChart.sortInBisectOrder([1, 2, 3, 4, 5]), [1, 5, 3, 2, 4]);
+      });
+    });
+
+    describe('makeTimestamps', () => {
+      it('throws an error if endDate not passed', () => {
+        assert.throws(() => {
+          MrChart.makeTimestamps();
+        }, 'endDate required');
+      });
+      it('returns an array of in seconds', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 1, 6), [
+          1541289599 - (secondsInDay * 5), 1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 3), 1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 1), 1541289599 - (secondsInDay * 0),
+        ]);
+      });
+      it('tests frequency greater than 1', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 6), [
+          1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 0),
+        ]);
+      });
+      it('tests frequency greater than 1', () => {
+        const endDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        const secondsInDay = 24 * 60 * 60;
+
+        assert.deepEqual(MrChart.makeTimestamps(endDate, 2, 7), [
+          1541289599 - (secondsInDay * 6),
+          1541289599 - (secondsInDay * 4),
+          1541289599 - (secondsInDay * 2),
+          1541289599 - (secondsInDay * 0),
+        ]);
+      });
+    });
+
+    describe('dateStringToDate', () => {
+      it('returns null if no input', () => {
+        assert.isNull(MrChart.dateStringToDate());
+      });
+
+      it('returns a new Date at EOD UTC', () => {
+        const actualDate = MrChart.dateStringToDate('2018-11-03');
+        const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        assert.equal(expectedDate.getTime(), 1541289599000, 'Sanity check.');
+
+        assert.equal(actualDate.getTime(), expectedDate.getTime());
+      });
+    });
+
+    describe('getEndDate', () => {
+      let clock;
+
+      beforeEach(() => {
+        clock = sinon.useFakeTimers(10000);
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('returns parsed input date', () => {
+        const input = '2018-11-03';
+
+        const expectedDate = new Date(Date.UTC(2018, 10, 3, 23, 59, 59));
+        // Time sanity check.
+        assert.equal(Math.round(expectedDate.getTime() / 1e3), 1541289599);
+
+        const actual = MrChart.getEndDate(input);
+        assert.equal(actual.getTime(), expectedDate.getTime());
+      });
+
+      it('returns EOD of current date by default', () => {
+        const expectedDate = new Date();
+        expectedDate.setHours(23);
+        expectedDate.setMinutes(59);
+        expectedDate.setSeconds(59);
+
+        assert.equal(MrChart.getEndDate().getTime(),
+            expectedDate.getTime());
+      });
+    });
+
+    describe('getStartDate', () => {
+      let clock;
+
+      beforeEach(() => {
+        clock = sinon.useFakeTimers(10000);
+      });
+
+      afterEach(() => {
+        clock.restore();
+      });
+
+      it('returns parsed input date', () => {
+        const input = '2018-07-03';
+
+        const expectedDate = new Date(Date.UTC(2018, 6, 3, 23, 59, 59));
+        // Time sanity check.
+        assert.equal(Math.round(expectedDate.getTime() / 1e3), 1530662399);
+
+        const actual = MrChart.getStartDate(input);
+        assert.equal(actual.getTime(), expectedDate.getTime());
+      });
+
+      it('returns EOD of current date by default', () => {
+        const today = new Date();
+        today.setHours(23);
+        today.setMinutes(59);
+        today.setSeconds(59);
+
+        const secondsInDay = 24 * 60 * 60;
+        const expectedDate = new Date(today.getTime() -
+            1000 * 90 * secondsInDay);
+        assert.equal(MrChart.getStartDate(undefined, today, 90).getTime(),
+            expectedDate.getTime());
+      });
+    });
+
+    describe('makeIndices', () => {
+      it('returns dates in mm/dd/yyy format', () => {
+        const timestamps = [
+          1540857599, 1540943999, 1541030399,
+          1541116799, 1541203199, 1541289599,
+        ];
+        assert.deepEqual(MrChart.makeIndices(timestamps), [
+          '10/29/2018', '10/30/2018', '10/31/2018',
+          '11/1/2018', '11/2/2018', '11/3/2018',
+        ]);
+      });
+    });
+
+    describe('getPredictedData', () => {
+      it('get predicted data shown in daily', () => {
+        const values = [0, 1, 2, 3, 4, 5, 6];
+        const result = MrChart.getPredictedData(
+            values, values.length, 3, 1, new Date('10-02-2017'));
+        assert.deepEqual(result[0], ['10/4/2017', '10/5/2017', '10/6/2017']);
+        assert.deepEqual(result[1], [7, 8, 9]);
+        assert.deepEqual(result[2], [0, 1, 2, 3, 4, 5, 6]);
+      });
+
+      it('get predicted data shown in weekly', () => {
+        const values = [0, 7, 14, 21, 28, 35, 42, 49, 56, 63, 70, 77, 84];
+        const result = MrChart.getPredictedData(
+            values, 91, 13, 7, new Date('10-02-2017'));
+        assert.deepEqual(result[1], values.map((x) => x+91));
+        assert.deepEqual(result[2], values);
+      });
+    });
+
+    describe('getErrorData', () => {
+      it('get error data with perfect regression', () => {
+        const values = [0, 1, 2, 3, 4, 5, 6];
+        const result = MrChart.getErrorData(values, values, [7, 8, 9]);
+        assert.deepEqual(result[0], [7, 8, 9]);
+        assert.deepEqual(result[1], [7, 8, 9]);
+      });
+
+      it('get error data with nonperfect regression', () => {
+        const values = [0, 1, 3, 4, 6, 6, 7];
+        const result = MrChart.getPredictedData(
+            values, values.length, 3, 1, new Date('10-02-2017'));
+        const error = MrChart.getErrorData(result[2], values, result[1]);
+        assert.isTrue(error[0][0] > result[1][0]);
+        assert.isTrue(error[1][0] < result[1][0]);
+      });
+    });
+
+    describe('getSortedLines', () => {
+      it('return all lines for less than n lines', () => {
+        const arrayValues = [
+          {label: 'line1', data: [0, 0, 1]},
+          {label: 'line2', data: [0, 1, 2]},
+          {label: 'line3', data: [0, 1, 0]},
+          {label: 'line4', data: [4, 0, 3]},
+        ];
+        const expectedValues = [
+          {label: 'line1', data: [0, 0, 1]},
+          {label: 'line2', data: [0, 1, 2]},
+          {label: 'line3', data: [0, 1, 0]},
+          {label: 'line4', data: [4, 0, 3]},
+        ];
+        const actualValues = MrChart.getSortedLines(arrayValues, 4);
+        for (let i = 0; i < 4; i++) {
+          assert.deepEqual(expectedValues[i], actualValues[i]);
+        }
+      });
+
+      it('return top n lines in sorted order for more than n lines',
+          () => {
+            const arrayValues = [
+              {label: 'line1', data: [0, 0, 1]},
+              {label: 'line2', data: [0, 1, 2]},
+              {label: 'line3', data: [0, 4, 0]},
+              {label: 'line4', data: [4, 0, 3]},
+              {label: 'line5', data: [0, 2, 3]},
+            ];
+            const expectedValues = [
+              {label: 'line5', data: [0, 2, 3]},
+              {label: 'line4', data: [4, 0, 3]},
+              {label: 'line2', data: [0, 1, 2]},
+            ];
+            const actualValues = MrChart.getSortedLines(arrayValues, 3);
+            for (let i = 0; i < 3; i++) {
+              assert.deepEqual(expectedValues[i], actualValues[i]);
+            }
+          });
+    });
+
+    describe('getGroupByFromQuery', () => {
+      it('get group by label object from URL', () => {
+        const input = {'groupby': 'label', 'labelprefix': 'Type'};
+
+        const expectedGroupBy = {
+          value: 'label',
+          labelPrefix: 'Type',
+          display: 'Type',
+        };
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('get group by is open object from URL', () => {
+        const input = {'groupby': 'open'};
+
+        const expectedGroupBy = {value: 'open', display: 'Is open'};
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('get group by none object from URL', () => {
+        const input = {'groupby': ''};
+
+        const expectedGroupBy = {value: '', display: 'None'};
+        assert.deepEqual(MrChart.getGroupByFromQuery(input), expectedGroupBy);
+      });
+
+      it('only returns valid groupBy values', () => {
+        const invalidKeys = ['pri', 'reporter', 'stars'];
+
+        const queryParams = {groupBy: ''};
+
+        invalidKeys.forEach((key) => {
+          queryParams.groupBy = key;
+          const expected = {value: '', display: 'None'};
+          const result = MrChart.getGroupByFromQuery(queryParams);
+          assert.deepEqual(result, expected);
+        });
+      });
+    });
+  });
+
+  describe('subscribedQuery', () => {
+    it('includes start and end date', () => {
+      assert.isTrue(subscribedQuery.has('start-date'));
+      assert.isTrue(subscribedQuery.has('start-date'));
+    });
+
+    it('includes groupby and labelprefix', () => {
+      assert.isTrue(subscribedQuery.has('groupby'));
+      assert.isTrue(subscribedQuery.has('labelprefix'));
+    });
+
+    it('includes q and can', () => {
+      assert.isTrue(subscribedQuery.has('q'));
+      assert.isTrue(subscribedQuery.has('can'));
+    });
+  });
+});