ThinkDeepTech/thinkdeep

View on GitHub
packages/deep-economic-analyzer/deep-analyzer-page-summary.js

Summary

Maintainability
F
3 days
Test Coverage
import {
  ApolloMutationController,
  ApolloQueryController,
  ApolloSubscriptionController,
} from '@apollo-elements/core';
import {LitElement, css, html} from '@apollo-elements/lit-apollo';
import '@google-web-components/google-chart';
import '@material/mwc-button';
import '@material/mwc-icon';
import '@material/mwc-list/mwc-list-item';
import '@material/mwc-select';
import '@material/mwc-textfield';
import '@thinkdeep/deep-card';
import '@vaadin/date-picker';
import './deep-site-configuration.js';
import {CollectEconomicData} from './graphql/CollectEconomicData.mutation.graphql.js';
import {GetSentiment} from './graphql/GetSentiment.query.graphql.js';
import {UpdateSentiments} from './graphql/UpdateSentiments.subscription.graphql.js';
import moment from 'moment/dist/moment.js';
import {EconomicEntityFactory, EconomicEntityType} from '@thinkdeep/model';

const DEFAULT_START_DATE = moment()
  .utc()
  .subtract(1, 'month')
  .format('YYYY-MM-DD');
const DEFAULT_END_DATE = null;

/**
 * Lit summary page component.
 */
export default class DeepAnalyzerPageSummary extends LitElement {
  /**
   * Lit component property definitions.
   */
  static get properties() {
    return {
      sentimentDatas: {type: Array},

      _siteConfiguration: {type: Object},

      _sentimentQueryController: {type: Object},

      _sentimentSubscriptionController: {type: Object},

      _collectEconomicDataMutationController: {type: Object},
    };
  }

  /**
   * Lit component constructor.
   */
  constructor() {
    super();

    this.sentimentDatas = [];

    this._siteConfiguration = {observedEconomicEntities: []};

    this._sentimentQueryController = new ApolloQueryController(
      this,
      GetSentiment,
      {
        variables: {
          economicEntities: [],
          startDate: this._utcDateString(DEFAULT_START_DATE, {
            hour: 0,
            minute: 0,
            second: 0,
            millisecond: 0,
          }),
          endDate: DEFAULT_END_DATE
            ? this._utcDateString(DEFAULT_END_DATE, {
                hour: 23,
                minute: 59,
                second: 59,
                millisecond: 999,
              })
            : DEFAULT_END_DATE,
        },
        noAutoSubscribe: true,
        onData: (data) => {
          const targetDatas = data?.getSentiments[0] || [];
          if (targetDatas.length > 0) {
            this.sentimentDatas = targetDatas;
          }
        },
        onError: (error) => {
          console.error(
            `Fetch sentiments failed with error: ${JSON.stringify(error)}`
          );
        },
      }
    );

    this._sentimentSubscriptionController = new ApolloSubscriptionController(
      this,
      UpdateSentiments,
      {
        variables: {
          economicEntities: [],
          startDate: this._utcDateString(DEFAULT_START_DATE, {
            hour: 0,
            minute: 0,
            second: 0,
            millisecond: 0,
          }),
          endDate: DEFAULT_END_DATE
            ? this._utcDateString(DEFAULT_END_DATE, {
                hour: 23,
                minute: 59,
                second: 59,
                millisecond: 999,
              })
            : DEFAULT_END_DATE,
        },
        onData: ({subscriptionData}) => {
          const newSentiment = subscriptionData?.data?.updateSentiments;
          if (Object.keys(newSentiment).length > 0) {
            // Remove oldest reading from array.
            this.sentimentDatas.shift();

            // Push newest reading into array.
            this.sentimentDatas.push(newSentiment);
          }
        },
        onError: (error) => {
          console.error(
            `An error occurred while subscribing to sentiment updates: ${JSON.stringify(
              error
            )}`
          );
        },
      }
    );

    this._collectEconomicDataMutationController = new ApolloMutationController(
      this,
      CollectEconomicData,
      {
        onError: (error) => {
          console.error(
            `An error occurred while attempting to collect economic data: ${JSON.stringify(
              error
            )}`
          );
        },
      }
    );
  }

  /**
   * Lit callback executed on first update of the component.
   */
  async firstUpdated() {
    super.firstUpdated();

    // NOTE: TODO: While a fix goes into place allowing mwc-button height/width to be set this
    // hack will be used to make the button size equal to what's desired for the app.
    await this.updateComplete;
    const materialButton = this.shadowRoot.querySelector('mwc-button');
    const button = materialButton.shadowRoot.querySelector('#button');
    button.setAttribute('style', 'height: 100%; width: 100%;');
  }

  /**
   * Lit callback on component connect.
   */
  connectedCallback() {
    super.connectedCallback();

    globalThis.addEventListener('resize', this._redrawChart);
    globalThis.addEventListener('orientationchange', this._redrawChart);
  }

  /**
   * Lit callback on component disconnect.
   */
  disconnectedCallback() {
    globalThis.removeEventListener('resize', this._redrawChart);
    globalThis.removeEventListener('orientationchange', this._redrawChart);

    super.disconnectedCallback();
  }

  /**
   * Lit component style definitions.
   * @return {TemplateResult}
   */
  static get styles() {
    const INPUT_RADIUS = 3;
    const INPUT_WIDTH = 90;
    return css`
      :host {
        display: block;
        height: 100%;
        width: 100%;
      }

      .page-grid {
        display: grid;
        grid-template-columns: 1fr;
        grid-template-rows: auto 62px auto;
        justify-items: center;
        align-items: center;
        height: 100%;
        width: 100%;
      }

      .card-deck {
        display: grid;
        grid-template-columns: 1fr;
        grid-gap: 4px;
        justify-items: center;
        height: 75vh;
        width: ${INPUT_WIDTH}%;
        padding: 8px;
        margin: 8px;
        overflow: scroll;
        scrollbar-width: none;
        -ms-overflow-style: none;
      }

      .card-deck::-webkit-scrollbar {
        display: none; /* Safari and Chrome */
      }

      .card {
        width: ${INPUT_WIDTH}%;
        height: 275px;
        max-height: 275px;
        padding: 8px;
        margin: 8px;
      }

      .input {
        width: ${INPUT_WIDTH}%;
        max-width: ${INPUT_WIDTH}%;
        margin: 2px;
      }

      .watch {
        display: grid;
        grid-template-columns: 80% 19.65%;
        grid-gap: 0.35%;
        justify-content: center;
        align-items: center;
      }

      .selection {
        display: grid;
        grid-template-columns: auto;
        grid-template-rows: auto auto auto;
        grid-gap: 2%;
        align-items: stretch;
      }

      .date-picker {
        background-color: white;
        border-radius: ${INPUT_RADIUS}px;
        width: 100%;
      }

      google-chart {
        width: 90%;
        height: auto;
        margin: 0px;
        padding: 0px;
      }

      .card {
        --shadow-color: var(--secondary-color-dark, lightgray);
      }

      mwc-button {
        height: 100%;
        width: 100%;
        --mdc-theme-primary: var(--primary-color-light);
        --mdc-theme-on-primary: var(--secondary-color);
      }

      .summary {
        display: flex;
        flex-direction: row;
        justify-content: space-around;
      }

      @media (min-width: 992px) {
        .card-deck {
          grid-template-columns: repeat(5, 1fr);
          grid-template-rows: auto;
        }

        .card {
          height: 85%;
          padding: 5%;
        }

        google-chart {
          width: 90%;
          height: 80%;
          margin: 0;
        }

        .selection {
          grid-template-columns: 1fr 1fr 1fr;
          grid-template-rows: auto;
          justify-content: space-between;
        }
      }

      [hidden] {
        display: none;
      }
    `;
  }

  /**
   * Lit updated lifecycle callback.
   */
  updated() {
    const chart = this.shadowRoot.querySelector('google-chart');
    if (chart) {
      this._setChartOptions();
    }
  }

  /**
   * Lit component render function.
   * @return {TemplateResult}
   */
  render() {
    return html`
      <deep-site-configuration
        @site-configuration="${this._handleSiteConfig}"
        hidden
      ></deep-site-configuration>

      <div class="page-grid">
        ${this._cardDeck(this.sentimentDatas)} ${this._watchInputs()}
        ${this._selectionInputs(this._siteConfiguration)}
      </div>
    `;
  }

  /**
   * Set the options for the sentiment chart.
   */
  _setChartOptions() {
    const googleChart = this.shadowRoot.querySelector('google-chart');
    const options = googleChart.options;
    options.vAxis = {title: 'Sentiment', minValue: -5, maxValue: 5};
    googleChart.options = options;
  }

  /**
   * Redraw the chart.
   *
   * NOTE: The use of an arrow function is required here because it ensures that
   * the 'this' context of the redraw function references the component when executed
   * from addEventListener.
   */
  _redrawChart = () => {
    // eslint-disable-next-line
    const chart = this.shadowRoot.querySelector('google-chart');
    if (chart) {
      chart.redraw();
    }
  };

  /**
   * Determine if the sentiment matches the data at the selected point in the google chart.
   * @param {Object} sentiment - Sentiment response from the API.
   * @param {Array} selectedPoint - Point selected on the google chart.
   * @return {Boolean} True if data matches. False otherwise.
   */
  _hasMatchingData(sentiment, selectedPoint) {
    return (
      sentiment.utcDateTime === selectedPoint[0] &&
      sentiment.comparative === selectedPoint[1]
    );
  }

  /**
   * Handle input.
   */
  _onInput() {
    const companyName = this.shadowRoot.querySelector('mwc-textfield').value;
    this._collectEconomicDataMutationController.variables = {
      economicEntities: [
        EconomicEntityFactory.get({
          name: companyName,
          type: EconomicEntityType.Business,
        }),
      ],
    };
  }

  /**
   * Collect economic data and update the user configuration to account for new collections.
   */
  _collectEconomicData() {
    const deepSiteConfig = this.shadowRoot.querySelector(
      'deep-site-configuration'
    );

    const economicEntity =
      this._collectEconomicDataMutationController.variables.economicEntities[0];
    deepSiteConfig.observeEconomicEntity(
      EconomicEntityFactory.get(economicEntity)
    );
    deepSiteConfig.updateConfiguration();
    this._collectEconomicDataMutationController.mutate();
  }

  /**
   * Find the selected business and add it to the query variables.
   */
  _onSelectBusiness() {
    const businessName = this.shadowRoot.querySelector(
      '#business > [aria-selected="true"]'
    ).value;

    const variables = {
      ...this._sentimentQueryController.variables,
      economicEntities: [
        EconomicEntityFactory.get({
          name: businessName,
          type: EconomicEntityType.Business,
        }),
      ],
    };

    this._updateSentimentControllers(variables);
    this._sentimentQueryController.executeQuery();
  }

  /**
   * Handle user selection of new start date.
   */
  _onSelectStartDate() {
    const selectedStartDate =
      this.shadowRoot.querySelector('#start-date').value;

    let utcDate = selectedStartDate;
    if (!utcDate) {
      console.warn(
        `An invalid start date was received. Falling back on the defaults.`
      );
      utcDate = DEFAULT_START_DATE;
    }

    utcDate = this._utcDateString(utcDate, {
      hour: 0,
      minute: 0,
      second: 0,
      millisecond: 0,
    });

    const variables = {
      ...this._sentimentQueryController.variables,
      startDate: utcDate,
    };

    this._updateSentimentControllers(variables);
    this._sentimentQueryController.executeQuery();
  }

  /**
   * Handle user selection of new end date.
   */
  _onSelectEndDate() {
    const selectedEndDate =
      this.shadowRoot.querySelector('#end-date').value || null;

    const utcDate = selectedEndDate
      ? this._utcDateString(selectedEndDate, {
          hour: 23,
          minute: 59,
          second: 59,
          millisecond: 999,
        })
      : null;

    const variables = {
      ...this._sentimentQueryController.variables,
      endDate: utcDate,
    };

    this._updateSentimentControllers(variables);
    this._sentimentQueryController.executeQuery();
  }

  /**
   * Convert date to utc string.
   * @param {String} subject Date string.
   * @param {Object} options Date transform options. I.e, { hour: <hour to set>, minute: <minute to set>, etc }.
   * @return {String} UTC formatted date time.
   */
  _utcDateString(subject, options) {
    const utcDate = moment.utc(subject);
    if (Object.keys(options).length > 0) {
      utcDate.set(options);
    }
    return utcDate.format();
  }

  _subscriptionClient;
  /**
   * Update sentiment controllers to use new values.
   * @param {Object} variables
   */
  _updateSentimentControllers(variables) {
    // Subscribe to updates for the desired business.
    // NOTE: This must occur before the data is fetched for the first time. Otherwise,
    // updating from zero to one watched business won't update the sentiment graph.
    this._sentimentSubscriptionController.variables = variables;

    // Fetch the data right away
    this._sentimentQueryController.variables = variables;
  }

  /**
   * Get the most recent sentiment value.
   * @param {Object} sentimentDatas Data which will be used to fetch most recent sentiment.
   * @return {Number} Most recent sentiment value.
   */
  _mostRecentSentiment(sentimentDatas) {
    return sentimentDatas[sentimentDatas.length - 1]?.comparative || 0;
  }

  /**
   * Get markup for sentiment summary card.
   * @param {Array<Object>} sentimentDatas
   * @return {Object} Lit HTML template result or ''.
   */
  _sentimentSummaryCard(sentimentDatas) {
    return sentimentDatas.length > 0
      ? html`
          <deep-card class="card">
            <h4 slot="header">Sentiment Summary</h4>
            <div class="summary" slot="body">
              <div>
                Recent
                <div>
                  ${this._mostRecentSentiment(this.sentimentDatas).toFixed(3)}
                </div>
              </div>
              <div>
                Average
                <div>
                  ${(
                    this.sentimentDatas
                      .map((value) => value.comparative || 0)
                      .reduce((previous, current) => previous + current, 0) /
                    this.sentimentDatas.length
                  ).toFixed(3)}
                </div>
              </div>
            </div>
          </deep-card>
        `
      : ``;
  }

  /**
   * Get markup for sentiment graph.
   * @param {Array<Object>} sentimentDatas
   * @return {Object} Lit HTML template result or ''.
   */
  _sentimentGraphCard(sentimentDatas) {
    // @google-chart-select="${this._handleChartSelection}"
    return sentimentDatas.length > 0
      ? html`
          <deep-card class="card">
            <h4 slot="header">Public Sentiment</h4>
            <google-chart
              slot="body"
              options="{}"
              type="line"
              cols='[{"label": "Date", "type": "string"}, {"label": "Comparative Score", "type": "number"}]'
              rows="[${sentimentDatas?.map((sentiment) =>
                JSON.stringify([
                  moment.utc(sentiment.utcDateTime).local().toDate(),
                  sentiment.comparative,
                ])
              )}]"
            ></google-chart>
          </deep-card>
        `
      : ``;
  }

  /**
   * Get watch input markup.
   * @return {Object} Lit template result.
   */
  _watchInputs() {
    return html` <div class="input watch">
      <mwc-textfield
        label="Watch (i.e, Google)"
        @input="${this._onInput.bind(this)}"
      ></mwc-textfield>
      <mwc-button
        raised
        @click="${this._collectEconomicData.bind(this)}"
        icon="add"
      ></mwc-button>
    </div>`;
  }

  /**
   * Get selection input markup.
   * @param {Object} siteConfiguration User configuration.
   * @return {Object} Lit template result.
   */
  _selectionInputs(siteConfiguration) {
    return html`
      <div class="input selection">
        <mwc-select
          id="business"
          label="Analyze"
          @selected="${this._onSelectBusiness}"
        >
          ${siteConfiguration.observedEconomicEntities.map(
            (economicEntity, index) =>
              html`<mwc-list-item
                ?selected="${index === 0}"
                value="${economicEntity.name}"
                >${economicEntity.name}</mwc-list-item
              >`
          )}
        </mwc-select>
        <vaadin-date-picker
          id="start-date"
          label="Start Date"
          class="date-picker"
          placeholder="MM/DD/YYYY"
          value="${DEFAULT_START_DATE}"
          @value-changed="${this._onSelectStartDate.bind(this)}"
          required
        ></vaadin-date-picker>
        <vaadin-date-picker
          id="end-date"
          label="End Date"
          class="date-picker"
          placeholder="MM/DD/YYYY"
          clear-button-visible
          @value-changed="${this._onSelectEndDate.bind(this)}"
        ></vaadin-date-picker>
      </div>
    `;
  }

  /**
   * Get card deck markup.
   * @param {Array<Object>} sentimentDatas Sentiment data from the api.
   * @return {Object} Lit template result.
   */
  _cardDeck(sentimentDatas) {
    return html`
      <div class="card-deck">
        ${sentimentDatas.length > 0
          ? this._sentimentSummaryCard(sentimentDatas)
          : ``}
        ${sentimentDatas.length > 0
          ? this._sentimentGraphCard(sentimentDatas)
          : ``}
      </div>
    `;
  }

  /**
   * Handle reception of the site-configuration event.
   * @param {Object} detail - Configuration of the form { observedEconomicEntities: [...]}.
   */
  _handleSiteConfig({detail}) {
    this._siteConfiguration = detail || {observedEconomicEntities: []};
  }
}

customElements.define('deep-analyzer-page-summary', DeepAnalyzerPageSummary);