diff --git a/static/app/components/charts/eventsRequest.tsx b/static/app/components/charts/eventsRequest.tsx index 716be26600628a..8dfeff961818dc 100644 --- a/static/app/components/charts/eventsRequest.tsx +++ b/static/app/components/charts/eventsRequest.tsx @@ -40,12 +40,9 @@ type LoadingStatus = { errored: boolean; }; -// Chart format for multiple series. -type MultiSeriesResults = Series[]; - export type RenderProps = LoadingStatus & TimeSeriesData & { - results?: MultiSeriesResults; + results?: Series[]; // Chart with multiple series. }; type DefaultProps = { @@ -483,15 +480,16 @@ class EventsRequest extends React.PureComponent a[0] - b[0]); - const results: MultiSeriesResults = sortedTimeseriesData.map(item => { + const results: Series[] = sortedTimeseriesData.map(item => { return item[1]; }); - const previousTimeseriesData: MultiSeriesResults | undefined = - sortedTimeseriesData.some(item => item[2] === null) - ? undefined - : sortedTimeseriesData.map(item => { - return item[2] as Series; - }); + const previousTimeseriesData: Series[] | undefined = sortedTimeseriesData.some( + item => item[2] === null + ) + ? undefined + : sortedTimeseriesData.map(item => { + return item[2] as Series; + }); return children({ loading, diff --git a/static/app/views/performance/landing/views/frontendPageloadView.tsx b/static/app/views/performance/landing/views/frontendPageloadView.tsx index d3c2a84d9def22..7a5bbc66a03f09 100644 --- a/static/app/views/performance/landing/views/frontendPageloadView.tsx +++ b/static/app/views/performance/landing/views/frontendPageloadView.tsx @@ -2,7 +2,7 @@ import {usePageError} from 'app/utils/performance/contexts/pageError'; import Table from '../../table'; import {FRONTEND_PAGELOAD_COLUMN_TITLES} from '../data'; -import {TripleChartRow} from '../widgets/components/widgetChartRow'; +import {DoubleChartRow, TripleChartRow} from '../widgets/components/widgetChartRow'; import {PerformanceWidgetSetting} from '../widgets/widgetDefinitions'; import {BasePerformanceViewProps} from './types'; @@ -10,6 +10,14 @@ import {BasePerformanceViewProps} from './types'; export function FrontendPageloadView(props: BasePerformanceViewProps) { return (
+ - {showBar && } - - {showDurationDetail && p75 && ( -
- {t('The p75 for all transactions is ')} - {p75} -
- )} - -
+ {showBar && } + {showDetail && ( + + {showDurationDetail && p75 && ( +
+ {t('The p75 for all transactions is ')} + {p75} +
+ )} + + +
+ )} ); } diff --git a/static/app/views/performance/landing/widgets/components/widgetContainer.tsx b/static/app/views/performance/landing/widgets/components/widgetContainer.tsx index 4305594134390e..3a42b723291c2f 100644 --- a/static/app/views/performance/landing/widgets/components/widgetContainer.tsx +++ b/static/app/views/performance/landing/widgets/components/widgetContainer.tsx @@ -14,6 +14,7 @@ import {HistogramWidget} from '../widgets/histogramWidget'; import {LineChartListWidget} from '../widgets/lineChartListWidget'; import {SingleFieldAreaWidget} from '../widgets/singleFieldAreaWidget'; import {TrendsWidget} from '../widgets/trendsWidget'; +import {VitalWidget} from '../widgets/vitalWidget'; import {ChartRowProps} from './widgetChartRow'; @@ -94,6 +95,8 @@ const _WidgetContainer = (props: Props) => { return ; case GenericPerformanceWidgetDataType.area: return ; + case GenericPerformanceWidgetDataType.vitals: + return ; case GenericPerformanceWidgetDataType.line_list: return ; case GenericPerformanceWidgetDataType.histogram: diff --git a/static/app/views/performance/landing/widgets/components/widgetHeader.tsx b/static/app/views/performance/landing/widgets/components/widgetHeader.tsx index ae2ca978c9bb28..8d805b0d6c19c3 100644 --- a/static/app/views/performance/landing/widgets/components/widgetHeader.tsx +++ b/static/app/views/performance/landing/widgets/components/widgetHeader.tsx @@ -13,7 +13,7 @@ import { export function WidgetHeader( props: GenericPerformanceWidgetProps & WidgetDataProps ) { - const {title, titleTooltip, subtitle, HeaderActions} = props; + const {title, titleTooltip, Subtitle, HeaderActions} = props; return ( @@ -21,7 +21,7 @@ export function WidgetHeader( {title} -
{subtitle ? subtitle : null}
+
{Subtitle ? : null}
{HeaderActions && ( diff --git a/static/app/views/performance/landing/widgets/transforms/transformEventsToVitals.tsx b/static/app/views/performance/landing/widgets/transforms/transformEventsToVitals.tsx new file mode 100644 index 00000000000000..6dba8a47b084f6 --- /dev/null +++ b/static/app/views/performance/landing/widgets/transforms/transformEventsToVitals.tsx @@ -0,0 +1,31 @@ +import {RenderProps} from 'app/components/charts/eventsRequest'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; +import {defined} from 'app/utils'; + +import {QueryDefinitionWithKey, WidgetDataConstraint, WidgetPropUnion} from '../types'; + +export function transformEventsRequestToVitals( + widgetProps: WidgetPropUnion, + results: RenderProps, + _: QueryDefinitionWithKey +) { + const {start, end, utc, interval, statsPeriod} = getParams(widgetProps.location.query); + + const data = results.results ?? []; + + const childData = { + ...results, + isLoading: results.loading, + isErrored: results.errored, + hasData: defined(data) && !!data.length && !!data[0].data.length, + data, + + utc: utc === 'true', + interval, + statsPeriod: statsPeriod ?? undefined, + start: start ?? '', + end: end ?? '', + }; + + return childData; +} diff --git a/static/app/views/performance/landing/widgets/types.tsx b/static/app/views/performance/landing/widgets/types.tsx index a71bce61d8845e..8c8b8990a3a799 100644 --- a/static/app/views/performance/landing/widgets/types.tsx +++ b/static/app/views/performance/landing/widgets/types.tsx @@ -94,11 +94,14 @@ type HeaderActions = FunctionComponent<{ widgetData: T; }>; +type Subtitle = FunctionComponent<{ + widgetData: T; +}>; + export type GenericPerformanceWidgetProps = { // Header; title: string; titleTooltip: string; - subtitle?: JSX.Element; fields: string[]; chartHeight: number; @@ -109,6 +112,7 @@ export type GenericPerformanceWidgetProps = { organization: Organization; // Components + Subtitle?: Subtitle; HeaderActions?: HeaderActions; EmptyComponent?: FunctionComponent<{height?: number}>; diff --git a/static/app/views/performance/landing/widgets/widgets/histogramWidget.tsx b/static/app/views/performance/landing/widgets/widgets/histogramWidget.tsx index 97a77d1cf5e537..3d39713bb103da 100644 --- a/static/app/views/performance/landing/widgets/widgets/histogramWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/histogramWidget.tsx @@ -51,9 +51,9 @@ export function HistogramWidget(props: Props) { return ( {...props} - subtitle={ + Subtitle={() => ( {t('Compared to last %s ', globalSelection.datetime.period)} - } + )} HeaderActions={provided => ( diff --git a/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx b/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx index ae81598274417a..d20645d424899f 100644 --- a/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/lineChartListWidget.tsx @@ -153,7 +153,7 @@ export function LineChartListWidget(props: Props) { return ( {...props} - subtitle={{t('Suggested transactions')}} + Subtitle={() => {t('Suggested transactions')}} HeaderActions={provided => ( )} diff --git a/static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx b/static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx index f942f5ca67bc6c..86335f4fd470f9 100644 --- a/static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/singleFieldAreaWidget.tsx @@ -62,9 +62,9 @@ export function SingleFieldAreaWidget(props: Props) { return ( {...props} - subtitle={ + Subtitle={() => ( {t('Compared to last %s ', globalSelection.datetime.period)} - } + )} HeaderActions={provided => ( diff --git a/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx b/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx index 9204839bc0e858..a77c8cc40c8dea 100644 --- a/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx +++ b/static/app/views/performance/landing/widgets/widgets/trendsWidget.tsx @@ -91,7 +91,7 @@ export function TrendsWidget(props: Props) { return ( {...rest} - subtitle={{t('Trending Transactions')}} + Subtitle={() => {t('Trending Transactions')}} HeaderActions={provided => } Queries={Queries} Visualizations={[ diff --git a/static/app/views/performance/landing/widgets/widgets/vitalWidget.tsx b/static/app/views/performance/landing/widgets/widgets/vitalWidget.tsx new file mode 100644 index 00000000000000..3ef075d9dadd16 --- /dev/null +++ b/static/app/views/performance/landing/widgets/widgets/vitalWidget.tsx @@ -0,0 +1,296 @@ +import {Fragment, FunctionComponent, useMemo, useState} from 'react'; +import styled from '@emotion/styled'; +import {Location} from 'history'; + +import Button from 'app/components/button'; +import _EventsRequest from 'app/components/charts/eventsRequest'; +import Link from 'app/components/links/link'; +import Truncate from 'app/components/truncate'; +import {IconClose} from 'app/icons'; +import {t} from 'app/locale'; +import space from 'app/styles/space'; +import {Organization} from 'app/types'; +import DiscoverQuery, {TableDataRow} from 'app/utils/discover/discoverQuery'; +import EventView from 'app/utils/discover/eventView'; +import {WebVital} from 'app/utils/discover/fields'; +import {VitalData} from 'app/utils/performance/vitals/vitalsCardsDiscoverQuery'; +import {decodeList} from 'app/utils/queryString'; +import {MutableSearch} from 'app/utils/tokenizeSearch'; +import withApi from 'app/utils/withApi'; +import {vitalDetailRouteWithQuery} from 'app/views/performance/vitalDetail/utils'; +import {_VitalChart} from 'app/views/performance/vitalDetail/vitalChart'; + +import {excludeTransaction} from '../../utils'; +import {VitalBar} from '../../vitalsCards'; +import {GenericPerformanceWidget} from '../components/performanceWidget'; +import SelectableList, {RightAlignedCell} from '../components/selectableList'; +import {transformDiscoverToList} from '../transforms/transformDiscoverToList'; +import {transformEventsRequestToVitals} from '../transforms/transformEventsToVitals'; +import {QueryDefinition, WidgetDataResult} from '../types'; +import {PerformanceWidgetSetting} from '../widgetDefinitions'; + +type Props = { + title: string; + titleTooltip: string; + fields: string[]; + chartColor?: string; + + eventView: EventView; + location: Location; + organization: Organization; + chartSetting: PerformanceWidgetSetting; + + ContainerActions: FunctionComponent<{isLoading: boolean}>; +}; + +type DataType = { + list: WidgetDataResult & ReturnType; + chart: WidgetDataResult & ReturnType; +}; + +export function VitalWidget(props: Props) { + const {ContainerActions, eventView, organization, location} = props; + const [selectedListIndex, setSelectListIndex] = useState(0); + + const Queries = { + list: useMemo>( + () => ({ + fields: props.fields[0], + component: provided => { + const _eventView = provided.eventView.clone(); + + const fieldFromProps = props.fields.map(field => ({ + field, + })); + + _eventView.sorts = [{kind: 'desc', field: props.fields[0]}]; + + _eventView.fields = [ + {field: 'transaction'}, + {field: 'title'}, + {field: 'project.id'}, + ...fieldFromProps, + ]; + const mutableSearch = new MutableSearch(_eventView.query); + _eventView.query = mutableSearch.formatString(); + return ; + }, + transform: transformDiscoverToList, + }), + [props.eventView, props.fields, props.organization.slug] + ), + chart: useMemo( + () => ({ + enabled: widgetData => { + return !!widgetData?.list?.data?.length; + }, + fields: props.fields, + component: provided => { + const _eventView = provided.eventView.clone(); + + _eventView.additionalConditions.setFilterValues('transaction', [ + provided.widgetData.list.data[selectedListIndex].transaction as string, + ]); + + return ( + + ); + }, + transform: transformEventsRequestToVitals, + }), + [props.eventView, selectedListIndex, props.organization.slug] + ), + }; + + const settingToVital: {[x: string]: WebVital} = { + [PerformanceWidgetSetting.WORST_LCP_VITALS]: WebVital.LCP, + }; + + const handleViewAllClick = () => { + // TODO(k-fish): Add analytics. + }; + + return ( + + {...props} + Subtitle={provided => { + const listItem = provided.widgetData.list?.data[selectedListIndex]; + + if (!listItem) { + return ; + } + + const data = { + [settingToVital[props.chartSetting]]: getVitalDataForListItem(listItem), + }; + + return ( + + + + ); + }} + HeaderActions={provided => { + const vital = settingToVital[props.chartSetting]; + const target = vitalDetailRouteWithQuery({ + orgSlug: organization.slug, + query: eventView.generateQueryStringObject(), + vitalName: vital, + projectID: decodeList(location.query.project), + }); + + return ( + +
+ +
+ +
+ ); + }} + Queries={Queries} + Visualizations={[ + { + component: provided => ( + <_VitalChart + {...provided.widgetData.chart} + {...provided} + field={props.fields[0]} + organization={organization} + query={eventView.query} + project={eventView.project} + environment={eventView.environment} + grid={{ + left: space(0), + right: space(0), + top: space(2), + bottom: space(2), + }} + /> + ), + height: 160, + }, + { + component: provided => ( + () => { + const transaction = listItem.transaction as string; + const _eventView = eventView.clone(); + + const initialConditions = new MutableSearch(_eventView.query); + initialConditions.addFilterValues('transaction', [transaction]); + + const vital = settingToVital[props.chartSetting]; + + _eventView.query = initialConditions.formatString(); + + const target = vitalDetailRouteWithQuery({ + orgSlug: organization.slug, + query: _eventView.generateQueryStringObject(), + vitalName: vital, + projectID: decodeList(location.query.project), + }); + + const data = { + [settingToVital[props.chartSetting]]: getVitalDataForListItem(listItem), + }; + + return ( + + + + + + + + + { + excludeTransaction(listItem.transaction, props); + setSelectListIndex(0); + }} + /> + + + ); + })} + /> + ), + height: 200, + noPadding: true, + }, + ]} + /> + ); +} + +function getVitalDataForListItem(listItem: TableDataRow) { + const poorData: number = + (listItem.count_if_measurements_lcp_greaterOrEquals_4000 as number) || 0; + const mehData: number = (listItem['equation[0]'] as number) || 0; + const goodData: number = (listItem['equation[1]'] as number) || 0; + const _vitalData = { + poor: poorData, + meh: mehData, + good: goodData, + p75: 0, + }; + const vitalData: VitalData = { + ..._vitalData, + total: _vitalData.poor + _vitalData.meh + _vitalData.good, + }; + + return vitalData; +} + +const EventsRequest = withApi(_EventsRequest); +const Subtitle = styled('span')` + color: ${p => p.theme.gray300}; + font-size: ${p => p.theme.fontSizeMedium}; +`; +const CloseContainer = styled('div')` + display: flex; + align-items: center; + justify-content: center; +`; +const GrowLink = styled(Link)` + flex-grow: 1; +`; + +const StyledIconClose = styled(IconClose)` + cursor: pointer; + color: ${p => p.theme.gray200}; + &:hover { + color: ${p => p.theme.gray300}; + } +`; diff --git a/static/app/views/performance/vitalDetail/colorBar.tsx b/static/app/views/performance/vitalDetail/colorBar.tsx index 93d4acfc4285a7..39789688911578 100644 --- a/static/app/views/performance/vitalDetail/colorBar.tsx +++ b/static/app/views/performance/vitalDetail/colorBar.tsx @@ -10,11 +10,15 @@ type ColorStop = { type Props = { colorStops: ColorStop[]; + barHeight?: number; }; const ColorBar = (props: Props) => { return ( - percent)}> + percent)} + > {props.colorStops.map(colorStop => { return ; })} @@ -24,17 +28,18 @@ const ColorBar = (props: Props) => { type VitalBarProps = { fractions: number[]; + barHeight?: number; }; const VitalBar = styled('div')` - height: 16px; + height: ${p => (p.barHeight ? `${p.barHeight}px` : '16px')}; width: 100%; overflow: hidden; position: relative; background: ${p => p.theme.gray100}; display: grid; grid-template-columns: ${p => p.fractions.map(f => `${f}fr`).join(' ')}; - margin-bottom: ${space(1)}; + margin-bottom: ${p => (p.barHeight ? '' : space(1))}; border-radius: 2px; `; diff --git a/static/app/views/performance/vitalDetail/utils.tsx b/static/app/views/performance/vitalDetail/utils.tsx index 79c1a7b9c0109f..c8f94ce765e0cc 100644 --- a/static/app/views/performance/vitalDetail/utils.tsx +++ b/static/app/views/performance/vitalDetail/utils.tsx @@ -33,6 +33,32 @@ export enum VitalState { GOOD = 'Good', } +// For mapping field equations for the vitals widget +export function fieldToVital(field: string) { + const FIELD_TO_VITAL_MAP: Record = { + 'count_if(measurements.lcp,greaterOrEquals,0) - count_if(measurements.lcp,greaterOrEquals,2500)': + VitalState.GOOD, + 'count_if(measurements.lcp,greaterOrEquals,2500) - count_if(measurements.lcp,greaterOrEquals,4000)': + VitalState.MEH, + 'count_if(measurements.lcp,greaterOrEquals,4000)': VitalState.POOR, + }; + + return FIELD_TO_VITAL_MAP[field]; +} + +// For mapping field equations for the vitals widget +export function vitalToField(vital: VitalState) { + const VITAL_MAP: Record = { + [VitalState.GOOD]: + 'count_if(measurements.lcp,greaterOrEquals,0) - count_if(measurements.lcp,greaterOrEquals,2500)', + [VitalState.MEH]: + 'count_if(measurements.lcp,greaterOrEquals,2500) - count_if(measurements.lcp,greaterOrEquals,4000)', + [VitalState.POOR]: 'count_if(measurements.lcp,greaterOrEquals,4000)', + }; + + return VITAL_MAP[vital]; +} + export const vitalStateColors: Record = { [VitalState.POOR]: 'red300', [VitalState.MEH]: 'yellow300', diff --git a/static/app/views/performance/vitalDetail/vitalChart.tsx b/static/app/views/performance/vitalDetail/vitalChart.tsx index 94df6b04a5b78b..54ba00a2c0c7cd 100644 --- a/static/app/views/performance/vitalDetail/vitalChart.tsx +++ b/static/app/views/performance/vitalDetail/vitalChart.tsx @@ -17,6 +17,7 @@ import QuestionTooltip from 'app/components/questionTooltip'; import {IconWarning} from 'app/icons'; import {t} from 'app/locale'; import {OrganizationSummary} from 'app/types'; +import {Series} from 'app/types/echarts'; import {getUtcToLocalDateObject} from 'app/utils/dates'; import {axisLabelFormatter, tooltipFormatter} from 'app/utils/discover/charts'; import EventView from 'app/utils/discover/eventView'; @@ -27,7 +28,14 @@ import useApi from 'app/utils/useApi'; import {replaceSeriesName, transformEventStatsSmoothed} from '../trends/utils'; -import {getMaxOfSeries, vitalNameFromLocation, webVitalMeh, webVitalPoor} from './utils'; +import { + fieldToVital, + getMaxOfSeries, + vitalNameFromLocation, + vitalStateColors, + webVitalMeh, + webVitalPoor, +} from './utils'; const QUERY_KEYS = [ 'environment', @@ -271,3 +279,88 @@ function VitalChart({ } export default withRouter(VitalChart); + +export type _VitalChartProps = Props & { + data?: Series[]; + loading: boolean; + reloading: boolean; + field: string; + height?: number; + grid: LineChart['props']['grid']; +}; + +function __VitalChart(props: _VitalChartProps) { + const {field: yAxis, data: _results, loading, reloading, height, grid} = props; + if (!_results) { + return null; + } + const theme = useTheme(); + + const chartOptions = { + grid, + seriesOptions: { + showSymbol: false, + }, + tooltip: { + trigger: 'axis' as const, + valueFormatter: tooltipFormatter, + }, + xAxis: { + axisLine: { + show: false, + }, + axisTick: { + show: false, + }, + splitLine: { + show: false, + }, + }, + yAxis: { + axisLabel: { + color: theme.chartLabel, + showMaxLabel: false, + formatter: (value: number) => axisLabelFormatter(value, yAxis), + }, + }, + }; + + const results = _results.filter(r => !!fieldToVital(r.seriesName)); + + const {smoothedResults} = transformEventStatsSmoothed(results); + + const smoothedSeries = smoothedResults + ? smoothedResults.map(({seriesName, ...rest}) => { + return { + seriesName: fieldToVital(seriesName) || 'count', + ...rest, + color: theme[vitalStateColors[fieldToVital(seriesName)]], + lineStyle: { + opacity: 1, + width: 2, + }, + }; + }) + : []; + + return ( +
+ + + {getDynamicText({ + value: ( + {}} + series={[...smoothedSeries]} + /> + ), + fixed: 'Web Vitals Chart', + })} + +
+ ); +} + +export const _VitalChart = withRouter(__VitalChart); diff --git a/tests/js/spec/views/performance/landing/index.spec.tsx b/tests/js/spec/views/performance/landing/index.spec.tsx index fff86183f1564c..c6ed36484f4406 100644 --- a/tests/js/spec/views/performance/landing/index.spec.tsx +++ b/tests/js/spec/views/performance/landing/index.spec.tsx @@ -106,11 +106,13 @@ describe('Performance > Landing > Index', function () { expect(wrapper.find('Table')).toHaveLength(1); const titles = wrapper.find('div[data-test-id="performance-widget-title"]'); - expect(titles).toHaveLength(3); + expect(titles).toHaveLength(5); - expect(titles.at(0).text()).toEqual('p75 LCP'); - expect(titles.at(1).text()).toEqual('LCP Distribution'); - expect(titles.at(2).text()).toEqual('FCP Distribution'); + expect(titles.at(0).text()).toEqual('Transactions Per Minute'); + expect(titles.at(1).text()).toEqual('Most Related Errors'); + expect(titles.at(2).text()).toEqual('p75 LCP'); + expect(titles.at(3).text()).toEqual('LCP Distribution'); + expect(titles.at(4).text()).toEqual('FCP Distribution'); }); it('renders frontend other view', async function () { diff --git a/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx b/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx index 6d8b3d76757c2e..b69bf18e0bfdf6 100644 --- a/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx +++ b/tests/js/spec/views/performance/landing/widgets/widgetContainer.spec.jsx @@ -146,6 +146,50 @@ describe('Performance > Widgets > WidgetContainer', function () { ); }); + it('Worst LCP widget', async function () { + const data = initializeData(); + + const wrapper = mountWithTheme( + , + data.routerContext + ); + await tick(); + wrapper.update(); + + expect(wrapper.find('div[data-test-id="performance-widget-title"]').text()).toEqual( + 'Worst LCP Web Vitals' + ); + expect(wrapper.find('a[data-test-id="view-all-button"]').text()).toEqual('View All'); + expect(eventsV2Mock).toHaveBeenCalledTimes(1); + expect(eventsV2Mock).toHaveBeenNthCalledWith( + 1, + expect.anything(), + expect.objectContaining({ + query: expect.objectContaining({ + environment: [], + field: [ + 'transaction', + 'title', + 'project.id', + 'count_if(measurements.lcp,greaterOrEquals,4000)', + 'count_if(measurements.lcp,greaterOrEquals,2500)', + 'count_if(measurements.lcp,greaterOrEquals,0)', + 'equation|count_if(measurements.lcp,greaterOrEquals,2500) - count_if(measurements.lcp,greaterOrEquals,4000)', + 'equation|count_if(measurements.lcp,greaterOrEquals,0) - count_if(measurements.lcp,greaterOrEquals,2500)', + ], + per_page: 3, + project: [], + query: '', + sort: '-count_if(measurements.lcp,greaterOrEquals,4000)', + statsPeriod: '14d', + }), + }) + ); + }); + it('LCP Histogram Widget', async function () { const data = initializeData();