= {
+ hour: [
+ {
+ label: 'No smoothing',
+ value: 1,
+ },
+ {
+ label: '24 Hrs',
+ value: 24,
+ },
+ ],
+ day: [
+ {
+ label: 'No smoothing',
+ value: 1,
+ },
+ {
+ label: '7 Day',
+ value: 7,
+ },
+ {
+ label: '28 Day',
+ value: 28,
+ },
+ ],
+ week: [],
+ month: [],
+}
+
+export type SmoothingKeyType = keyof typeof smoothingOptions
diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx
index d7aa2a2a1d8d7..71235fef975d7 100644
--- a/frontend/src/lib/constants.tsx
+++ b/frontend/src/lib/constants.tsx
@@ -115,6 +115,7 @@ export const FEATURE_FLAGS = {
AND_OR_FILTERING: 'and-or-filtering', // owner: @edscode
PROJECT_HOMEPAGE: 'project-homepage', // owner: @rcmarron
FEATURE_FLAGS_ACTIVITY_LOG: '8545-ff-activity-log', // owner: @pauldambra
+ SMOOTHING_INTERVAL: 'smoothing-interval', // owner: @timgl
TUNE_RECORDING_SNAPSHOT_LIMIT: 'tune-recording-snapshot-limit', // owner: @rcmarron
}
diff --git a/frontend/src/scenes/insights/InsightTabs/InsightDisplayConfig.tsx b/frontend/src/scenes/insights/InsightTabs/InsightDisplayConfig.tsx
index 18b759f14a52b..0393df6c0814b 100644
--- a/frontend/src/scenes/insights/InsightTabs/InsightDisplayConfig.tsx
+++ b/frontend/src/scenes/insights/InsightTabs/InsightDisplayConfig.tsx
@@ -2,17 +2,21 @@ import React from 'react'
import { ChartFilter } from 'lib/components/ChartFilter'
import { CompareFilter } from 'lib/components/CompareFilter/CompareFilter'
import { IntervalFilter } from 'lib/components/IntervalFilter'
-import { ACTIONS_BAR_CHART_VALUE, ACTIONS_PIE_CHART, ACTIONS_TABLE } from 'lib/constants'
+import { SmoothingFilter } from 'lib/components/SmoothingFilter/SmoothingFilter'
+import { ACTIONS_BAR_CHART_VALUE, ACTIONS_PIE_CHART, ACTIONS_TABLE, ACTIONS_LINE_GRAPH_LINEAR } from 'lib/constants'
import { FilterType, FunnelVizType, ItemMode, InsightType } from '~/types'
import { CalendarOutlined } from '@ant-design/icons'
import { InsightDateFilter } from '../InsightDateFilter'
import { RetentionDatePicker } from '../RetentionDatePicker'
import { FunnelDisplayLayoutPicker } from './FunnelTab/FunnelDisplayLayoutPicker'
-import { FunnelBinsPicker } from 'scenes/insights/InsightTabs/FunnelTab/FunnelBinsPicker'
import { PathStepPicker } from './PathTab/PathStepPicker'
import { ReferencePicker as RetentionReferencePicker } from './RetentionTab/ReferencePicker'
import { Tooltip } from 'antd'
import { InfoCircleOutlined } from '@ant-design/icons'
+import { FunnelBinsPicker } from './FunnelTab/FunnelBinsPicker'
+import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
+import { useValues } from 'kea'
+import { FEATURE_FLAGS } from 'lib/constants'
interface InsightDisplayConfigProps {
filters: FilterType
@@ -83,6 +87,7 @@ export function InsightDisplayConfig({
}: InsightDisplayConfigProps): JSX.Element {
const showFunnelBarOptions = activeView === InsightType.FUNNELS
const showPathOptions = activeView === InsightType.PATHS
+ const { featureFlags } = useValues(featureFlagLogic)
return (
@@ -117,6 +122,14 @@ export function InsightDisplayConfig({
)}
+ {activeView === InsightType.TRENDS &&
+ !filters.breakdown_type &&
+ !filters.compare &&
+ (!filters.display || filters.display === ACTIONS_LINE_GRAPH_LINEAR) &&
+ featureFlags[FEATURE_FLAGS.SMOOTHING_INTERVAL] ? (
+
+ ) : null}
+
{activeView === InsightType.RETENTION && (
<>
diff --git a/frontend/src/scenes/insights/utils/cleanFilters.test.ts b/frontend/src/scenes/insights/utils/cleanFilters.test.ts
index ee622d03dc07f..cf5def72694df 100644
--- a/frontend/src/scenes/insights/utils/cleanFilters.test.ts
+++ b/frontend/src/scenes/insights/utils/cleanFilters.test.ts
@@ -225,4 +225,19 @@ describe('cleanFilters', () => {
expect(cleanedFilters).toHaveProperty('breakdown_type', 'event')
expect(cleanedFilters).toHaveProperty('breakdown_group_type_index', undefined)
})
+
+ it('reads "smoothing_intervals" and "interval" from URL when viewing and corrects bad pairings', () => {
+ const cleanedFilters = cleanFilters(
+ {
+ interval: 'day',
+ smoothing_intervals: 4,
+ },
+ {
+ interval: 'day',
+ smoothing_intervals: 3,
+ }
+ )
+
+ expect(cleanedFilters).toHaveProperty('smoothing_intervals', 1)
+ })
})
diff --git a/frontend/src/scenes/insights/utils/cleanFilters.ts b/frontend/src/scenes/insights/utils/cleanFilters.ts
index 4bcd25e8af0df..58c6e9612af37 100644
--- a/frontend/src/scenes/insights/utils/cleanFilters.ts
+++ b/frontend/src/scenes/insights/utils/cleanFilters.ts
@@ -7,6 +7,7 @@ import { autocorrectInterval } from 'lib/utils'
import { DEFAULT_STEP_LIMIT } from 'scenes/paths/pathsLogic'
import { isTrendsInsight } from 'scenes/insights/sharedUtils'
import { FeatureFlagsSet } from 'lib/logic/featureFlagLogic'
+import { smoothingOptions } from 'lib/components/SmoothingFilter/smoothings'
export function getDefaultEvent(): Entity {
const event = getDefaultEventName()
@@ -213,6 +214,18 @@ export function cleanFilters(
cleanSearchParams['compare'] = false
}
+ if (cleanSearchParams.interval && cleanSearchParams.smoothing_intervals) {
+ if (
+ !smoothingOptions[cleanSearchParams.interval].find(
+ (option) => option.value === cleanSearchParams.smoothing_intervals
+ )
+ ) {
+ if (cleanSearchParams.smoothing_intervals !== 1) {
+ cleanSearchParams.smoothing_intervals = 1
+ }
+ }
+ }
+
if (cleanSearchParams.insight === InsightType.LIFECYCLE) {
if (cleanSearchParams.events?.length) {
cleanSearchParams.events = [
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index da46583dfe9a3..fea4af6a4af5a 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -876,6 +876,7 @@ export enum ChartDisplayType {
export type BreakdownType = 'cohort' | 'person' | 'event' | 'group'
export type IntervalType = 'hour' | 'day' | 'week' | 'month'
+export type SmoothingType = number
export enum InsightType {
TRENDS = 'TRENDS',
@@ -917,6 +918,11 @@ export interface FilterType {
insight?: InsightType
display?: ChartDisplayType
interval?: IntervalType
+
+ // Specifies that we want to smooth the aggregation over the specified
+ // number of intervals, e.g. for a day interval, we may want to smooth over
+ // 7 days to remove weekly variation. Smoothing is performed as a moving average.
+ smoothing_intervals?: number
date_from?: string | null
date_to?: string | null
properties?: AnyPropertyFilter[] | PropertyGroupFilter
diff --git a/posthog/api/test/test_dashboard.py b/posthog/api/test/test_dashboard.py
index 13d2b41257ab8..ea608ff44c5b2 100644
--- a/posthog/api/test/test_dashboard.py
+++ b/posthog/api/test/test_dashboard.py
@@ -222,6 +222,7 @@ def test_refresh_cache(self):
).to_dict(),
team=self.team,
last_refresh=now(),
+ order=0,
)
item_trends: Insight = Insight.objects.create(
dashboard=dashboard,
@@ -237,6 +238,7 @@ def test_refresh_cache(self):
).to_dict(),
team=self.team,
last_refresh=now(),
+ order=1,
)
with freeze_time("2020-01-20T13:00:01Z"):
diff --git a/posthog/api/test/test_stickiness.py b/posthog/api/test/test_stickiness.py
index 7f220d2a0d1d2..50a7345268cd4 100644
--- a/posthog/api/test/test_stickiness.py
+++ b/posthog/api/test/test_stickiness.py
@@ -1,12 +1,12 @@
+from dataclasses import dataclass
from datetime import datetime, timedelta
-from typing import Any, Dict
+from typing import Any, Dict, Optional, Union
from dateutil.relativedelta import relativedelta
from django.test.client import Client
from django.utils import timezone
from freezegun import freeze_time
-from posthog.api.test.test_trends import get_time_series_ok
from posthog.constants import ENTITY_ID, ENTITY_TYPE
from posthog.models.team import Team
from posthog.test.base import APIBaseTest
@@ -38,6 +38,29 @@ def get_stickiness_people_ok(client: Client, team_id: int, request: Dict[str, An
return response.json()
+def get_time_series_ok(data):
+ res = {}
+ for item in data["result"]:
+ collect_dates = {}
+ for idx, date in enumerate(item["days"]):
+ collect_dates[date] = NormalizedTrendResult(
+ value=item["data"][idx],
+ label=item["labels"][idx],
+ person_url=item["persons_urls"][idx]["url"],
+ breakdown_value=item.get("breakdown_value", None),
+ )
+ res[item["label"]] = collect_dates
+ return res
+
+
+@dataclass
+class NormalizedTrendResult:
+ value: float
+ label: str
+ person_url: str
+ breakdown_value: Optional[Union[str, int]]
+
+
# parameterize tests to reuse in EE
def stickiness_test_factory(stickiness, event_factory, person_factory, action_factory, get_earliest_timestamp):
class TestStickiness(APIBaseTest):
diff --git a/posthog/api/test/test_trends.py b/posthog/api/test/test_trends.py
deleted file mode 100644
index d04a121012243..0000000000000
--- a/posthog/api/test/test_trends.py
+++ /dev/null
@@ -1,210 +0,0 @@
-import json
-from dataclasses import dataclass, field
-from datetime import datetime
-from typing import Any, Dict, List, Optional, Union
-
-import pytest
-from django.test import Client
-from freezegun import freeze_time
-
-from posthog.api.test.test_cohort import create_cohort_ok
-from posthog.api.test.test_event_definition import (
- EventData,
- capture_event,
- create_organization,
- create_team,
- create_user,
-)
-from posthog.api.test.test_retention import identify
-from posthog.models.team import Team
-from posthog.test.base import stripResponse
-
-
-@pytest.mark.django_db
-@pytest.mark.ee
-def test_includes_only_intervals_within_range(client: Client):
- """
- This is the case highlighted by https://github.com/PostHog/posthog/issues/2675
-
- Here the issue is that we request, for instance, 14 days as the
- date_from, display at weekly intervals but previously we
- were displaying 4 ticks on the date axis. If we were exactly on the
- beginning of the week for two weeks then we'd want 2 ticks.
- Otherwise we would have 3 ticks as the range would be intersecting
- with three weeks. We should never need to display 4 ticks.
- """
- organization = create_organization(name="test org")
- team = create_team(organization=organization)
- user = create_user("user", "pass", organization)
-
- client.force_login(user)
-
- # I'm creating a cohort here so that I can use as a breakdown, just because
- # this is what was used demonstrated in
- # https://github.com/PostHog/posthog/issues/2675 but it might not be the
- # simplest way to reproduce
-
- # "2021-09-19" is a sunday, i.e. beginning of week
- with freeze_time("2021-09-20T16:00:00"):
- # First identify as a member of the cohort
- distinct_id = "abc"
- identify(distinct_id=distinct_id, team_id=team.id, properties={"cohort_identifier": 1})
- cohort = create_cohort_ok(
- client=client, team_id=team.id, name="test cohort", groups=[{"properties": {"cohort_identifier": 1}}]
- )
-
- for date in ["2021-09-04", "2021-09-05", "2021-09-12", "2021-09-19"]:
- capture_event(
- event=EventData(
- event="$pageview",
- team_id=team.id,
- distinct_id=distinct_id,
- timestamp=datetime.fromisoformat(date),
- properties={"distinct_id": "abc"},
- )
- )
-
- trends = get_trends_ok(
- client,
- request=TrendsRequestBreakdown(
- date_from="-14days",
- date_to="2021-09-21",
- interval="week",
- insight="TRENDS",
- breakdown=json.dumps([cohort["id"]]),
- breakdown_type="cohort",
- display="ActionsLineGraph",
- events=[
- {
- "id": "$pageview",
- "math": "dau",
- "name": "$pageview",
- "custom_name": None,
- "type": "events",
- "order": 0,
- "properties": [],
- "math_property": None,
- }
- ],
- ),
- team=team,
- )
-
- assert stripResponse(trends["result"], remove=("persons_urls", "filter")) == [
- {
- "action": {
- "id": "$pageview",
- "type": "events",
- "order": 0,
- "name": "$pageview",
- "custom_name": None,
- "math": "dau",
- "math_property": None,
- "math_group_type_index": None,
- "properties": {},
- },
- "breakdown_value": cohort["id"],
- "label": "$pageview - test cohort",
- "count": 3.0,
- "data": [1.0, 1.0, 1.0],
- # Prior to the fix this would also include '29-Aug-2021'
- "labels": ["5-Sep-2021", "12-Sep-2021", "19-Sep-2021"],
- "days": ["2021-09-05", "2021-09-12", "2021-09-19"],
- }
- ]
-
-
-@dataclass(frozen=True)
-class TrendsRequest:
- date_from: Optional[str] = None
- date_to: Optional[str] = None
- interval: Optional[str] = None
- insight: Optional[str] = None
- display: Optional[str] = None
- compare: Optional[bool] = None
- events: List[Dict[str, Any]] = field(default_factory=list)
- properties: List[Dict[str, Any]] = field(default_factory=list)
-
-
-@dataclass(frozen=True)
-class TrendsRequestBreakdown(TrendsRequest):
- breakdown: Optional[Union[List[int], str]] = None
- breakdown_type: Optional[str] = None
-
-
-def get_trends(client, request: Union[TrendsRequestBreakdown, TrendsRequest], team: Team):
- data: Dict[str, Any] = {
- "date_from": request.date_from,
- "date_to": request.date_to,
- "interval": request.interval,
- "insight": request.insight,
- "display": request.display,
- "compare": request.compare,
- "events": json.dumps(request.events),
- "properties": json.dumps(request.properties),
- }
-
- if isinstance(request, TrendsRequestBreakdown):
- data["breakdown"] = request.breakdown
- data["breakdown_type"] = request.breakdown_type
-
- filtered_data = {k: v for k, v in data.items() if v is not None}
-
- return client.get(f"/api/projects/{team.id}/insights/trend/", data=filtered_data,)
-
-
-def get_trends_ok(client: Client, request: TrendsRequest, team: Team):
- response = get_trends(client=client, request=request, team=team)
- assert response.status_code == 200, response.content
- return response.json()
-
-
-@dataclass(frozen=True)
-class NormalizedTrendResult:
- value: float
- label: str
- person_url: str
- breakdown_value: Optional[Union[str, int]]
-
-
-def get_trends_time_series_ok(
- client: Client, request: TrendsRequest, team: Team
-) -> Dict[str, Dict[str, NormalizedTrendResult]]:
- data = get_trends_ok(client=client, request=request, team=team)
- return get_time_series_ok(data)
-
-
-def get_time_series_ok(data):
- res = {}
- for item in data["result"]:
- collect_dates = {}
- for idx, date in enumerate(item["days"]):
- collect_dates[date] = NormalizedTrendResult(
- value=item["data"][idx],
- label=item["labels"][idx],
- person_url=item["persons_urls"][idx]["url"],
- breakdown_value=item.get("breakdown_value", None),
- )
- key = item["label"] + (f' - {item["compare_label"]}' if "compare_label" in item else "")
- res[key] = collect_dates
- return res
-
-
-def get_trends_aggregate_ok(client: Client, request: TrendsRequest, team: Team) -> Dict[str, NormalizedTrendResult]:
- data = get_trends_ok(client=client, request=request, team=team)
- res = {}
- for item in data["result"]:
- res[item["label"]] = NormalizedTrendResult(
- value=item["aggregated_value"],
- label=item["action"]["name"],
- person_url=item["persons"]["url"],
- breakdown_value=item.get("breakdown_value", None),
- )
-
- return res
-
-
-def get_people_from_url_ok(client: Client, url: str):
- response = client.get("/" + url)
- assert response.status_code == 200, response.content
- return response.json()["results"][0]["people"]
diff --git a/posthog/constants.py b/posthog/constants.py
index 012fe44dce662..d1cfa0ef8fe2d 100644
--- a/posthog/constants.py
+++ b/posthog/constants.py
@@ -87,6 +87,7 @@ class AvailableFeature(str, Enum):
PROPERTY_GROUPS = "property_groups"
SELECTOR = "selector"
INTERVAL = "interval"
+SMOOTHING_INTERVALS = "smoothing_intervals"
DISPLAY = "display"
SHOWN_AS = "shown_as"
FILTER_TEST_ACCOUNTS = "filter_test_accounts"
diff --git a/posthog/models/filters/filter.py b/posthog/models/filters/filter.py
index d899e27eddd4c..248643340160e 100644
--- a/posthog/models/filters/filter.py
+++ b/posthog/models/filters/filter.py
@@ -26,6 +26,7 @@
SelectorMixin,
SessionMixin,
ShownAsMixin,
+ SmoothingIntervalsMixin,
)
from posthog.models.filters.mixins.funnel import (
FunnelCorrelationActorsMixin,
@@ -49,6 +50,7 @@
class Filter(
PropertyMixin,
IntervalMixin,
+ SmoothingIntervalsMixin,
EntitiesMixin,
EntityIdMixin,
EntityTypeMixin,
diff --git a/posthog/models/filters/mixins/common.py b/posthog/models/filters/mixins/common.py
index acce65c96825f..ae313656730c6 100644
--- a/posthog/models/filters/mixins/common.py
+++ b/posthog/models/filters/mixins/common.py
@@ -1,7 +1,7 @@
import datetime
import json
import re
-from typing import Any, Dict, List, Literal, Optional, Union
+from typing import Any, Dict, List, Literal, Optional, Union, cast
from dateutil.relativedelta import relativedelta
from django.db.models.query_utils import Q
@@ -33,6 +33,7 @@
SELECTOR,
SESSION,
SHOWN_AS,
+ SMOOTHING_INTERVALS,
TREND_FILTER_TYPE_ACTIONS,
TREND_FILTER_TYPE_EVENTS,
)
@@ -45,6 +46,25 @@
ALLOWED_FORMULA_CHARACTERS = r"([a-zA-Z \-\*\^0-9\+\/\(\)]+)"
+class SmoothingIntervalsMixin(BaseParamMixin):
+ @cached_property
+ def smoothing_intervals(self) -> int:
+ interval_candidate_string = self._data.get(SMOOTHING_INTERVALS)
+ if not interval_candidate_string:
+ return 1
+ try:
+ interval_candidate = int(interval_candidate_string)
+ if interval_candidate < 1:
+ raise ValueError(f"Smoothing intervals must be a positive integer!")
+ except ValueError:
+ raise ValueError(f"Smoothing intervals must be a positive integer!")
+ return cast(int, interval_candidate)
+
+ @include_dict
+ def smoothing_intervals_to_dict(self):
+ return {SMOOTHING_INTERVALS: self.smoothing_intervals}
+
+
class SelectorMixin(BaseParamMixin):
@cached_property
def selector(self) -> Optional[str]:
diff --git a/posthog/models/filters/test/test_filter.py b/posthog/models/filters/test/test_filter.py
index 015bdcdb0660a..f83d5134149e1 100644
--- a/posthog/models/filters/test/test_filter.py
+++ b/posthog/models/filters/test/test_filter.py
@@ -36,7 +36,8 @@ def test_to_dict(self):
}
)
self.assertCountEqual(
- list(filter.to_dict().keys()), ["events", "display", "compare", "insight", "date_from", "interval"],
+ list(filter.to_dict().keys()),
+ ["events", "display", "compare", "insight", "date_from", "interval", "smoothing_intervals"],
)
def test_simplify_test_accounts(self):