0
0
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:
Alex Gyujin Kim 2021-07-19 12:00:59 -07:00 committed by GitHub
parent 127e827894
commit d9973c7e5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 455 additions and 221 deletions

View File

@ -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

View File

@ -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,
},
)

View File

@ -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):

View File

@ -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,

View File

@ -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)

View File

@ -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>
)
}

View File

@ -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;
}
}

View File

@ -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>
)
}

View File

@ -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]) {

View File

@ -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) => {

View File

@ -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,
}
}

View File

@ -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;

View File

@ -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} />

View 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 }),
},
],
},
})

View File

@ -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 },
]

View File

@ -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 />

View File

@ -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

View File

@ -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",

View File

@ -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"