Skip to content

Commit 56b3ee2

Browse files
committed
Extract fetching main graph and remapping main graph data
1 parent cc4dd5b commit 56b3ee2

4 files changed

Lines changed: 292 additions & 209 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Metric } from '../../../types/query-api'
2+
import { DashboardState } from '../../dashboard-state'
3+
import { DashboardPeriod } from '../../dashboard-time-periods'
4+
import { PlausibleSite } from '../../site-context'
5+
import { createStatsQuery } from '../../stats-query'
6+
import { isRealTimeDashboard } from '../../util/filters'
7+
import * as api from '../../api'
8+
9+
export function fetchMainGraph(
10+
site: PlausibleSite,
11+
dashboardState: DashboardState,
12+
metric: Metric,
13+
interval: string
14+
): Promise<MainGraphResponse> {
15+
const metricToQuery =
16+
metric === 'conversion_rate' ? 'group_conversion_rate' : metric
17+
18+
const reportParams = {
19+
metrics: [metricToQuery],
20+
dimensions: [`time:${interval}`],
21+
include: {
22+
time_labels: true,
23+
time_label_result_indices: true,
24+
present_index: true,
25+
partial_time_labels: true
26+
}
27+
}
28+
29+
const statsQuery = createStatsQuery(dashboardState, reportParams)
30+
31+
if (isRealTimeDashboard(dashboardState)) {
32+
statsQuery.date_range = DashboardPeriod.realtime_30m
33+
}
34+
35+
statsQuery.include.present_index = true
36+
37+
return api.stats(site, statsQuery)
38+
}
39+
40+
type RevenueMetric = {
41+
short: string
42+
value: number
43+
long: string
44+
currency: string
45+
}
46+
47+
type ResultItem = {
48+
dimensions: [string] // one item
49+
metrics: null | [number] | [RevenueMetric] // one item
50+
}
51+
52+
export type MainGraphResponse = {
53+
results: Array<ResultItem | null>
54+
comparison_results: Array<
55+
(ResultItem & { change: [number | null] | null }) | null
56+
>
57+
meta: {
58+
partial_time_labels: string[] | null
59+
time_labels: string[]
60+
time_label_result_indices: (number | null)[]
61+
comparison_time_labels?: string[]
62+
comparison_time_label_result_indices?: (number | null)[]
63+
}
64+
query: {
65+
interval: string
66+
date_range: [string, string]
67+
comparison_date_range?: [string, string]
68+
dimensions: [string] // one item
69+
metrics: [string] // one item
70+
}
71+
}
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { Metric } from '../../../types/query-api'
2+
import { MainGraphResponse } from './fetch-main-graph'
3+
4+
/**
5+
* Fills gaps in @see MainGraphResponse the series of `results` and `comparisonResults`.
6+
* The BE doesn't return buckets in the series where the value is 0:
7+
* these need to filled by the FE to have a consistent plot.
8+
*
9+
* The assumption is that the two series each are continuously defined.
10+
*
11+
* Extracts the numeric values for the series when they are wrapped.
12+
*
13+
* In the same single loop, for the sake of efficiency, it determines
14+
* - the maximum y value (used for scaling the graph),
15+
* - the start and end labels of both series (used for generating appropriate X axis labels),
16+
* - whether there's a slice at the very end of main series that is partial (used for explaining the drop at the end of the graph).
17+
*
18+
*/
19+
export const remapAndFillData = ({
20+
data,
21+
metric
22+
}: {
23+
data: MainGraphResponse
24+
metric: Metric
25+
}): {
26+
remappedData: GraphDatum[]
27+
yMax: number
28+
mainSeriesStartEndLabels: [string | null, string | null]
29+
comparisonSeriesStartEndLabels: [string | null, string | null]
30+
startOfLastPartialSlice: null | number
31+
} => {
32+
const totalBucketCount = Math.max(
33+
data.meta.comparison_time_label_result_indices?.length ?? 0,
34+
data.meta.time_label_result_indices.length
35+
)
36+
37+
let yMax: number = 1
38+
let firstTimeLabel: null | string = null
39+
let lastTimeLabel: null | string = null
40+
41+
let firstComparisonTimeLabel: null | string = null
42+
let lastComparisonTimeLabel: null | string = null
43+
44+
let startOfLastPartialSlice: null | number = null
45+
46+
const remappedData: GraphDatum[] = new Array(totalBucketCount)
47+
.fill(null)
48+
.map((_, index) => {
49+
const [
50+
timeLabel,
51+
indexOfResult,
52+
comparisonTimeLabel,
53+
indexOfComparisonResult
54+
] = [
55+
data.meta.time_labels[index] ?? null,
56+
data.meta.time_label_result_indices[index] ?? null,
57+
(data.meta.comparison_time_labels &&
58+
data.meta.comparison_time_labels[index]) ??
59+
null,
60+
(data.meta.comparison_time_label_result_indices &&
61+
data.meta.comparison_time_label_result_indices[index]) ??
62+
null
63+
]
64+
65+
const mainSeriesDefined = typeof timeLabel === 'string'
66+
const comparisonSeriesDefined = typeof comparisonTimeLabel === 'string'
67+
68+
let isPartial: boolean | null = null
69+
let value: number | null = null
70+
71+
if (mainSeriesDefined) {
72+
isPartial = (data.meta.partial_time_labels ?? []).find(
73+
(l) => l === timeLabel
74+
)
75+
? true
76+
: false
77+
78+
if (isPartial) {
79+
startOfLastPartialSlice = index
80+
} else {
81+
// if there is a full period after a partial slice,
82+
// it's not a partial slice anchored at the end of the series
83+
startOfLastPartialSlice = null
84+
}
85+
86+
if (firstTimeLabel === null) {
87+
firstTimeLabel = timeLabel
88+
}
89+
90+
lastTimeLabel = timeLabel
91+
92+
if (indexOfResult !== null) {
93+
const row = data.results[indexOfResult]
94+
const [unparsedValue] = row!.metrics!
95+
if (unparsedValue === null) {
96+
value = 0
97+
} else if (
98+
typeof unparsedValue === 'object' &&
99+
unparsedValue.hasOwnProperty('value')
100+
) {
101+
value = unparsedValue.value
102+
} else if (typeof unparsedValue === 'number') {
103+
value = unparsedValue
104+
}
105+
} else {
106+
value = 0
107+
}
108+
}
109+
if (value !== null && value > yMax) {
110+
yMax = value
111+
}
112+
let change = null
113+
let comparisonValue = null
114+
if (comparisonSeriesDefined) {
115+
if (firstComparisonTimeLabel === null) {
116+
firstComparisonTimeLabel = comparisonTimeLabel
117+
}
118+
119+
lastComparisonTimeLabel = comparisonTimeLabel
120+
121+
if (indexOfComparisonResult !== null) {
122+
const row = data.comparison_results[indexOfComparisonResult]
123+
const [unparsedValue] = row!.metrics!
124+
125+
if (unparsedValue === null) {
126+
comparisonValue = 0
127+
} else if (
128+
typeof unparsedValue === 'object' &&
129+
unparsedValue.hasOwnProperty('value')
130+
) {
131+
comparisonValue = unparsedValue.value
132+
} else if (typeof unparsedValue === 'number') {
133+
comparisonValue = unparsedValue
134+
change = row!.change !== null ? row!.change[0] : null
135+
}
136+
} else {
137+
comparisonValue = 0
138+
}
139+
}
140+
141+
if (comparisonValue !== null && comparisonValue > yMax) {
142+
yMax = comparisonValue
143+
}
144+
145+
if (mainSeriesDefined && comparisonSeriesDefined && change === null) {
146+
change = METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS.includes(metric)
147+
? getChangeInPercentagePoints(value!, comparisonValue!)
148+
: getRelativeChange(value!, comparisonValue!)
149+
}
150+
151+
return {
152+
value,
153+
comparisonValue,
154+
timeLabel,
155+
comparisonTimeLabel,
156+
change,
157+
isPartial
158+
}
159+
})
160+
161+
return {
162+
startOfLastPartialSlice,
163+
remappedData,
164+
yMax,
165+
mainSeriesStartEndLabels: [firstTimeLabel, lastTimeLabel],
166+
comparisonSeriesStartEndLabels: [
167+
firstComparisonTimeLabel,
168+
lastComparisonTimeLabel
169+
]
170+
}
171+
}
172+
173+
const METRICS_WITH_CHANGE_IN_PERCENTAGE_POINTS = [
174+
'bounce_rate',
175+
'exit_rate',
176+
'conversion_rate',
177+
'group_conversion_rate'
178+
]
179+
180+
const getChangeInPercentagePoints = (
181+
value: number,
182+
comparisonValue: number
183+
): number => {
184+
return value - comparisonValue
185+
}
186+
187+
const getRelativeChange = (value: number, comparisonValue: number): number => {
188+
if (comparisonValue === 0 && value > 0) {
189+
return 100
190+
}
191+
if (comparisonValue === 0 && value === 0) {
192+
return 0
193+
}
194+
195+
return Math.round(((value - comparisonValue) / comparisonValue) * 100)
196+
}
197+
198+
/**
199+
* A data point for the graph and tooltip.
200+
* It's x position is its index in `GraphDatum[]` array.
201+
* The values for `value`, `comparisonValue` should be plotted on the y axis, when they are defined for the x position.
202+
*/
203+
type GraphDatum = {
204+
/** When `value` is null, it means the main series isn't defined in this x position */
205+
value: number | null
206+
timeLabel: string | null
207+
isPartial: boolean | null
208+
/** When `comparisonValue` is null, it means the comparison series isn't defined in this x position */
209+
comparisonValue?: number | null
210+
comparisonTimeLabel?: string | null
211+
change?: number | null
212+
}

0 commit comments

Comments
 (0)