mirror of
https://github.com/PostHog/posthog.git
synced 2024-11-21 13:39:22 +01:00
Add all steps option to time conversion funnel (#5142)
* add all steps option * all steps working; add total and mean time to convert * change display type checks to use enum * kea types * dangling console log * Add average conversion time to time to convert results * respond to feedabck * responsive histogram sizes * merged @Twixes backend changes; adjust data shape on frontend; add responsiveness to histogram * add tooltip label * adjust copy and tooltip * minor tweaks * respond to general feedback * kea auto * better empty state: * error handling null time bins * fix tests Co-authored-by: Michael Matloka <dev@twixes.com> Co-authored-by: Paolo D'Amico <paolodamico@users.noreply.github.com>
This commit is contained in:
parent
127e827894
commit
d9973c7e5d
@ -16,8 +16,11 @@ class ClickhouseFunnelTimeToConvert(ClickhouseFunnelBase):
|
||||
super().__init__(filter, team)
|
||||
self.funnel_order = funnel_order_class(filter, team)
|
||||
|
||||
def _format_results(self, results: list) -> list:
|
||||
return results
|
||||
def _format_results(self, results: list) -> dict:
|
||||
return {
|
||||
"bins": [(bin_from_seconds, person_count) for bin_from_seconds, person_count, _ in results],
|
||||
"average_conversion_time": results[0][2],
|
||||
}
|
||||
|
||||
def get_query(self) -> str:
|
||||
steps_per_person_query = self.funnel_order.get_step_counts_query()
|
||||
@ -68,6 +71,7 @@ class ClickhouseFunnelTimeToConvert(ClickhouseFunnelBase):
|
||||
SELECT
|
||||
floor(min({steps_average_conversion_time_expression_sum})) AS from_seconds,
|
||||
ceil(max({steps_average_conversion_time_expression_sum})) AS to_seconds,
|
||||
round(avg({steps_average_conversion_time_expression_sum}), 2) AS average_conversion_time,
|
||||
{bin_count_expression or ""}
|
||||
ceil((to_seconds - from_seconds) / {bin_count_identifier}) AS bin_width_seconds_raw,
|
||||
-- Use 60 seconds as fallback bin width in case of only one sample
|
||||
@ -82,27 +86,29 @@ class ClickhouseFunnelTimeToConvert(ClickhouseFunnelBase):
|
||||
if bin_count_expression else ""
|
||||
}
|
||||
( SELECT from_seconds FROM histogram_params ) AS histogram_from_seconds,
|
||||
( SELECT to_seconds FROM histogram_params ) AS histogram_to_seconds
|
||||
( SELECT to_seconds FROM histogram_params ) AS histogram_to_seconds,
|
||||
( SELECT average_conversion_time FROM histogram_params ) AS histogram_average_conversion_time
|
||||
SELECT
|
||||
bin_to_seconds,
|
||||
person_count
|
||||
bin_from_seconds,
|
||||
person_count,
|
||||
histogram_average_conversion_time AS average_conversion_time
|
||||
FROM (
|
||||
-- Calculating bins from step runs
|
||||
SELECT
|
||||
histogram_from_seconds + floor(({steps_average_conversion_time_expression_sum} - histogram_from_seconds) / bin_width_seconds) * bin_width_seconds AS bin_to_seconds,
|
||||
histogram_from_seconds + floor(({steps_average_conversion_time_expression_sum} - histogram_from_seconds) / bin_width_seconds) * bin_width_seconds AS bin_from_seconds,
|
||||
count() AS person_count
|
||||
FROM step_runs
|
||||
-- We only need to check step to_step here, because it depends on all the other ones being NOT NULL too
|
||||
WHERE step_{to_step}_average_conversion_time IS NOT NULL
|
||||
GROUP BY bin_to_seconds
|
||||
GROUP BY bin_from_seconds
|
||||
) results
|
||||
FULL OUTER JOIN (
|
||||
-- Making sure bin_count bins are returned
|
||||
-- Those not present in the results query due to lack of data simply get person_count 0
|
||||
SELECT histogram_from_seconds + number * bin_width_seconds AS bin_to_seconds FROM system.numbers LIMIT {bin_count_identifier} + 1
|
||||
SELECT histogram_from_seconds + number * bin_width_seconds AS bin_from_seconds FROM system.numbers LIMIT {bin_count_identifier} + 1
|
||||
) fill
|
||||
USING (bin_to_seconds)
|
||||
ORDER BY bin_to_seconds
|
||||
USING (bin_from_seconds)
|
||||
ORDER BY bin_from_seconds
|
||||
SETTINGS allow_experimental_window_functions = 1"""
|
||||
|
||||
return query
|
||||
|
@ -67,12 +67,15 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
# Autobinned using the minimum time to convert, maximum time to convert, and sample count
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
{
|
||||
"bins": [
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
"average_conversion_time": 29_540,
|
||||
},
|
||||
)
|
||||
|
||||
def test_custom_bin_count_single_step(self):
|
||||
@ -117,16 +120,19 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
# 7 bins, autoscaled to work best with minimum time to convert and maximum time to convert at hand
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 13_732 s - users A and B
|
||||
(13732.0, 0), # Analogous to above, just an interval (in this case 13_732 s) up - no users
|
||||
(25244.0, 0), # And so on
|
||||
(36756.0, 0),
|
||||
(48268.0, 0),
|
||||
(59780.0, 0),
|
||||
(71292.0, 1), # Reached step 1 from step 0 in at least 71_292 s but less than 82_804 s - user C
|
||||
(82804.0, 0),
|
||||
],
|
||||
{
|
||||
"bins": [
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 13_732 s - users A and B
|
||||
(13732.0, 0), # Analogous to above, just an interval (in this case 13_732 s) up - no users
|
||||
(25244.0, 0), # And so on
|
||||
(36756.0, 0),
|
||||
(48268.0, 0),
|
||||
(59780.0, 0),
|
||||
(71292.0, 1), # Reached step 1 from step 0 in at least 71_292 s but less than 82_804 s - user C
|
||||
(82804.0, 0),
|
||||
],
|
||||
"average_conversion_time": 29_540,
|
||||
},
|
||||
)
|
||||
|
||||
def test_auto_bin_count_total(self):
|
||||
@ -151,8 +157,6 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
"interval": "day",
|
||||
"date_from": "2021-06-07 00:00:00",
|
||||
"date_to": "2021-06-13 23:59:59",
|
||||
"funnel_from_step": 0,
|
||||
"funnel_to_step": 2,
|
||||
"funnel_window_days": 7,
|
||||
"events": [
|
||||
{"id": "step one", "order": 0},
|
||||
@ -167,42 +171,24 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(10800.0, 1), # Reached step 2 from step 0 in at least 10_800 s but less than 10_860 s - user A
|
||||
(10860.0, 0), # Analogous to above, just an interval (in this case 60 s) up - no users
|
||||
(10920.0, 0), # And so on
|
||||
(10980.0, 0),
|
||||
],
|
||||
)
|
||||
|
||||
# check with no params
|
||||
filter = Filter(
|
||||
data={
|
||||
"insight": INSIGHT_FUNNELS,
|
||||
"interval": "day",
|
||||
"date_from": "2021-06-07 00:00:00",
|
||||
"date_to": "2021-06-13 23:59:59",
|
||||
"funnel_window_days": 7,
|
||||
"events": [
|
||||
{"id": "step one", "order": 0},
|
||||
{"id": "step two", "order": 1},
|
||||
{"id": "step three", "order": 2},
|
||||
{
|
||||
"bins": [
|
||||
(10800.0, 1), # Reached step 2 from step 0 in at least 10_800 s but less than 10_860 s - user A
|
||||
(10860.0, 0), # Analogous to above, just an interval (in this case 60 s) up - no users
|
||||
(10920.0, 0), # And so on
|
||||
(10980.0, 0),
|
||||
],
|
||||
}
|
||||
"average_conversion_time": 10_800,
|
||||
},
|
||||
)
|
||||
|
||||
funnel_trends = ClickhouseFunnelTimeToConvert(filter, self.team, ClickhouseFunnel)
|
||||
results = funnel_trends.run()
|
||||
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(10800.0, 1), # Reached step 2 from step 0 in at least 10_800 s but less than 10_860 s - user A
|
||||
(10860.0, 0), # Analogous to above, just an interval (in this case 60 s) up - no users
|
||||
(10920.0, 0), # And so on
|
||||
(10980.0, 0),
|
||||
],
|
||||
# Let's verify that behavior with steps unspecified is the same as when first and last steps specified
|
||||
funnel_trends_steps_specified = ClickhouseFunnelTimeToConvert(
|
||||
Filter(data={**filter._data, "funnel_from_step": 0, "funnel_to_step": 2,}), self.team, ClickhouseFunnel
|
||||
)
|
||||
results_steps_specified = funnel_trends_steps_specified.run()
|
||||
|
||||
self.assertEqual(results, results_steps_specified)
|
||||
|
||||
def test_basic_unordered(self):
|
||||
_create_person(distinct_ids=["user a"], team=self.team)
|
||||
@ -212,12 +198,15 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
_create_event(event="step three", distinct_id="user a", team=self.team, timestamp="2021-06-08 18:00:00")
|
||||
_create_event(event="step one", distinct_id="user a", team=self.team, timestamp="2021-06-08 19:00:00")
|
||||
_create_event(event="step two", distinct_id="user a", team=self.team, timestamp="2021-06-08 21:00:00")
|
||||
# Converted from 0 to 1 in 7200 s
|
||||
|
||||
_create_event(event="step one", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:00:00")
|
||||
_create_event(event="step two", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:37:00")
|
||||
# Converted from 0 to 1 in 2200 s
|
||||
|
||||
_create_event(event="step two", distinct_id="user c", team=self.team, timestamp="2021-06-11 07:00:00")
|
||||
_create_event(event="step one", distinct_id="user c", team=self.team, timestamp="2021-06-12 06:00:00")
|
||||
# Converted from 0 to 1 in 82_800 s
|
||||
|
||||
filter = Filter(
|
||||
data={
|
||||
@ -243,12 +232,15 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
# Autobinned using the minimum time to convert, maximum time to convert, and sample count
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
{
|
||||
"bins": [
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
"average_conversion_time": 29540,
|
||||
},
|
||||
)
|
||||
|
||||
def test_basic_strict(self):
|
||||
@ -259,18 +251,22 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
|
||||
_create_event(event="step one", distinct_id="user a", team=self.team, timestamp="2021-06-08 18:00:00")
|
||||
_create_event(event="step two", distinct_id="user a", team=self.team, timestamp="2021-06-08 19:00:00")
|
||||
# Converted from 0 to 1 in 3600 s
|
||||
_create_event(event="step three", distinct_id="user a", team=self.team, timestamp="2021-06-08 21:00:00")
|
||||
|
||||
_create_event(event="step one", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:00:00")
|
||||
_create_event(event="step two", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:37:00")
|
||||
# Converted from 0 to 1 in 2200 s
|
||||
_create_event(event="blah", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:38:00")
|
||||
_create_event(event="step three", distinct_id="user b", team=self.team, timestamp="2021-06-09 13:39:00")
|
||||
|
||||
_create_event(event="step one", distinct_id="user c", team=self.team, timestamp="2021-06-11 07:00:00")
|
||||
_create_event(event="step two", distinct_id="user c", team=self.team, timestamp="2021-06-12 06:00:00")
|
||||
# Converted from 0 to 1 in 82_800 s
|
||||
|
||||
_create_event(event="step one", distinct_id="user d", team=self.team, timestamp="2021-06-11 07:00:00")
|
||||
_create_event(event="blah", distinct_id="user d", team=self.team, timestamp="2021-06-12 07:00:00")
|
||||
# Blah cancels conversion
|
||||
_create_event(event="step two", distinct_id="user d", team=self.team, timestamp="2021-06-12 09:00:00")
|
||||
|
||||
filter = Filter(
|
||||
@ -297,10 +293,13 @@ class TestFunnelTrends(ClickhouseTestMixin, APIBaseTest):
|
||||
# Autobinned using the minimum time to convert, maximum time to convert, and sample count
|
||||
self.assertEqual(
|
||||
results,
|
||||
[
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
{
|
||||
"bins": [
|
||||
(2220.0, 2), # Reached step 1 from step 0 in at least 2200 s but less than 29_080 s - users A and B
|
||||
(29080.0, 0), # Analogous to above, just an interval (in this case 26_880 s) up - no users
|
||||
(55940.0, 0), # Same as above
|
||||
(82800.0, 1), # Reached step 1 from step 0 in at least 82_800 s but less than 109_680 s - user C
|
||||
],
|
||||
"average_conversion_time": 29540,
|
||||
},
|
||||
)
|
||||
|
@ -260,7 +260,13 @@ class ClickhouseTestFunnelTypes(ClickhouseTestMixin, APIBaseTest):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(), {"result": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1],]},
|
||||
response.json(),
|
||||
{
|
||||
"result": {
|
||||
"bins": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1]],
|
||||
"average_conversion_time": 29540.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_funnel_time_to_convert_auto_bins_strict(self):
|
||||
@ -302,7 +308,13 @@ class ClickhouseTestFunnelTypes(ClickhouseTestMixin, APIBaseTest):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(), {"result": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1],]},
|
||||
response.json(),
|
||||
{
|
||||
"result": {
|
||||
"bins": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1]],
|
||||
"average_conversion_time": 29540.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_funnel_time_to_convert_auto_bins_unordered(self):
|
||||
@ -344,7 +356,13 @@ class ClickhouseTestFunnelTypes(ClickhouseTestMixin, APIBaseTest):
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
response.json(), {"result": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1],]},
|
||||
response.json(),
|
||||
{
|
||||
"result": {
|
||||
"bins": [[2220.0, 2], [29080.0, 0], [55940.0, 0], [82800.0, 1]],
|
||||
"average_conversion_time": 29540.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def test_funnel_invalid_action_handled(self):
|
||||
|
@ -11,7 +11,6 @@ export const ACTIONS_BAR_CHART = 'ActionsBar'
|
||||
export const ACTIONS_BAR_CHART_VALUE = 'ActionsBarValue'
|
||||
export const PATHS_VIZ = 'PathsViz'
|
||||
export const FUNNEL_VIZ = 'FunnelViz'
|
||||
export const FUNNELS_TIME_TO_CONVERT = 'FunnelsTimeToConvert'
|
||||
|
||||
export enum OrganizationMembershipLevel {
|
||||
Member = 1,
|
||||
|
@ -414,10 +414,10 @@ export function slugify(text: string): string {
|
||||
.replace(/--+/g, '-')
|
||||
}
|
||||
|
||||
export function humanFriendlyDuration(d: string | number | null, maxUnits?: number): string {
|
||||
export function humanFriendlyDuration(d: string | number | null | undefined, maxUnits?: number): string {
|
||||
// Convert `d` (seconds) to a human-readable duration string.
|
||||
// Example: `1d 10hrs 9mins 8s`
|
||||
if (d === '' || d === null) {
|
||||
if (d === '' || d === null || d === undefined) {
|
||||
return ''
|
||||
}
|
||||
d = Number(d)
|
||||
|
@ -1,17 +1,17 @@
|
||||
// This file contains funnel-related components that are used in the general insights scope
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { FUNNELS_TIME_TO_CONVERT, FUNNEL_VIZ } from 'lib/constants'
|
||||
import { humanFriendlyDuration } from 'lib/utils'
|
||||
import React from 'react'
|
||||
import { Button } from 'antd'
|
||||
import { Button, Tooltip } from 'antd'
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { funnelLogic } from './funnelLogic'
|
||||
import './FunnelCanvasLabel.scss'
|
||||
import { chartFilterLogic } from 'lib/components/ChartFilter/chartFilterLogic'
|
||||
import { ChartDisplayType } from '~/types'
|
||||
import { InfoCircleOutlined } from '@ant-design/icons'
|
||||
|
||||
export function FunnelCanvasLabel(): JSX.Element | null {
|
||||
const { stepsWithCount, histogramStep, totalConversionRate } = useValues(funnelLogic)
|
||||
const { stepsWithCount, histogramStep, conversionMetrics } = useValues(funnelLogic)
|
||||
const { allFilters } = useValues(insightLogic)
|
||||
const { setChartFilter } = useActions(chartFilterLogic)
|
||||
|
||||
@ -21,25 +21,35 @@ export function FunnelCanvasLabel(): JSX.Element | null {
|
||||
|
||||
return (
|
||||
<div className="funnel-canvas-label">
|
||||
{allFilters.display === FUNNEL_VIZ && (
|
||||
{allFilters.display === ChartDisplayType.FunnelViz && (
|
||||
<>
|
||||
<span className="text-muted-alt">Total conversion rate: </span>
|
||||
<span>{totalConversionRate}%</span>
|
||||
<span className="text-muted-alt">
|
||||
<Tooltip title="Overall conversion rate for all users on the entire funnel.">
|
||||
<InfoCircleOutlined style={{ marginRight: 3 }} />
|
||||
</Tooltip>
|
||||
Total conversion rate:{' '}
|
||||
</span>
|
||||
<span>{conversionMetrics.totalRate}%</span>
|
||||
<span style={{ margin: '2px 8px', borderLeft: '1px solid var(--border)' }} />
|
||||
</>
|
||||
)}
|
||||
{stepsWithCount[histogramStep]?.average_conversion_time !== null ? (
|
||||
{stepsWithCount[histogramStep.from_step]?.average_conversion_time !== null && (
|
||||
<>
|
||||
<span className="text-muted-alt">Average time to convert: </span>
|
||||
<span className="text-muted-alt">
|
||||
<Tooltip title="Average (arithmetic mean) of the total time each user spent in the enitre funnel.">
|
||||
<InfoCircleOutlined style={{ marginRight: 3 }} />
|
||||
</Tooltip>
|
||||
Average time to convert:{' '}
|
||||
</span>
|
||||
<Button
|
||||
type="link"
|
||||
disabled={allFilters.display === FUNNELS_TIME_TO_CONVERT}
|
||||
disabled={allFilters.display === ChartDisplayType.FunnelsTimeToConvert}
|
||||
onClick={() => setChartFilter(ChartDisplayType.FunnelsTimeToConvert)}
|
||||
>
|
||||
{humanFriendlyDuration(stepsWithCount[histogramStep]?.average_conversion_time)}
|
||||
{humanFriendlyDuration(conversionMetrics.averageTime)}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,12 +1,8 @@
|
||||
.funnel__header__info {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.funnel__header__steps {
|
||||
.funnel-header-steps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.funnel__header__steps__label {
|
||||
.funnel-header-steps-label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
@ -1,48 +1,54 @@
|
||||
import React from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { Col, Row, Select } from 'antd'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { humanFriendlyDuration, humanizeNumber } from 'lib/utils'
|
||||
import useSize from '@react-hook/size'
|
||||
import { ANTD_TOOLTIP_PLACEMENTS, humanFriendlyDuration, humanizeNumber } from 'lib/utils'
|
||||
import { calcPercentage, getReferenceStep } from './funnelUtils'
|
||||
import { funnelLogic } from './funnelLogic'
|
||||
import { Histogram } from 'scenes/insights/Histogram'
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { ChartDisplayType } from '~/types'
|
||||
|
||||
import './FunnelHistogram.scss'
|
||||
import { FUNNELS_TIME_TO_CONVERT } from 'lib/constants'
|
||||
|
||||
export function FunnelHistogramHeader(): JSX.Element | null {
|
||||
const { stepsWithCount, stepReference, histogramStepsDropdown } = useValues(funnelLogic)
|
||||
const { changeHistogramStep } = useActions(funnelLogic)
|
||||
const { allFilters } = useValues(insightLogic)
|
||||
|
||||
if (allFilters.display !== FUNNELS_TIME_TO_CONVERT) {
|
||||
if (allFilters.display !== ChartDisplayType.FunnelsTimeToConvert) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="funnel__header__steps">
|
||||
<span className="funnel__header__steps__label">Steps</span>
|
||||
<div className="funnel-header-steps">
|
||||
<span className="funnel-header-steps-label">Steps</span>
|
||||
{histogramStepsDropdown.length > 0 && stepsWithCount.length > 0 && (
|
||||
<Select
|
||||
defaultValue={histogramStepsDropdown[0]?.value}
|
||||
onChange={changeHistogramStep}
|
||||
defaultValue={histogramStepsDropdown[0]?.from_step}
|
||||
onChange={(from_step) => {
|
||||
changeHistogramStep(from_step, from_step + 1)
|
||||
}}
|
||||
dropdownMatchSelectWidth={false}
|
||||
dropdownAlign={ANTD_TOOLTIP_PLACEMENTS.bottomLeft}
|
||||
data-attr="funnel-bar-layout-selector"
|
||||
optionLabelProp="label"
|
||||
>
|
||||
{histogramStepsDropdown.map((option, i) => {
|
||||
const basisStep = getReferenceStep(stepsWithCount, stepReference, i)
|
||||
return (
|
||||
<Select.Option key={option?.value} value={option?.value || 1} label={<>{option?.label}</>}>
|
||||
<Select.Option key={option.from_step} value={option.from_step} label={<>{option?.label}</>}>
|
||||
<Col style={{ minWidth: 300 }}>
|
||||
<Row style={{ justifyContent: 'space-between', padding: '8px 0px' }}>
|
||||
<span className="l4">{option?.label}</span>
|
||||
<span className="text-muted-alt">
|
||||
Mean time: {humanFriendlyDuration(option?.average_conversion_time)}
|
||||
Average time: {humanFriendlyDuration(option.average_conversion_time)}
|
||||
</span>
|
||||
</Row>
|
||||
<Row className="text-muted-alt">
|
||||
Total conversion rate:{' '}
|
||||
{humanizeNumber(Math.round(calcPercentage(option.count, basisStep.count)))}%
|
||||
{humanizeNumber(Math.round(calcPercentage(option.count ?? 0, basisStep.count)))}
|
||||
%
|
||||
</Row>
|
||||
</Col>
|
||||
</Select.Option>
|
||||
@ -56,9 +62,12 @@ export function FunnelHistogramHeader(): JSX.Element | null {
|
||||
|
||||
export function FunnelHistogram(): JSX.Element {
|
||||
const { histogramGraphData, barGraphLayout } = useValues(funnelLogic)
|
||||
const ref = useRef(null)
|
||||
const [width] = useSize(ref)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Histogram data={histogramGraphData} layout={barGraphLayout} />
|
||||
</>
|
||||
<div ref={ref}>
|
||||
<Histogram data={histogramGraphData} layout={barGraphLayout} width={width} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -147,7 +147,7 @@ export function FunnelViz({
|
||||
) : null
|
||||
}
|
||||
if (featureFlags[FEATURE_FLAGS.FUNNEL_BAR_VIZ] && filters.display == ChartDisplayType.FunnelsTimeToConvert) {
|
||||
return timeConversionBins && timeConversionBins.length > 0 ? <FunnelHistogram /> : null
|
||||
return timeConversionBins?.bins?.length > 0 ? <FunnelHistogram /> : null
|
||||
}
|
||||
|
||||
if (featureFlags[FEATURE_FLAGS.FUNNEL_BAR_VIZ]) {
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { isBreakpoint, kea } from 'kea'
|
||||
import { kea, isBreakpoint } from 'kea'
|
||||
import api from 'lib/api'
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { autocorrectInterval, objectsEqual, uuid } from 'lib/utils'
|
||||
@ -10,22 +10,26 @@ import { funnelLogicType } from './funnelLogicType'
|
||||
import {
|
||||
EntityTypes,
|
||||
FilterType,
|
||||
FunnelStep,
|
||||
ChartDisplayType,
|
||||
FunnelResult,
|
||||
FunnelStep,
|
||||
FunnelsTimeConversionBins,
|
||||
FunnelTimeConversionStep,
|
||||
PathType,
|
||||
PersonType,
|
||||
ViewType,
|
||||
FunnelStepWithNestedBreakdown,
|
||||
FunnelTimeConversionMetrics,
|
||||
FunnelRequestParams,
|
||||
LoadedRawFunnelResults,
|
||||
} from '~/types'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
import { FEATURE_FLAGS, FunnelLayout } from 'lib/constants'
|
||||
import { preflightLogic } from 'scenes/PreflightCheck/logic'
|
||||
import { FunnelStepReference } from 'scenes/insights/InsightTabs/FunnelTab/FunnelStepReferencePicker'
|
||||
import { eventDefinitionsModel } from '~/models/eventDefinitionsModel'
|
||||
import { calcPercentage } from './funnelUtils'
|
||||
import { calcPercentage, cleanBinResult, getLastFilledStep, getReferenceStep } from './funnelUtils'
|
||||
import { personsModalLogic } from 'scenes/trends/personsModalLogic'
|
||||
|
||||
function aggregateBreakdownResult(breakdownList: FunnelStep[][]): FunnelStepWithNestedBreakdown[] {
|
||||
if (breakdownList.length) {
|
||||
return breakdownList[0].map((step, i) => ({
|
||||
@ -53,21 +57,13 @@ function wait(ms = 1000): Promise<any> {
|
||||
}
|
||||
|
||||
const SECONDS_TO_POLL = 3 * 60
|
||||
interface FunnelRequestParams extends FilterType {
|
||||
refresh?: boolean
|
||||
from_dashboard?: boolean
|
||||
funnel_window_days?: number
|
||||
}
|
||||
interface TimeStepOption {
|
||||
label: string
|
||||
value: number
|
||||
average_conversion_time: number
|
||||
count: number
|
||||
}
|
||||
|
||||
interface LoadedRawFunnelResults {
|
||||
results: FunnelStep[] | FunnelStep[][]
|
||||
timeConversionBins: [number, number][]
|
||||
const EMPTY_FUNNEL_RESULTS = {
|
||||
results: [],
|
||||
timeConversionResults: {
|
||||
bins: [],
|
||||
average_conversion_time: 0,
|
||||
},
|
||||
}
|
||||
|
||||
async function pollFunnel<T = FunnelStep[]>(apiParams: FunnelRequestParams): Promise<FunnelResult<T>> {
|
||||
@ -110,7 +106,7 @@ export const cleanFunnelParams = (filters: Partial<FilterType>): FilterType => {
|
||||
const isStepsEmpty = (filters: FilterType): boolean =>
|
||||
[...(filters.actions || []), ...(filters.events || [])].length === 0
|
||||
|
||||
export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepOption>>({
|
||||
export const funnelLogic = kea<funnelLogicType>({
|
||||
key: (props) => {
|
||||
return props.dashboardItemId || 'some_funnel'
|
||||
},
|
||||
@ -136,7 +132,8 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
}),
|
||||
setStepReference: (stepReference: FunnelStepReference) => ({ stepReference }),
|
||||
setBarGraphLayout: (barGraphLayout: FunnelLayout) => ({ barGraphLayout }),
|
||||
changeHistogramStep: (histogramStep: number) => ({ histogramStep }),
|
||||
setTimeConversionBins: (timeConversionBins: FunnelsTimeConversionBins) => ({ timeConversionBins }),
|
||||
changeHistogramStep: (from_step: number, to_step: number) => ({ from_step, to_step }),
|
||||
setIsGroupingOutliers: (isGroupingOutliers) => ({ isGroupingOutliers }),
|
||||
}),
|
||||
|
||||
@ -147,12 +144,18 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
|
||||
loaders: ({ props, values }) => ({
|
||||
rawResults: [
|
||||
{ results: [], timeConversionBins: [] } as LoadedRawFunnelResults,
|
||||
EMPTY_FUNNEL_RESULTS as LoadedRawFunnelResults,
|
||||
{
|
||||
loadResults: async (refresh = false, breakpoint): Promise<LoadedRawFunnelResults> => {
|
||||
if (props.cachedResults && !refresh && values.filters === props.filters) {
|
||||
// TODO: cache timeConversionBins? how does this cachedResults work?
|
||||
return { results: props.cachedResults as FunnelStep[] | FunnelStep[][], timeConversionBins: [] }
|
||||
return {
|
||||
results: props.cachedResults as FunnelStep[] | FunnelStep[][],
|
||||
timeConversionResults: {
|
||||
bins: [],
|
||||
average_conversion_time: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { apiParams, eventCount, actionCount, interval, histogramStep, filters } = values
|
||||
@ -178,36 +181,44 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBinsResults(): Promise<[number, number][]> {
|
||||
async function loadBinsResults(): Promise<FunnelsTimeConversionBins> {
|
||||
if (filters.display === ChartDisplayType.FunnelsTimeToConvert) {
|
||||
try {
|
||||
const binsResult = await pollFunnel<[number, number][]>({
|
||||
// API specs (#5110) require neither funnel_{from|to}_step to be provided if querying
|
||||
// for all steps
|
||||
const isAllSteps = values.histogramStep.from_step === -1
|
||||
|
||||
const binsResult = await pollFunnel<FunnelsTimeConversionBins>({
|
||||
...apiParams,
|
||||
...(refresh ? { refresh } : {}),
|
||||
funnel_viz_type: 'time_to_convert',
|
||||
funnel_to_step: histogramStep,
|
||||
...(!isAllSteps ? { funnel_from_step: histogramStep.from_step } : {}),
|
||||
...(!isAllSteps ? { funnel_to_step: histogramStep.to_step } : {}),
|
||||
})
|
||||
return binsResult.result
|
||||
return cleanBinResult(binsResult.result)
|
||||
} catch (e) {
|
||||
throw new Error('Could not load funnel time conversion bins')
|
||||
}
|
||||
}
|
||||
return []
|
||||
return EMPTY_FUNNEL_RESULTS.timeConversionResults
|
||||
}
|
||||
|
||||
const queryId = uuid()
|
||||
insightLogic.actions.startQuery(queryId)
|
||||
try {
|
||||
const [result, timeConversionBins] = await Promise.all([loadFunnelResults(), loadBinsResults()])
|
||||
const [result, timeConversionResults] = await Promise.all([
|
||||
loadFunnelResults(),
|
||||
loadBinsResults(),
|
||||
])
|
||||
breakpoint()
|
||||
insightLogic.actions.endQuery(queryId, ViewType.FUNNELS, result.last_refresh)
|
||||
|
||||
return { results: result.result, timeConversionBins }
|
||||
return { results: result.result, timeConversionResults }
|
||||
} catch (e) {
|
||||
if (!isBreakpoint(e)) {
|
||||
insightLogic.actions.endQuery(queryId, ViewType.FUNNELS, null, e)
|
||||
}
|
||||
return { results: [], timeConversionBins: [] }
|
||||
return EMPTY_FUNNEL_RESULTS
|
||||
}
|
||||
},
|
||||
},
|
||||
@ -254,10 +265,19 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
setBarGraphLayout: (_, { barGraphLayout }) => barGraphLayout,
|
||||
},
|
||||
],
|
||||
histogramStep: [
|
||||
1,
|
||||
timeConversionBins: [
|
||||
{
|
||||
changeHistogramStep: (_, { histogramStep }) => histogramStep,
|
||||
bins: [] as [number, number][],
|
||||
average_conversion_time: 0,
|
||||
},
|
||||
{
|
||||
setTimeConversionBins: (_, { timeConversionBins }) => timeConversionBins,
|
||||
},
|
||||
],
|
||||
histogramStep: [
|
||||
{ from_step: -1, to_step: -1 } as FunnelTimeConversionStep,
|
||||
{
|
||||
changeHistogramStep: (_, { from_step, to_step }) => ({ from_step, to_step }),
|
||||
},
|
||||
],
|
||||
isGroupingOutliers: [
|
||||
@ -271,7 +291,7 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
selectors: ({ props, selectors }) => ({
|
||||
isLoading: [(s) => [s.rawResultsLoading], (rawResultsLoading) => rawResultsLoading],
|
||||
results: [(s) => [s.rawResults], (rawResults) => rawResults.results],
|
||||
timeConversionBins: [(s) => [s.rawResults], (rawResults) => rawResults.timeConversionBins],
|
||||
timeConversionBins: [(s) => [s.rawResults], (rawResults) => rawResults.timeConversionResults],
|
||||
peopleSorted: [
|
||||
() => [selectors.stepsWithCount, selectors.people],
|
||||
(steps, people) => {
|
||||
@ -291,10 +311,10 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
propertiesForUrl: [() => [selectors.filters], (filters: FilterType) => cleanFunnelParams(filters)],
|
||||
isValidFunnel: [
|
||||
() => [selectors.stepsWithCount, selectors.timeConversionBins],
|
||||
(stepsWithCount: FunnelStep[], timeConversionBins: [number, number][]) => {
|
||||
(stepsWithCount: FunnelStep[], timeConversionBins: FunnelsTimeConversionBins) => {
|
||||
return (
|
||||
(stepsWithCount && stepsWithCount[0] && stepsWithCount[0].count > -1) ||
|
||||
timeConversionBins?.length > 0
|
||||
timeConversionBins?.bins?.length > 0
|
||||
)
|
||||
},
|
||||
],
|
||||
@ -311,12 +331,12 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
],
|
||||
histogramGraphData: [
|
||||
() => [selectors.timeConversionBins],
|
||||
(timeConversionBins) => {
|
||||
if (timeConversionBins.length < 2) {
|
||||
(timeConversionBins: FunnelsTimeConversionBins) => {
|
||||
if (timeConversionBins?.bins.length < 2) {
|
||||
return []
|
||||
}
|
||||
const binSize = timeConversionBins[1][0] - timeConversionBins[0][0]
|
||||
return timeConversionBins.map(([id, count]: [id: number, count: number]) => {
|
||||
const binSize = timeConversionBins.bins[1][0] - timeConversionBins.bins[0][0]
|
||||
return timeConversionBins.bins.map(([id, count]: [id: number, count: number]) => {
|
||||
const value = Math.max(0, id)
|
||||
return {
|
||||
id: value,
|
||||
@ -328,14 +348,26 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
},
|
||||
],
|
||||
histogramStepsDropdown: [
|
||||
() => [selectors.stepsWithCount],
|
||||
(stepsWithCount) => {
|
||||
const stepsDropdown: TimeStepOption[] = []
|
||||
() => [selectors.stepsWithCount, selectors.conversionMetrics],
|
||||
(stepsWithCount, conversionMetrics) => {
|
||||
const stepsDropdown: FunnelTimeConversionStep[] = []
|
||||
|
||||
if (stepsWithCount.length > 1) {
|
||||
stepsDropdown.push({
|
||||
label: `All steps`,
|
||||
from_step: -1,
|
||||
to_step: -1,
|
||||
count: stepsWithCount[stepsWithCount.length - 1].count,
|
||||
average_conversion_time: conversionMetrics.averageTime,
|
||||
})
|
||||
}
|
||||
|
||||
stepsWithCount.forEach((_, idx) => {
|
||||
if (stepsWithCount[idx + 1]) {
|
||||
stepsDropdown.push({
|
||||
label: `Steps ${idx + 1} and ${idx + 2}`,
|
||||
value: idx + 1,
|
||||
from_step: idx,
|
||||
to_step: idx + 1,
|
||||
count: stepsWithCount[idx + 1].count,
|
||||
average_conversion_time: stepsWithCount[idx + 1].average_conversion_time ?? 0,
|
||||
})
|
||||
@ -344,19 +376,36 @@ export const funnelLogic = kea<funnelLogicType<LoadedRawFunnelResults, TimeStepO
|
||||
return stepsDropdown
|
||||
},
|
||||
],
|
||||
totalConversionRate: [
|
||||
() => [selectors.stepsWithCount],
|
||||
(stepsWithCount) =>
|
||||
stepsWithCount.length > 1
|
||||
? calcPercentage(stepsWithCount[stepsWithCount.length - 1].count, stepsWithCount[0].count)
|
||||
: 0,
|
||||
],
|
||||
areFiltersValid: [
|
||||
() => [selectors.filters],
|
||||
(filters) => {
|
||||
return (filters.events?.length || 0) + (filters.actions?.length || 0) > 1
|
||||
},
|
||||
],
|
||||
conversionMetrics: [
|
||||
() => [selectors.stepsWithCount, selectors.histogramStep],
|
||||
(stepsWithCount, timeStep): FunnelTimeConversionMetrics => {
|
||||
if (stepsWithCount.length <= 1) {
|
||||
return {
|
||||
averageTime: 0,
|
||||
stepRate: 0,
|
||||
totalRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const isAllSteps = timeStep.from_step === -1
|
||||
const fromStep = isAllSteps
|
||||
? getReferenceStep(stepsWithCount, FunnelStepReference.total)
|
||||
: stepsWithCount[timeStep.from_step]
|
||||
const toStep = isAllSteps ? getLastFilledStep(stepsWithCount) : stepsWithCount[timeStep.to_step]
|
||||
|
||||
return {
|
||||
averageTime: toStep?.average_conversion_time || 0,
|
||||
stepRate: calcPercentage(toStep.count, fromStep.count),
|
||||
totalRate: calcPercentage(stepsWithCount[stepsWithCount.length - 1].count, stepsWithCount[0].count),
|
||||
}
|
||||
},
|
||||
],
|
||||
apiParams: [
|
||||
(s) => [s.filters, s.conversionWindowInDays, featureFlagLogic.selectors.featureFlags],
|
||||
(filters, conversionWindowInDays, featureFlags) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { humanizeNumber } from 'lib/utils'
|
||||
import { FunnelStepReference } from 'scenes/insights/InsightTabs/FunnelTab/FunnelStepReferencePicker'
|
||||
import { getChartColors } from 'lib/colors'
|
||||
import { FunnelStep } from '~/types'
|
||||
import { FunnelStep, FunnelsTimeConversionBins } from '~/types'
|
||||
|
||||
export function calcPercentage(numerator: number, denominator: number): number {
|
||||
// Rounds to two decimal places
|
||||
@ -23,6 +23,18 @@ export function getReferenceStep<T>(steps: T[], stepReference: FunnelStepReferen
|
||||
}
|
||||
}
|
||||
|
||||
// Gets last filled step if steps[index] is empty.
|
||||
// Useful in calculating total and average times for total conversions where the last step has 0 count
|
||||
export function getLastFilledStep(steps: FunnelStep[], index?: number): FunnelStep {
|
||||
const firstIndex = Math.min(steps.length, Math.max(0, index || steps.length - 1)) + 1
|
||||
return (
|
||||
steps
|
||||
.slice(0, firstIndex)
|
||||
.reverse()
|
||||
.find((s) => s.count > 0) || steps[0]
|
||||
)
|
||||
}
|
||||
|
||||
export function humanizeOrder(order: number): number {
|
||||
return order + 1
|
||||
}
|
||||
@ -61,3 +73,11 @@ export function getSeriesPositionName(
|
||||
export function humanizeStepCount(count: number): string {
|
||||
return count > 9999 ? humanizeNumber(count, 2) : count.toLocaleString()
|
||||
}
|
||||
|
||||
export function cleanBinResult(binsResult: FunnelsTimeConversionBins): FunnelsTimeConversionBins {
|
||||
return {
|
||||
...binsResult,
|
||||
bins: binsResult.bins.map(([time, count]) => [time ?? 0, count ?? 0]),
|
||||
average_conversion_time: binsResult.average_conversion_time ?? 0,
|
||||
}
|
||||
}
|
||||
|
@ -1,16 +1,11 @@
|
||||
.histogram-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
min-height: 600px;
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
vertical-align: top;
|
||||
overflow: hidden;
|
||||
|
||||
svg {
|
||||
height: 100%;
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin-left: 0;
|
||||
|
@ -1,12 +1,14 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { D3Selector, useD3, getOrCreateEl, animate, D3Transition } from 'lib/hooks/useD3'
|
||||
import { FunnelLayout } from 'lib/constants'
|
||||
import { getChartColors } from 'lib/colors'
|
||||
import { getConfig, createRoundedRectPath } from './histogramUtils'
|
||||
import { createRoundedRectPath, getConfig, INITIAL_CONFIG } from './histogramUtils'
|
||||
|
||||
import './Histogram.scss'
|
||||
import { humanFriendlyDuration } from 'lib/utils'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { histogramLogic } from 'scenes/insights/Histogram/histogramLogic'
|
||||
|
||||
export interface HistogramDatum {
|
||||
id: string | number
|
||||
@ -20,22 +22,25 @@ interface HistogramProps {
|
||||
layout?: FunnelLayout
|
||||
color?: string
|
||||
isAnimated?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export function Histogram({
|
||||
data,
|
||||
layout = FunnelLayout.vertical,
|
||||
width = INITIAL_CONFIG.width,
|
||||
height = INITIAL_CONFIG.height,
|
||||
color = 'white',
|
||||
isAnimated = false,
|
||||
}: HistogramProps): JSX.Element {
|
||||
const { config } = useValues(histogramLogic)
|
||||
const { setConfig } = useActions(histogramLogic)
|
||||
const colorList = getChartColors(color)
|
||||
const isEmpty = data.length === 0 || d3.sum(data.map((d) => d.count)) === 0
|
||||
|
||||
// TODO: All D3 state outside of useD3 hook will be moved into separate kea histogramLogic
|
||||
|
||||
const isVertical = layout === FunnelLayout.vertical
|
||||
const config = getConfig(isVertical)
|
||||
|
||||
// Initialize x-axis and y-axis scales
|
||||
const xMin = data?.[0]?.bin0 || 0
|
||||
const xMax = data?.[data.length - 1]?.bin1 || 1
|
||||
@ -67,17 +72,30 @@ export function Histogram({
|
||||
// y-axis gridline scale
|
||||
const yAxisGrid = config.axisFn.y(y).tickSize(-config.gridlineTickSize).tickFormat('').ticks(y.ticks().length)
|
||||
|
||||
// Update config to new values if dimensions change
|
||||
useEffect(() => {
|
||||
setConfig(getConfig(layout, width, height))
|
||||
}, [width, height])
|
||||
|
||||
const ref = useD3(
|
||||
(container) => {
|
||||
const renderCanvas = (parentNode: D3Selector): D3Selector => {
|
||||
// Update config to reflect dimension changes
|
||||
x.range(config.ranges.x)
|
||||
y.range(config.ranges.y)
|
||||
yAxisGrid.tickSize(-config.gridlineTickSize)
|
||||
|
||||
// Get or create svg > g
|
||||
const _svg = getOrCreateEl(parentNode, 'svg > g', () =>
|
||||
parentNode
|
||||
.append('svg:svg')
|
||||
.attr('viewBox', `0 0 ${config.width} ${config.height}`)
|
||||
.attr('viewBox', `0 0 ${config.inner.width} ${config.inner.height}`)
|
||||
.attr('width', '100%')
|
||||
.append('svg:g')
|
||||
.classed(layout, true)
|
||||
)
|
||||
// update dimensions
|
||||
parentNode.select('svg').attr('viewBox', `0 0 ${config.width} ${config.height}`)
|
||||
|
||||
// if class doesn't exist on svg>g, layout has changed. after we learn this, reset
|
||||
// the layout
|
||||
@ -96,7 +114,7 @@ export function Histogram({
|
||||
.join('path')
|
||||
.call(animate, config.transitionDuration, isAnimated, (it: D3Transition) => {
|
||||
return it.attr('d', (d: HistogramDatum) => {
|
||||
if (isVertical) {
|
||||
if (layout === FunnelLayout.vertical) {
|
||||
return createRoundedRectPath(
|
||||
x(d.bin0) + config.spacing.btwnBins / 2,
|
||||
y(d.count),
|
||||
@ -169,7 +187,7 @@ export function Histogram({
|
||||
|
||||
renderCanvas(container)
|
||||
},
|
||||
[data, layout]
|
||||
[data, layout, config]
|
||||
)
|
||||
|
||||
return <div className="histogram-container" ref={ref} />
|
||||
|
18
frontend/src/scenes/insights/Histogram/histogramLogic.ts
Normal file
18
frontend/src/scenes/insights/Histogram/histogramLogic.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { kea } from 'kea'
|
||||
import { getConfig, HistogramConfig } from 'scenes/insights/Histogram/histogramUtils'
|
||||
import { histogramLogicType } from './histogramLogicType'
|
||||
import { FunnelLayout } from 'lib/constants'
|
||||
|
||||
export const histogramLogic = kea<histogramLogicType>({
|
||||
actions: {
|
||||
setConfig: (config: HistogramConfig) => ({ config }),
|
||||
},
|
||||
reducers: {
|
||||
config: [
|
||||
getConfig(FunnelLayout.vertical),
|
||||
{
|
||||
setConfig: (state, { config }) => ({ ...state, ...config }),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
@ -1,6 +1,7 @@
|
||||
import * as d3 from 'd3'
|
||||
import { FunnelLayout } from 'lib/constants'
|
||||
|
||||
interface HistogramConfig {
|
||||
export interface HistogramConfig {
|
||||
height: number
|
||||
width: number
|
||||
inner: { height: number; width: number }
|
||||
@ -26,41 +27,45 @@ export const INITIAL_CONFIG = {
|
||||
},
|
||||
}
|
||||
|
||||
export const getConfig = (isVertical: boolean): HistogramConfig => ({
|
||||
...INITIAL_CONFIG,
|
||||
inner: {
|
||||
height: INITIAL_CONFIG.height - INITIAL_CONFIG.margin.bottom - INITIAL_CONFIG.margin.top,
|
||||
width: INITIAL_CONFIG.width - INITIAL_CONFIG.margin.left - INITIAL_CONFIG.margin.right,
|
||||
},
|
||||
ranges: {
|
||||
x: isVertical
|
||||
? [INITIAL_CONFIG.margin.left, INITIAL_CONFIG.width - INITIAL_CONFIG.margin.right]
|
||||
: [INITIAL_CONFIG.margin.top, INITIAL_CONFIG.height - INITIAL_CONFIG.margin.bottom],
|
||||
y: isVertical
|
||||
? [INITIAL_CONFIG.height - INITIAL_CONFIG.margin.bottom, INITIAL_CONFIG.margin.top]
|
||||
: [INITIAL_CONFIG.margin.left, INITIAL_CONFIG.width - INITIAL_CONFIG.margin.right],
|
||||
},
|
||||
gridlineTickSize: isVertical
|
||||
? INITIAL_CONFIG.width -
|
||||
INITIAL_CONFIG.margin.left +
|
||||
INITIAL_CONFIG.spacing.yLabel * 2.5 -
|
||||
INITIAL_CONFIG.margin.right
|
||||
: INITIAL_CONFIG.height - INITIAL_CONFIG.margin.bottom - INITIAL_CONFIG.margin.top,
|
||||
transforms: {
|
||||
x: isVertical
|
||||
? `translate(0,${INITIAL_CONFIG.height - INITIAL_CONFIG.margin.bottom})`
|
||||
: `translate(${INITIAL_CONFIG.margin.left},0)`,
|
||||
y: isVertical ? `translate(${INITIAL_CONFIG.margin.left},0)` : `translate(0,${INITIAL_CONFIG.margin.top})`,
|
||||
yGrid: isVertical
|
||||
? `translate(${INITIAL_CONFIG.margin.left - INITIAL_CONFIG.spacing.yLabel * 2.5},0)`
|
||||
: `translate(0,${INITIAL_CONFIG.margin.top})`,
|
||||
},
|
||||
axisFn: {
|
||||
x: isVertical ? d3.axisBottom : d3.axisLeft,
|
||||
y: isVertical ? d3.axisLeft : d3.axisTop,
|
||||
},
|
||||
})
|
||||
export const getConfig = (layout: FunnelLayout, width?: number, height?: number): HistogramConfig => {
|
||||
const _width = width || INITIAL_CONFIG.width,
|
||||
_height = height || INITIAL_CONFIG.height
|
||||
const isVertical = layout === FunnelLayout.vertical
|
||||
|
||||
return {
|
||||
...INITIAL_CONFIG,
|
||||
height: _height,
|
||||
width: _width,
|
||||
inner: {
|
||||
height: _height - INITIAL_CONFIG.margin.bottom - INITIAL_CONFIG.margin.top,
|
||||
width: _width - INITIAL_CONFIG.margin.left - INITIAL_CONFIG.margin.right,
|
||||
},
|
||||
ranges: {
|
||||
x: isVertical
|
||||
? [INITIAL_CONFIG.margin.left, _width - INITIAL_CONFIG.margin.right]
|
||||
: [INITIAL_CONFIG.margin.top, _height - INITIAL_CONFIG.margin.bottom],
|
||||
y: isVertical
|
||||
? [_height - INITIAL_CONFIG.margin.bottom, INITIAL_CONFIG.margin.top]
|
||||
: [INITIAL_CONFIG.margin.left, _width - INITIAL_CONFIG.margin.right],
|
||||
},
|
||||
gridlineTickSize: isVertical
|
||||
? _width - INITIAL_CONFIG.margin.left + INITIAL_CONFIG.spacing.yLabel * 2.5 - INITIAL_CONFIG.margin.right
|
||||
: _height - INITIAL_CONFIG.margin.bottom - INITIAL_CONFIG.margin.top,
|
||||
transforms: {
|
||||
x: isVertical
|
||||
? `translate(0,${_height - INITIAL_CONFIG.margin.bottom})`
|
||||
: `translate(${INITIAL_CONFIG.margin.left},0)`,
|
||||
y: isVertical ? `translate(${INITIAL_CONFIG.margin.left},0)` : `translate(0,${INITIAL_CONFIG.margin.top})`,
|
||||
yGrid: isVertical
|
||||
? `translate(${INITIAL_CONFIG.margin.left - INITIAL_CONFIG.spacing.yLabel * 2.5},0)`
|
||||
: `translate(0,${INITIAL_CONFIG.margin.top})`,
|
||||
},
|
||||
axisFn: {
|
||||
x: isVertical ? d3.axisBottom : d3.axisLeft,
|
||||
y: isVertical ? d3.axisLeft : d3.axisTop,
|
||||
},
|
||||
}
|
||||
}
|
||||
// Shamelessly inspired by https://gist.github.com/skokenes/6fa266f4f50c86f77ceabcd6dfca9e42
|
||||
export const createRoundedRectPath = (
|
||||
x: number,
|
||||
@ -137,3 +142,12 @@ export const createRoundedRectPath = (
|
||||
'z'
|
||||
)
|
||||
}
|
||||
|
||||
export const HISTOGRAM_WIDTH_BREAKPOINTS = [
|
||||
{ width: 400, value: 1 },
|
||||
{
|
||||
width: 700,
|
||||
value: 4 / 3,
|
||||
},
|
||||
{ width: 1000, value: 5 / 3 },
|
||||
]
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
ACTIONS_PIE_CHART,
|
||||
ACTIONS_TABLE,
|
||||
FEATURE_FLAGS,
|
||||
FUNNELS_TIME_TO_CONVERT,
|
||||
} from 'lib/constants'
|
||||
import React from 'react'
|
||||
import { ChartDisplayType, FilterType, ViewType } from '~/types'
|
||||
@ -116,7 +115,7 @@ export function InsightDisplayConfig({
|
||||
|
||||
{activeView === ViewType.RETENTION && <RetentionDatePicker />}
|
||||
|
||||
{showFunnelBarOptions && allFilters.display !== FUNNELS_TIME_TO_CONVERT && (
|
||||
{showFunnelBarOptions && allFilters.display !== ChartDisplayType.FunnelsTimeToConvert && (
|
||||
<>
|
||||
<FunnelDisplayLayoutPicker />
|
||||
<FunnelStepReferencePicker />
|
||||
|
@ -617,8 +617,9 @@ export interface FilterType {
|
||||
filter_test_accounts?: boolean
|
||||
from_dashboard?: boolean
|
||||
funnel_step?: number
|
||||
funnel_viz_type?: string // this and the below param is used for funnels time to convert, it'll be updated soon
|
||||
funnel_to_step?: number
|
||||
funnel_viz_type?: string // parameter sent to funnels API for time conversion code path
|
||||
funnel_from_step?: number // used in time to convert: initial step index to compute time to convert
|
||||
funnel_to_step?: number // used in time to convert: ending step index to compute time to convert
|
||||
compare?: boolean
|
||||
}
|
||||
|
||||
@ -720,6 +721,44 @@ export interface FunnelResult<ResultType = FunnelStep[]> {
|
||||
type: 'Funnel'
|
||||
}
|
||||
|
||||
export interface FunnelsTimeConversionBins {
|
||||
bins: [number, number][] | []
|
||||
average_conversion_time: number
|
||||
}
|
||||
|
||||
export interface FunnelsTimeConversionResult {
|
||||
result: FunnelsTimeConversionBins
|
||||
last_refresh: string | null
|
||||
is_cached: boolean
|
||||
type: 'Funnel'
|
||||
}
|
||||
|
||||
// Indexing boundaries = [from_step, to_step)
|
||||
export interface FunnelTimeConversionStep {
|
||||
from_step: number // set this to -1 if querying for all steps
|
||||
to_step: number
|
||||
label?: string
|
||||
average_conversion_time?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
export interface FunnelTimeConversionMetrics {
|
||||
averageTime: number
|
||||
stepRate: number
|
||||
totalRate: number
|
||||
}
|
||||
|
||||
export interface FunnelRequestParams extends FilterType {
|
||||
refresh?: boolean
|
||||
from_dashboard?: boolean
|
||||
funnel_window_days?: number
|
||||
}
|
||||
|
||||
export interface LoadedRawFunnelResults {
|
||||
results: FunnelStep[] | FunnelStep[][]
|
||||
timeConversionResults: FunnelsTimeConversionBins
|
||||
}
|
||||
|
||||
export interface ChartParams {
|
||||
dashboardItemId?: number
|
||||
color?: string
|
||||
|
@ -48,6 +48,7 @@
|
||||
"@posthog/react-rrweb-player": "1.1.3-beta",
|
||||
"@posthog/rrweb": "^0.9.15-beta",
|
||||
"@posthog/simmerjs": "0.7.4",
|
||||
"@react-hook/size": "^2.1.2",
|
||||
"@sentry/browser": "^6.0.4",
|
||||
"@types/md5": "^2.3.0",
|
||||
"@types/react-virtualized": "^9.21.11",
|
||||
|
50
yarn.lock
50
yarn.lock
@ -1259,6 +1259,11 @@
|
||||
"@types/yargs" "^15.0.0"
|
||||
chalk "^4.0.0"
|
||||
|
||||
"@juggle/resize-observer@^3.3.1":
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
|
||||
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
|
||||
|
||||
"@mdx-js/react@^1.0.0":
|
||||
version "1.6.22"
|
||||
resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-1.6.22.tgz#ae09b4744fddc74714ee9f9d6f17a66e77c43573"
|
||||
@ -1347,6 +1352,35 @@
|
||||
lodash.takeright "^4.1.1"
|
||||
query-selector-shadow-dom "0.8.0"
|
||||
|
||||
"@react-hook/latest@^1.0.2":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
|
||||
integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==
|
||||
|
||||
"@react-hook/passive-layout-effect@^1.2.0":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz#c06dac2d011f36d61259aa1c6df4f0d5e28bc55e"
|
||||
integrity sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==
|
||||
|
||||
"@react-hook/resize-observer@^1.2.1":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-hook/resize-observer/-/resize-observer-1.2.2.tgz#7eaf3151557a68f94fdcfd4666ada9948a45e743"
|
||||
integrity sha512-F7ciucmLrF4vZM78sUpRquvMCSVJwu3ayxapsHKKzTOvixZtcwJZocDBOasQQIbd7DtXzD/sVEJCKJUC0X1MDA==
|
||||
dependencies:
|
||||
"@juggle/resize-observer" "^3.3.1"
|
||||
"@react-hook/latest" "^1.0.2"
|
||||
"@react-hook/passive-layout-effect" "^1.2.0"
|
||||
"@types/raf-schd" "^4.0.0"
|
||||
raf-schd "^4.0.2"
|
||||
|
||||
"@react-hook/size@^2.1.2":
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@react-hook/size/-/size-2.1.2.tgz#87ed634ffb200f65d3e823501e5559aa3d584451"
|
||||
integrity sha512-BmE5asyRDxSuQ9p14FUKJ0iBRgV9cROjqNG9jT/EjCM+xHha1HVqbPoT+14FQg1K7xIydabClCibUY4+1tw/iw==
|
||||
dependencies:
|
||||
"@react-hook/passive-layout-effect" "^1.2.0"
|
||||
"@react-hook/resize-observer" "^1.2.1"
|
||||
|
||||
"@sentry/browser@^6.0.4":
|
||||
version "6.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-6.0.4.tgz#f31c0a9e7b22638cff9da70aa96c7934a18a2059"
|
||||
@ -1958,6 +1992,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.4.tgz#15925414e0ad2cd765bfef58842f7e26a7accb24"
|
||||
integrity sha512-1HcDas8SEj4z1Wc696tH56G8OlRaH/sqZOynNNB+HF0WOeXPaxTtbYzJY2oEfiUxjSKjhCKr+MvR7dCHcEelug==
|
||||
|
||||
"@types/raf-schd@^4.0.0":
|
||||
version "4.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/raf-schd/-/raf-schd-4.0.1.tgz#1f9e03736f277fe9c7b82102bf18570a6ee19f82"
|
||||
integrity sha512-Ha+EnKHFIh9EKW0/XZJPUd3EGDFisEvauaBd4VVCRPKeOqUxNEc9TodiY2Zhk33XCgzJucoFEcaoNcBAPHTQ2A==
|
||||
|
||||
"@types/react-dom@^16.9.8":
|
||||
version "16.9.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.9.9.tgz#d2d0a6f720a0206369ccbefff752ba37b9583136"
|
||||
@ -7179,9 +7218,9 @@ kea-window-values@^0.0.1:
|
||||
integrity sha512-60SfOqHrmnCC8hSD2LALMJemYcohQ8tGcTHlA5u4rQy0l0wFFE4gqH1WbKd+73j9m4f6zdoOk07rwJf+P8maiQ==
|
||||
|
||||
kea@^2.4.5:
|
||||
version "2.4.5"
|
||||
resolved "https://registry.yarnpkg.com/kea/-/kea-2.4.5.tgz#5a43a5aedf306bc7ae0a271ee5bc1ae5fedb4959"
|
||||
integrity sha512-eo9o0AlnPVKyX2/qFAMxFksPjgHfc//NgzET7X3QbddKPDqCVgNfR8DGdrhnbrKlHF9lJtMQ4ERbVr9WaEEheg==
|
||||
version "2.4.6"
|
||||
resolved "https://registry.yarnpkg.com/kea/-/kea-2.4.6.tgz#07782a79c5f036e6514d6300281416e736ed98e3"
|
||||
integrity sha512-NuH1GOck4Dmr18Kjrto8ZsL6n7OgL2CagQeqPnFjdjyxBgNS7JDusex6G9sasW5Cp1D0Cb4izJvFVocj0fS8Jg==
|
||||
|
||||
killable@^1.0.1:
|
||||
version "1.0.1"
|
||||
@ -9013,6 +9052,11 @@ querystringify@^2.1.1:
|
||||
resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6"
|
||||
integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==
|
||||
|
||||
raf-schd@^4.0.2:
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.3.tgz#5d6c34ef46f8b2a0e880a8fcdb743efc5bfdbc1a"
|
||||
integrity sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==
|
||||
|
||||
ramda@^0.27.1:
|
||||
version "0.27.1"
|
||||
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.27.1.tgz#66fc2df3ef873874ffc2da6aa8984658abacf5c9"
|
||||
|
Loading…
Reference in New Issue
Block a user