Skip to content

Commit

Permalink
Automatically adjust dates when changing interval
Browse files Browse the repository at this point in the history
  • Loading branch information
robbie-c committed Nov 30, 2023
1 parent c431edf commit 3fc1a08
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 98 deletions.
40 changes: 32 additions & 8 deletions frontend/src/lib/components/IntervalFilter/IntervalFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { LemonSelect } from '@posthog/lemon-ui'
import { LemonSelect, LemonSelectOption } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { insightLogic } from 'scenes/insights/insightLogic'
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'

import { InsightQueryNode } from '~/queries/schema'
import { IntervalType } from '~/types'

interface IntervalFilterProps {
disabled?: boolean
Expand All @@ -19,21 +20,44 @@ export function IntervalFilter({ disabled }: IntervalFilterProps): JSX.Element {
<span>
<span className="hide-lte-md">grouped </span>by
</span>
<LemonSelect
size={'small'}
<IntervalFilterStandalone
disabled={disabled}
value={interval || 'day'}
dropdownMatchSelectWidth={false}
onChange={(value) => {
interval={interval || 'day'}
onIntervalChange={(value) => {
updateQuerySource({ interval: value } as Partial<InsightQueryNode>)
}}
data-attr="interval-filter"
options={Object.entries(enabledIntervals).map(([value, { label, disabledReason }]) => ({
value,
value: value as IntervalType,
label,
disabledReason,
}))}
/>
</>
)
}

interface IntervalFilterStandaloneProps {
disabled?: boolean
interval: IntervalType | undefined
onIntervalChange: (interval: IntervalType) => void
options: LemonSelectOption<IntervalType>[]
}

export function IntervalFilterStandalone({
disabled,
interval,
onIntervalChange,
options,
}: IntervalFilterStandaloneProps): JSX.Element {
return (
<LemonSelect
size={'small'}
disabled={disabled}
value={interval || 'day'}
dropdownMatchSelectWidth={false}
onChange={onIntervalChange}
data-attr="interval-filter"
options={options}
/>
)
}
192 changes: 134 additions & 58 deletions frontend/src/lib/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -864,7 +864,8 @@ const dateOptionsMap = {
m: 'month',
w: 'week',
d: 'day',
}
h: 'hour',
} as const

export function dateFilterToText(
dateFrom: string | dayjs.Dayjs | null | undefined,
Expand Down Expand Up @@ -936,45 +937,67 @@ export function dateFilterToText(
return defaultValue
}

export function dateStringToComponents(date: string | null): {
amount: number
unit: (typeof dateOptionsMap)[keyof typeof dateOptionsMap]
clip: 'Start' | 'End'
} | null {
if (!date) {
return null
}
const parseDate = /^([-+]?)([0-9]*)([hdwmqy])(|Start|End)$/
const matches = date.match(parseDate)
if (!matches) {
return null
}
const [, sign, rawAmount, rawUnit, clip] = matches
const amount = rawAmount ? parseInt(sign + rawAmount) : 0
const unit = dateOptionsMap[rawUnit] || 'day'
return { amount, unit, clip: clip as 'Start' | 'End' }
}

/** Convert a string like "-30d" or "2022-02-02" or "-1mEnd" to `Dayjs().startOf('day')` */
export function dateStringToDayJs(date: string | null): dayjs.Dayjs | null {
if (isDate.test(date || '')) {
return dayjs(date)
}
const parseDate = /^([-+]?)([0-9]*)([dmwqy])(|Start|End)$/
const matches = (date || '').match(parseDate)
let response: null | dayjs.Dayjs = null
if (matches) {
const [, sign, rawAmount, rawUnit, clip] = matches
const amount = rawAmount ? parseInt(sign + rawAmount) : 0
const unit = dateOptionsMap[rawUnit] || 'day'

switch (unit) {
case 'year':
response = dayjs().add(amount, 'year')
break
case 'quarter':
response = dayjs().add(amount * 3, 'month')
break
case 'month':
response = dayjs().add(amount, 'month')
break
case 'week':
response = dayjs().add(amount * 7, 'day')
break
default:
response = dayjs().add(amount, 'day')
break
}

if (clip === 'Start') {
return response.startOf(unit)
} else if (clip === 'End') {
return response.endOf(unit)
}
return response.startOf('day')
const dateComponents = dateStringToComponents(date)
if (!dateComponents) {
return null
}
return response

const { unit, amount, clip } = dateComponents
let response: dayjs.Dayjs

switch (unit) {
case 'year':
response = dayjs().add(amount, 'year')
break
case 'quarter':
response = dayjs().add(amount * 3, 'month')
break
case 'month':
response = dayjs().add(amount, 'month')
break
case 'week':
response = dayjs().add(amount * 7, 'day')
break
case 'day':
response = dayjs().add(amount, 'day')
break
case 'hour':
response = dayjs().add(amount, 'hour')
break
default:
throw new UnexpectedNeverError(unit)
}

if (clip === 'Start') {
return response.startOf(unit)
} else if (clip === 'End') {
return response.endOf(unit)
}
return response.startOf('day')
}

export const getDefaultInterval = (dateFrom: string | null, dateTo: string | null): IntervalType => {
Expand All @@ -987,44 +1010,97 @@ export const getDefaultInterval = (dateFrom: string | null, dateTo: string | nul
}
}

if (dateFrom?.endsWith('h') || dateTo?.endsWith('h')) {
const parsedDateFrom = dateStringToComponents(dateFrom)
const parsedDateTo = dateStringToComponents(dateTo)

if (parsedDateFrom?.unit === 'hour' || parsedDateTo?.unit === 'hour') {
return 'hour'
}

if (
dateFrom === 'mStart' ||
dateFrom?.endsWith('d') ||
dateFrom?.endsWith('dStart') ||
dateFrom?.endsWith('dEnd') ||
dateTo?.endsWith('d') ||
dateTo?.endsWith('dStart') ||
dateTo?.endsWith('dEnd')
) {
if (parsedDateFrom?.unit === 'day' || parsedDateTo?.unit === 'day' || dateFrom === 'mStart') {
return 'day'
}

if (
dateFrom === 'all' ||
dateFrom === 'yStart' ||
dateFrom?.endsWith('m') ||
dateFrom?.endsWith('mStart') ||
dateFrom?.endsWith('mEnd') ||
dateFrom?.endsWith('y') ||
dateFrom?.endsWith('yStart') ||
dateFrom?.endsWith('yEnd') ||
dateTo?.endsWith('m') ||
dateTo?.endsWith('mStart') ||
dateTo?.endsWith('mEnd') ||
dateTo?.endsWith('y') ||
dateTo?.endsWith('yStart') ||
dateTo?.endsWith('yEnd')
parsedDateFrom?.unit === 'month' ||
parsedDateTo?.unit === 'month' ||
parsedDateFrom?.unit === 'quarter' ||
parsedDateTo?.unit === 'quarter' ||
parsedDateFrom?.unit === 'year' ||
parsedDateTo?.unit === 'year' ||
dateFrom === 'all'
) {
return 'month'
}

const dateFromDayJs = dateStringToDayJs(dateFrom)
const dateToDayJs = dateStringToDayJs(dateTo)

const intervalMonths = dateFromDayJs?.diff(dateToDayJs, 'month')
if (intervalMonths != null && intervalMonths >= 1) {
return 'month'
}
const intervalDays = dateFromDayJs?.diff(dateToDayJs, 'day')
if (intervalDays != null && intervalDays >= 1) {
return 'day'
}
const intervalHours = dateFromDayJs?.diff(dateToDayJs, 'hour')
if (intervalHours != null && intervalHours >= 1) {
return 'hour'
}

return 'day'
}

/* If the interval changes, check if it's compatible with the selected dates, and return new dates
* from a map of sensible defaults if not */
export const areDatesValidForInterval = (
interval: IntervalType,
oldDateFrom: string | null,
oldDateTo: string | null
): boolean => {
const parsedOldDateFrom = dateStringToDayJs(oldDateFrom)
const parsedOldDateTo = dateStringToDayJs(oldDateTo) || dayjs()

if (oldDateFrom === 'all' || !parsedOldDateFrom) {
return interval === 'month'
} else if (interval === 'month') {
return parsedOldDateTo.diff(parsedOldDateFrom, 'month') >= 2
} else if (interval === 'week') {
return parsedOldDateTo.diff(parsedOldDateFrom, 'week') >= 2
} else if (interval === 'day') {
const diff = parsedOldDateTo.diff(parsedOldDateFrom, 'day')
return diff >= 2
} else if (interval === 'hour') {
return (
parsedOldDateTo.diff(parsedOldDateFrom, 'hour') >= 2 &&
parsedOldDateTo.diff(parsedOldDateFrom, 'hour') < 24 * 7 * 2 // 2 weeks
)
}
throw new UnexpectedNeverError(interval)
}

const defaultDatesForInterval = {
hour: { dateFrom: '-24h', dateTo: null },
day: { dateFrom: '-7d', dateTo: null },
week: { dateFrom: '-28d', dateTo: null },
month: { dateFrom: '-6m', dateTo: null },
}

export const updateDatesWithInterval = (
interval: IntervalType,
oldDateFrom: string | null,
oldDateTo: string | null
): { dateFrom: string | null; dateTo: string | null } => {
if (areDatesValidForInterval(interval, oldDateFrom, oldDateTo)) {
return {
dateFrom: oldDateFrom,
dateTo: oldDateTo,
}
}
return defaultDatesForInterval[interval]
}

export function clamp(value: number, min: number, max: number): number {
return value > max ? max : value < min ? min : value
}
Expand Down
53 changes: 46 additions & 7 deletions frontend/src/scenes/web-analytics/WebAnalyticsTile.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useActions, useValues } from 'kea'
import { IntervalFilterStandalone } from 'lib/components/IntervalFilter'
import { UnexpectedNeverError } from 'lib/utils'
import { useCallback, useMemo } from 'react'
import { countryCodeToFlag, countryCodeToName } from 'scenes/insights/views/WorldMap'
Expand All @@ -7,8 +8,7 @@ import { DeviceTab, GeographyTab, webAnalyticsLogic } from 'scenes/web-analytics
import { Query } from '~/queries/Query/Query'
import { DataTableNode, InsightVizNode, NodeKind, WebStatsBreakdown } from '~/queries/schema'
import { QueryContext, QueryContextColumnComponent, QueryContextColumnTitleComponent } from '~/queries/types'
import { GraphPointPayload, PropertyFilterType } from '~/types'
import { ChartDisplayType } from '~/types'
import { ChartDisplayType, GraphPointPayload, PropertyFilterType } from '~/types'

const PercentageCell: QueryContextColumnComponent = ({ value }) => {
if (typeof value === 'number') {
Expand Down Expand Up @@ -173,10 +173,22 @@ export const webAnalyticsDataTableQueryContext: QueryContext = {
},
}

export const WebStatsTrendTile = ({ query }: { query: InsightVizNode }): JSX.Element => {
const { togglePropertyFilter, setGeographyTab, setDeviceTab } = useActions(webAnalyticsLogic)
const { hasCountryFilter, deviceTab, hasDeviceTypeFilter, hasBrowserFilter, hasOSFilter } =
useValues(webAnalyticsLogic)
export const WebStatsTrendTile = ({
query,
showIntervalTile,
}: {
query: InsightVizNode
showIntervalTile?: boolean
}): JSX.Element => {
const { togglePropertyFilter, setGeographyTab, setDeviceTab, setInterval } = useActions(webAnalyticsLogic)
const {
hasCountryFilter,
deviceTab,
hasDeviceTypeFilter,
hasBrowserFilter,
hasOSFilter,
dateFilter: { interval },
} = useValues(webAnalyticsLogic)
const { key: worldMapPropertyName } = webStatsBreakdownToPropertyName(WebStatsBreakdown.Country)
const { key: deviceTypePropertyName } = webStatsBreakdownToPropertyName(WebStatsBreakdown.DeviceType)

Expand Down Expand Up @@ -239,7 +251,34 @@ export const WebStatsTrendTile = ({ query }: { query: InsightVizNode }): JSX.Ele
}
}, [onWorldMapClick])

return <Query query={query} readOnly={true} context={context} />
return (
<div
className={'border'}
// eslint-disable-next-line react/forbid-dom-props
style={{
borderRadius: 'var(--radius)',
}}
>
{showIntervalTile && (
<div className="flex flex-row items-center justify-end m-2 mr-4">
<div className="flex flex-row items-center">
<span className="mr-2">Group by</span>
<IntervalFilterStandalone
interval={interval}
onIntervalChange={setInterval}
options={[
{ value: 'hour', label: 'Hour' },
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
]}
/>
</div>
</div>
)}
<Query query={query} readOnly={true} context={context} />
</div>
)
}

export const WebStatsTableTile = ({
Expand Down
Loading

0 comments on commit 3fc1a08

Please sign in to comment.