controllers/dashboard/core_vitals_summary_controller.js

  1. import { Controller } from "@hotwired/stimulus"
  2. import { debounce } from "debounce"
  3. import { subscription } from "~/javascripts/store/mixins/subscription"
  4. import {
  5. targetLineValues, metricsInPercentile, axisMeasurementValues, percentage
  6. } from "~/javascripts/dashboard/utils"
  7. import SingleStackedBar from "~/javascripts/dashboard/SingleStackedBar"
  8. /**
  9. * @class Dashboard.CoreVitalsSummaryController
  10. * @classdesc Stimulus controller that populates the mean core vitals score per vital type.
  11. * @extends Controller
  12. **/
  13. export default class extends Controller {
  14. static targets = [
  15. "lcp", "fid", "cls", "lcpLoader", "fidLoader", "clsLoader", "lcpVis", "fidVis", "clsVis"
  16. ]
  17. static values = {
  18. loading: String,
  19. storeId: String
  20. }
  21. static classes = [ "success", "warning", "error", "unavailable" ]
  22. /**
  23. * Subscribe to the store.
  24. *
  25. * @instance
  26. * @memberof Dashboard.CoreVitalsSummaryController
  27. **/
  28. connect() {
  29. subscription(this)
  30. this.subscribe()
  31. this.reconnect()
  32. this.resize = debounce(this.resize, 250).bind(this)
  33. }
  34. /**
  35. * Handles a repeated Turbo visit to the dashboard page.
  36. *
  37. * @instance
  38. * @memberof Dashboard.CoreVitalsSummaryController
  39. **/
  40. reconnect() {
  41. if (this.store("selectedDataVizData")) {
  42. this.storeUpdated("selectedDataVizData", this.storeIdValue)
  43. }
  44. }
  45. /**
  46. * Handles a resize of the screen by width but ignores height changes. Deletes the current
  47. * visualisation and the instances of the stacked bar charts so they will be created from scratch again.
  48. *
  49. * @instance
  50. * @memberof Dashboard.CoreVitalsSummaryController
  51. **/
  52. resize() {
  53. if (window.innerWidth === this._windowWidth) return
  54. this._windowWidth = window.innerWidth
  55. this.destroySingleStackedBarDisplay();
  56. ["lcp", "fid", "cls"].forEach((context) => {
  57. console.log(this.contextData(context))
  58. this.updateContextDistribution(context, this.contextData(context))
  59. })
  60. }
  61. /**
  62. * Shows the loading indicator for the mean core vital values and hides the stacked bar charts and
  63. * then destroys them so they can be created from scratch again when the new data is fetched from
  64. * the database.
  65. *
  66. * @instance
  67. * @memberof Dashboard.CoreVitalsSummaryController
  68. **/
  69. loadSummary(target, text) {
  70. target.innerHTML = `${text} ${this.loadingValue}`
  71. this.removeAlertColors(target)
  72. this.singleStackedBarDisplay(false)
  73. this.destroySingleStackedBarDisplay()
  74. }
  75. /**
  76. * Remove all of the color classes for the given target
  77. *
  78. * @instance
  79. * @memberof Dashboard.CoreVitalsSummaryController
  80. **/
  81. removeAlertColors(target) {
  82. target.classList.remove(this.successClass, this.warningClass, this.errorClass, this.unavailableClass)
  83. }
  84. /**
  85. * Changes the visibility of the stacked bar charts to either show the data vis or a loading indicator
  86. *
  87. * @instance
  88. * @memberof Dashboard.CoreVitalsSummaryController
  89. **/
  90. singleStackedBarDisplay(hasData=true) {
  91. let dataVis = hasData ? "block" : "none"
  92. let loader = hasData ? "none" : "block"
  93. this.lcpVisTarget.style.display = dataVis
  94. this.fidVisTarget.style.display = dataVis
  95. this.clsVisTarget.style.display = dataVis
  96. this.lcpLoaderTarget.style.display = loader
  97. this.fidLoaderTarget.style.display = loader
  98. this.clsLoaderTarget.style.display = loader
  99. }
  100. /**
  101. * Destroy stacked bar charts for each context and set them to undefined so they will be created from
  102. * scratch again
  103. *
  104. * @instance
  105. * @memberof Dashboard.CoreVitalsSummaryController
  106. **/
  107. destroySingleStackedBarDisplay() {
  108. ["lcp", "fid", "cls"].forEach((context) => {
  109. this[`${context}VisTarget`].innerHTML = ""
  110. this[`_singleStackedBar_${context}`] = undefined
  111. })
  112. }
  113. /**
  114. * Update the summary details for the mean values and stacked bar charts
  115. *
  116. * @instance
  117. * @memberof Dashboard.CoreVitalsSummaryController
  118. **/
  119. updateSummary(target, text, context) {
  120. let contextData = this.contextData(context)
  121. this.updateMeanCoreVitalsValue(target, text, context, contextData)
  122. this.singleStackedBarDisplay()
  123. this.updateContextDistribution(context, contextData)
  124. }
  125. /**
  126. * Get the data within the 75th percentile of all current results for the given context
  127. *
  128. * @instance
  129. * @memberof Dashboard.CoreVitalsSummaryController
  130. **/
  131. contextData(context) {
  132. let data = this.store("selectedDataVizData").filter(data => data.metric === context)
  133. return metricsInPercentile(data, "data_float", 0.75)
  134. }
  135. /**
  136. * Sets the text for the mean core vital for the given context and alters to color to indicate whether
  137. * it's good, needs improvement or poor.
  138. *
  139. * @instance
  140. * @memberof Dashboard.CoreVitalsSummaryController
  141. **/
  142. updateMeanCoreVitalsValue(target, text, context, contextData) {
  143. const meanCoreVital = this.calculateMeanCoreVital(contextData)
  144. target.innerHTML = `${text} ${meanCoreVital} ${axisMeasurementValues(context)}`
  145. this.removeAlertColors(target)
  146. target.classList.add(this.alertColor(meanCoreVital, context))
  147. }
  148. /**
  149. * @instance
  150. * @memberof Dashboard.CoreVitalsSummaryController
  151. **/
  152. calculateMeanCoreVital(contextData) {
  153. const totals = contextData.reduce((acc , data) => {
  154. return { count: acc.count + 1, dataFloat: acc.dataFloat + data.data_float }
  155. }, { count: 0, dataFloat: 0 })
  156. return totals.count === 0 ? "N/A" :
  157. Math.round(((totals.dataFloat / totals.count) + Number.EPSILON) * 10000) / 10000
  158. }
  159. /**
  160. * Return the color depending on the time in relation to the KPI
  161. *
  162. * @instance
  163. * @memberof Dashboard.CoreVitalsSummaryController
  164. **/
  165. alertColor(value, context) {
  166. const lineValues = targetLineValues(context)
  167. if (value == "N/A") return this.unavailableClass
  168. if (value <= lineValues.successLineValue) return this.successClass
  169. if (value <= lineValues.failLineValue) return this.warningClass
  170. return this.errorClass
  171. }
  172. /**
  173. * Update the datavisualisation showing the split of good, okay, bad for the context
  174. *
  175. * @instance
  176. * @memberof Dashboard.CoreVitalsSummaryController
  177. **/
  178. updateContextDistribution(context, contextData) {
  179. const grouped = this.coreVitalsGroupingCount(context, contextData)
  180. const data = this.dataForStackedBarChart(grouped)
  181. if(this.isDataVizEmpty(context)) {
  182. this.singleStackedBar(data, context).createDataVis()
  183. } else {
  184. this.singleStackedBar(data, context).updateDataVis(data, context)
  185. }
  186. }
  187. /**
  188. * Get the total count of results plus the number of results within each context.
  189. *
  190. * @instance
  191. * @memberof Dashboard.CoreVitalsSummaryController
  192. **/
  193. coreVitalsGroupingCount(context, contextData) {
  194. let result
  195. const target = targetLineValues(context)
  196. return contextData.reduce((acc , data) => {
  197. if (data.data_float <= target.successLineValue) {
  198. result = "good"
  199. } else if (data.data_float >= target.failLineValue) {
  200. result = "poor"
  201. } else {
  202. result = "needsImprovement"
  203. }
  204. let preUpdate = {
  205. count: acc.count + 1, good: acc.good, needsImprovement: acc.needsImprovement, poor: acc.poor
  206. }
  207. preUpdate[result] += 1
  208. return preUpdate
  209. }, { count: 0, good: 0, needsImprovement: 0, poor: 0 })
  210. }
  211. /**
  212. * Get the percentage for each context. The cumulative value informs the data viz at which x axis value
  213. * the bar should start at. The bar class modifier is used in the CSS to change the bar colour
  214. *
  215. * @instance
  216. * @memberof Dashboard.CoreVitalsSummaryController
  217. **/
  218. dataForStackedBarChart(groupedCounts) {
  219. const good = percentage(groupedCounts.count, groupedCounts.good)
  220. const needsImprovement = percentage(groupedCounts.count, groupedCounts.needsImprovement)
  221. const poor = percentage(groupedCounts.count, groupedCounts.poor)
  222. return [
  223. { percentage: good, cumulative: 0, barClassModifier: "good" },
  224. { percentage: needsImprovement, cumulative: good, barClassModifier: "needsImprovement" },
  225. { percentage: poor, cumulative: (good + needsImprovement), barClassModifier: "poor" }
  226. ]
  227. }
  228. /**
  229. * Checks if the visualisation has been created yet
  230. *
  231. * @instance
  232. * @memberof Dashboard.CoreVitalsSummaryController
  233. **/
  234. isDataVizEmpty(context) {
  235. return document.querySelector(`[data-stacked-bar-${context}='wrapper']`).getElementsByTagName("svg").length === 0
  236. }
  237. /**
  238. * Creates a new instance of the SingleStackedBar class if not created, otherwise returns the instance
  239. * of the class
  240. *
  241. * @instance
  242. * @memberof Dashboard.CoreVitalsSummaryController
  243. **/
  244. singleStackedBar(data, context) {
  245. if (this[`_singleStackedBar_${context}`] === undefined) {
  246. this._windowWidth = window.innerWidth
  247. this[`_singleStackedBar_${context}`] = new SingleStackedBar(
  248. data,
  249. this.store("contextSelected"),
  250. `[data-stacked-bar-${context}='wrapper']`
  251. )
  252. }
  253. return this[`_singleStackedBar_${context}`]
  254. }
  255. /**
  256. * Triggered by the store whenever any store data changes.
  257. *
  258. * @instance
  259. * @memberof Dashboard.CoreVitalsSummaryController
  260. **/
  261. storeUpdated(prop, storeId) {
  262. if (this.store("fetchingDataVizData")) {
  263. this.loadSummary(this.lcpTarget, "LCP")
  264. this.loadSummary(this.fidTarget, "FID")
  265. this.loadSummary(this.clsTarget, "CLS")
  266. }
  267. if (prop === "selectedDataVizData" && storeId === this.storeIdValue) {
  268. this.updateSummary(this.lcpTarget, "LCP ...", "lcp")
  269. this.updateSummary(this.fidTarget, "FID ...", "fid")
  270. this.updateSummary(this.clsTarget, "CLS ...", "cls")
  271. }
  272. }
  273. /**
  274. * Unsubscribe from the store
  275. *
  276. * @instance
  277. * @memberof Dashboard.MeanBuildTimesController
  278. **/
  279. disconnect() {
  280. this.unsubscribe()
  281. }
  282. }