controllers/dashboard/core_vitals_summary_controller.js

import { Controller } from "@hotwired/stimulus"
import { debounce } from "debounce"
import { subscription } from "~/javascripts/store/mixins/subscription"
import { 
  targetLineValues, metricsInPercentile, axisMeasurementValues, percentage
} from "~/javascripts/dashboard/utils"
import SingleStackedBar from "~/javascripts/dashboard/SingleStackedBar"

/**
 * @class Dashboard.CoreVitalsSummaryController
 * @classdesc Stimulus controller that populates the mean core vitals score per vital type.
 * @extends Controller
 **/
export default class extends Controller {
  static targets = [
    "lcp", "fid", "cls", "lcpLoader", "fidLoader", "clsLoader", "lcpVis", "fidVis", "clsVis"
  ]
  static values = {
    loading: String,
    storeId: String
  }
  static classes = [ "success", "warning", "error", "unavailable" ]

  /** 
   * Subscribe to the store.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
  connect() {
    subscription(this)
    this.subscribe()
    this.reconnect()
    this.resize = debounce(this.resize, 250).bind(this)
  }

  /** 
   * Handles a repeated Turbo visit to the dashboard page.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
  reconnect() {
    if (this.store("selectedDataVizData")) {
      this.storeUpdated("selectedDataVizData", this.storeIdValue)
    }
  }

  /** 
   * Handles a resize of the screen by width but ignores height changes. Deletes the current
   * visualisation and the instances of the stacked bar charts so they will be created from scratch again.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
  resize() {
    if (window.innerWidth === this._windowWidth) return

    this._windowWidth = window.innerWidth
    this.destroySingleStackedBarDisplay();

    ["lcp", "fid", "cls"].forEach((context) => {
      console.log(this.contextData(context))
      this.updateContextDistribution(context, this.contextData(context))
    })
  }

  /** 
   * Shows the loading indicator for the mean core vital values and hides the stacked bar charts and
   * then destroys them so they can be created from scratch again when the new data is fetched from
   * the database.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   loadSummary(target, text) {
    target.innerHTML = `${text} ${this.loadingValue}`
    this.removeAlertColors(target)

    this.singleStackedBarDisplay(false)
    this.destroySingleStackedBarDisplay()
  }

  /** 
   * Remove all of the color classes for the given target
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   removeAlertColors(target) {
    target.classList.remove(this.successClass, this.warningClass, this.errorClass, this.unavailableClass)
  }

  /** 
   * Changes the visibility of the stacked bar charts to either show the data vis or a loading indicator
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   singleStackedBarDisplay(hasData=true) {
    let dataVis = hasData ? "block" : "none"
    let loader = hasData ? "none" : "block"

    this.lcpVisTarget.style.display = dataVis
    this.fidVisTarget.style.display = dataVis
    this.clsVisTarget.style.display = dataVis
    this.lcpLoaderTarget.style.display = loader
    this.fidLoaderTarget.style.display = loader
    this.clsLoaderTarget.style.display = loader
  }

  /** 
   * Destroy stacked bar charts for each context and set them to undefined so they will be created from
   * scratch again
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   destroySingleStackedBarDisplay() {
    ["lcp", "fid", "cls"].forEach((context) => {
      this[`${context}VisTarget`].innerHTML = ""
      this[`_singleStackedBar_${context}`] = undefined
    })
  }

  /** 
   * Update the summary details for the mean values and stacked bar charts
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   updateSummary(target, text, context) {
    let contextData = this.contextData(context)

    this.updateMeanCoreVitalsValue(target, text, context, contextData)
    this.singleStackedBarDisplay()
    this.updateContextDistribution(context, contextData)
  }

  /** 
   * Get the data within the 75th percentile of all current results for the given context
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   contextData(context) {
    let data = this.store("selectedDataVizData").filter(data => data.metric === context)
    return metricsInPercentile(data, "data_float", 0.75)
  }

  /** 
   * Sets the text for the mean core vital for the given context and alters to color to indicate whether
   * it's good, needs improvement or poor.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   updateMeanCoreVitalsValue(target, text, context, contextData) {
    const meanCoreVital = this.calculateMeanCoreVital(contextData)
    target.innerHTML = `${text} ${meanCoreVital} ${axisMeasurementValues(context)}`
    this.removeAlertColors(target)
    target.classList.add(this.alertColor(meanCoreVital, context))
  }

  /** 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   calculateMeanCoreVital(contextData) {
    const totals = contextData.reduce((acc , data) => {
      return { count: acc.count + 1, dataFloat: acc.dataFloat + data.data_float }
    }, { count: 0, dataFloat: 0 })
    return totals.count === 0 ? "N/A" :
                                Math.round(((totals.dataFloat / totals.count) + Number.EPSILON) * 10000) / 10000
  }

  /** 
   * Return the color depending on the time in relation to the KPI
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   alertColor(value, context) {
    const lineValues = targetLineValues(context)

    if (value == "N/A") return this.unavailableClass
    if (value <= lineValues.successLineValue) return this.successClass
    if (value <= lineValues.failLineValue) return this.warningClass
    return this.errorClass
  }

  /** 
   * Update the datavisualisation showing the split of good, okay, bad for the context
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   updateContextDistribution(context, contextData) {
    const grouped = this.coreVitalsGroupingCount(context, contextData)
    const data = this.dataForStackedBarChart(grouped)

    if(this.isDataVizEmpty(context)) {
      this.singleStackedBar(data, context).createDataVis()
    } else {
      this.singleStackedBar(data, context).updateDataVis(data, context)
    }
  }

  /** 
   * Get the total count of results plus the number of results within each context.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   coreVitalsGroupingCount(context, contextData) {
    let result
    const target = targetLineValues(context)
    
    return contextData.reduce((acc , data) => {
      if (data.data_float <= target.successLineValue) {
        result = "good"
      } else if (data.data_float >= target.failLineValue) {
        result = "poor"
      } else {
        result = "needsImprovement"
      }
      let preUpdate = { 
        count: acc.count + 1, good: acc.good, needsImprovement: acc.needsImprovement, poor: acc.poor
      }
      preUpdate[result] += 1 
      return preUpdate
    }, { count: 0, good: 0, needsImprovement: 0, poor: 0 })
  }

  /** 
   * Get the percentage for each context. The cumulative value informs the data viz at which x axis value
   * the bar should start at. The bar class modifier is used in the CSS to change the bar colour
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
  dataForStackedBarChart(groupedCounts) {
    const good = percentage(groupedCounts.count, groupedCounts.good)
    const needsImprovement = percentage(groupedCounts.count, groupedCounts.needsImprovement)
    const poor = percentage(groupedCounts.count, groupedCounts.poor)
    return [
      { percentage: good, cumulative: 0, barClassModifier: "good" },
      { percentage: needsImprovement, cumulative: good, barClassModifier: "needsImprovement" },
      { percentage: poor, cumulative: (good + needsImprovement), barClassModifier: "poor" }
    ]
  }

  /** 
   * Checks if the visualisation has been created yet
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   isDataVizEmpty(context) {
    return document.querySelector(`[data-stacked-bar-${context}='wrapper']`).getElementsByTagName("svg").length === 0
  }

  /** 
   * Creates a new instance of the SingleStackedBar class if not created, otherwise returns the instance
   * of the class
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
   singleStackedBar(data, context) {
    if (this[`_singleStackedBar_${context}`] === undefined) {
      this._windowWidth = window.innerWidth
      this[`_singleStackedBar_${context}`] = new SingleStackedBar(
        data,
        this.store("contextSelected"),
        `[data-stacked-bar-${context}='wrapper']`
      )
    }
    return this[`_singleStackedBar_${context}`]
  }

  /** 
   * Triggered by the store whenever any store data changes.
   * 
   * @instance
   * @memberof Dashboard.CoreVitalsSummaryController
   **/
  storeUpdated(prop, storeId) {
    if (this.store("fetchingDataVizData")) {
      this.loadSummary(this.lcpTarget, "LCP")
      this.loadSummary(this.fidTarget, "FID")
      this.loadSummary(this.clsTarget, "CLS")
    }
    if (prop === "selectedDataVizData" && storeId === this.storeIdValue) {
      this.updateSummary(this.lcpTarget, "LCP ...", "lcp")
      this.updateSummary(this.fidTarget, "FID ...", "fid")
      this.updateSummary(this.clsTarget, "CLS ...", "cls")
    }
  }

  /** 
   * Unsubscribe from the store
   * 
   * @instance
   * @memberof Dashboard.MeanBuildTimesController
   **/
  disconnect() {
    this.unsubscribe()
  }
}