Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions assets/js/dashboard/dashboard-time-periods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ export enum ComparisonMode {
custom = 'custom'
}

export type DashboardTimeSettings = {
site: Pick<PlausibleSite, 'offset' | 'statsBegin'>
date: DashboardState['date']
period: DashboardState['period']
from: DashboardState['from']
to: DashboardState['to']
comparison: DashboardState['comparison']
compare_from: DashboardState['compare_from']
compare_to: DashboardState['compare_to']
}

export const COMPARISON_MODES = {
[ComparisonMode.off]: 'Disable comparison',
[ComparisonMode.previous_period]: 'Previous period',
Expand All @@ -73,6 +84,41 @@ const COMPARISON_DISABLED_PERIODS = [
DashboardPeriod.all
]

const PERIODS_EXCLUDING_NOW = [
DashboardPeriod['7d'],
DashboardPeriod['28d'],
DashboardPeriod['30d'],
DashboardPeriod['91d'],
DashboardPeriod['6mo'],
DashboardPeriod['12mo']
]

export function isHistoricalPeriod({
site,
date,
period,
from,
to,
comparison,
compare_from,
compare_to
}: DashboardTimeSettings) {
const startOfDay = nowForSite(site).startOf('day')

const mainPeriodIncludesToday =
period === DashboardPeriod.custom && to && from
? !to.isBefore(startOfDay)
: !(date?.isBefore(startOfDay) || PERIODS_EXCLUDING_NOW.includes(period))

const comparisonPeriodIncludesToday =
comparison === ComparisonMode.custom &&
compare_to &&
compare_from &&
!compare_to.isBefore(startOfDay)

return !(mainPeriodIncludesToday || comparisonPeriodIncludesToday)
}

export const isComparisonForbidden = ({
period,
segmentIsExpanded
Expand Down
153 changes: 153 additions & 0 deletions assets/js/dashboard/hooks/api-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { DEFAULT_SITE } from '../../../test-utils/app-context-providers'
import { ComparisonMode, DashboardPeriod } from '../dashboard-time-periods'
import { formatISO, nowForSite } from '../util/date'
import {
CACHE_TTL_HISTORICAL,
CACHE_TTL_LONG_ONGOING,
CACHE_TTL_REALTIME,
CACHE_TTL_SHORT_ONGOING,
getStaleTime
} from './api-client'

const site = DEFAULT_SITE
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion, non-blocking: If we refactor getStaleTime not to depend on arg named site but directly on the two properties from the site, it makes the test depend on less things. That way, we'd not need to import or fake a full site here, just the properties offset and statsBegin.

const today = nowForSite(site)
const yesterday = nowForSite(site).subtract(1, 'day')

const noComparison = { comparison: null, compare_from: null, compare_to: null }
const base = { site, date: null, from: null, to: null, ...noComparison }

describe(`${getStaleTime.name}`, () => {
describe('realtime periods', () => {
it('for realtime', () => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

meganitpick, non-blocking: Can we refactor it( to test(? it works better if there's a verb right after

expect(getStaleTime({ ...base, period: DashboardPeriod.realtime })).toBe(
CACHE_TTL_REALTIME
)
})

it('for realtime_30m', () => {
expect(
getStaleTime({ ...base, period: DashboardPeriod.realtime_30m })
).toBe(CACHE_TTL_REALTIME)
})
})

describe('historical periods (does not include today)', () => {
it('for 28d', () => {
expect(getStaleTime({ ...base, period: DashboardPeriod['28d'] })).toBe(
CACHE_TTL_HISTORICAL
)
})

it('for 6mo', () => {
expect(getStaleTime({ ...base, period: DashboardPeriod['6mo'] })).toBe(
CACHE_TTL_HISTORICAL
)
})

it('for period=day and date=yesterday', () => {
expect(
getStaleTime({ ...base, period: DashboardPeriod.day, date: yesterday })
).toBe(CACHE_TTL_HISTORICAL)
})

it('for custom period ending yesterday', () => {
expect(
getStaleTime({
...base,
period: DashboardPeriod.custom,
from: yesterday.subtract(7, 'day'),
to: yesterday,
...noComparison
})
).toBe(CACHE_TTL_HISTORICAL)
})
})

describe('ongoing periods with short TTL (supports day or shorter interval)', () => {
it('for today period', () => {
expect(getStaleTime({ ...base, period: DashboardPeriod.day })).toBe(
CACHE_TTL_SHORT_ONGOING
)
})

it('for 24h period', () => {
expect(getStaleTime({ ...base, period: DashboardPeriod['24h'] })).toBe(
CACHE_TTL_SHORT_ONGOING
)
})

it('for period=month and date=today', () => {
expect(
getStaleTime({ ...base, period: DashboardPeriod.month, date: today })
).toBe(CACHE_TTL_SHORT_ONGOING)
})

it('for period=year and date=today', () => {
expect(
getStaleTime({ ...base, period: DashboardPeriod.year, date: today })
).toBe(CACHE_TTL_SHORT_ONGOING)
})

it('for custom period under 12 months ending today', () => {
expect(
getStaleTime({
...base,
period: DashboardPeriod.custom,
from: today.subtract(6, 'month'),
to: today
})
).toBe(CACHE_TTL_SHORT_ONGOING)
})

it('for all time period when stats begin recently', () => {
const siteWithRecentStats = {
...DEFAULT_SITE,
statsBegin: formatISO(yesterday)
}
expect(
getStaleTime({
...base,
site: siteWithRecentStats,
period: DashboardPeriod.all
})
).toBe(CACHE_TTL_SHORT_ONGOING)
})

it('for a historical period when comparison includes today', () => {
expect(
getStaleTime({
...base,
period: DashboardPeriod['28d'],
date: today,
comparison: ComparisonMode.custom,
compare_from: yesterday.subtract(28, 'day'),
compare_to: today
})
).toBe(CACHE_TTL_SHORT_ONGOING)
})
})

describe('ongoing periods with long TTL (only week or month interval available)', () => {
it('for custom period over 12 months ending today', () => {
expect(
getStaleTime({
...base,
period: DashboardPeriod.custom,
from: today.subtract(13, 'month'),
to: today
})
).toBe(CACHE_TTL_LONG_ONGOING)
})

it('for all time period when stats begin over 12 months ago', () => {
const siteWithOldStats = { ...DEFAULT_SITE, statsBegin: '2020-01-01' }
expect(
getStaleTime({
...base,
site: siteWithOldStats,
period: DashboardPeriod.all
})
).toBe(CACHE_TTL_LONG_ONGOING)
})
})
})
106 changes: 52 additions & 54 deletions assets/js/dashboard/hooks/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,23 @@ import {
} from '@tanstack/react-query'
import * as api from '../api'
import { DashboardState } from '../dashboard-state'
import { DashboardPeriod } from '../dashboard-time-periods'
import { Dayjs } from 'dayjs'
import {
DashboardPeriod,
DashboardTimeSettings,
isHistoricalPeriod
} from '../dashboard-time-periods'
import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer'
import {
GetIntervalProps,
Interval,
validIntervals
} from '../stats/graph/intervals'

// defines when queries that don't include the current time should be refetched
const HISTORICAL_RESPONSES_STALE_TIME_MS = 12 * 60 * 60 * 1000
// define (in ms) when query API responses should become stale
export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS
export const CACHE_TTL_SHORT_ONGOING = 5 * 60 * 1000 // 5 minutes
export const CACHE_TTL_LONG_ONGOING = 60 * 60 * 1000 // 1 hour
export const CACHE_TTL_HISTORICAL = 12 * 60 * 60 * 1000 // 12 hours

// how many items per page for breakdown modals
const PAGINATION_LIMIT = 100
Expand All @@ -34,12 +45,14 @@ export function usePaginatedGetAPI<
TResponse extends { results: unknown[] },
TKey extends PaginatedQueryKeyBase = PaginatedQueryKeyBase
>({
site,
key,
getRequestParams,
afterFetchData,
afterFetchNextPage,
initialPageParam = 1
}: {
site: DashboardTimeSettings['site']
key: TKey
getRequestParams: GetRequestParams<TKey>
afterFetchData?: (response: TResponse) => void
Expand Down Expand Up @@ -89,6 +102,10 @@ export function usePaginatedGetAPI<
? lastPageIndex + 1
: null
},
staleTime: ({ queryKey }) => {
const [_, opts] = queryKey
return getStaleTime({ site, ...opts.dashboardState })
},
initialPageParam,
placeholderData: (previousData) => previousData
})
Expand All @@ -108,59 +125,40 @@ export const cleanToPageOne = <
return data
}

export const getStaleTime = (
/** the start of the current day */
startOfDay: Dayjs,
{
period,
from,
to,
date
}: Pick<DashboardState, 'period' | 'from' | 'to' | 'date'>
): number => {
if (DashboardPeriod.custom && to && from) {
// historical
if (from.isBefore(startOfDay) && to.isBefore(startOfDay)) {
return HISTORICAL_RESPONSES_STALE_TIME_MS
}
// period includes now
if (to.diff(from, 'days') < 7) {
return 5 * 60 * 1000
}
if (to.diff(from, 'months') < 1) {
return 15 * 60 * 1000
}
if (to.diff(from, 'months') < 12) {
return 60 * 60 * 1000
}
return 3 * 60 * 60 * 1000
/**
* Returns the time-to-live for cached query API responses based on the given DashboardTimeSettings.
*
* - For a realtime dashboard: {@link CACHE_TTL_REALTIME}
* - For any historical period (i.e. does not include today): {@link CACHE_TTL_HISTORICAL}
* - For a period that includes today, supporting 'day' or shorter interval: {@link CACHE_TTL_SHORT_ONGOING}
* - For a period that includes today, too long to support 'day' interval: {@link CACHE_TTL_LONG_ONGOING}
*/
export const getStaleTime = (props: DashboardTimeSettings): number => {
if (
[DashboardPeriod.realtime, DashboardPeriod.realtime_30m].includes(
props.period
)
) {
return CACHE_TTL_REALTIME
}

const historical = date?.isBefore(startOfDay)
if (historical) {
return HISTORICAL_RESPONSES_STALE_TIME_MS
if (isHistoricalPeriod(props)) {
return CACHE_TTL_HISTORICAL
}

switch (period) {
case DashboardPeriod.realtime:
return REALTIME_UPDATE_TIME_MS
case DashboardPeriod['24h']:
case DashboardPeriod.day:
return 5 * 60 * 1000
case DashboardPeriod['7d']:
return 15 * 60 * 1000
case DashboardPeriod['28d']:
case DashboardPeriod['30d']:
case DashboardPeriod['91d']:
case DashboardPeriod['6mo']:
return 60 * 60 * 1000
case DashboardPeriod['12mo']:
case DashboardPeriod.year:
return 3 * 60 * 60 * 1000
case DashboardPeriod.all:
default:
// err on the side of less caching,
// to avoid the user refresheshing
return 15 * 60 * 1000
const availableIntervals = validIntervals(
Object.fromEntries(
Object.entries(props).filter(([k, _v]) => k !== 'date')
) as GetIntervalProps
)
Comment on lines +149 to +153
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue, blocking: The cleaner way to exclude date would be to destructure const {date, ...rest} = props, const availableIntervals = validIntervals(rest), but we don't actually have to do it, because the function ignores the date prop if present. It doesn't make use of it. Checking locally, the line const availableIntervals = validIntervals(props) is valid according to typescript compiler.


if (
availableIntervals.includes(Interval.day) ||
availableIntervals.includes(Interval.hour) ||
availableIntervals.includes(Interval.minute)
) {
return CACHE_TTL_SHORT_ONGOING
} else {
return CACHE_TTL_LONG_ONGOING
}
}
3 changes: 2 additions & 1 deletion assets/js/dashboard/stats/graph/graph-interval-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,10 @@ export function GraphIntervalProvider({
}) {
const site = useSiteContext()
const { dashboardState } = useDashboardStateContext()
const intervalStorageKey = `interval__${dashboardState.period}__${site.domain}`

const { selectedInterval, onIntervalClick, availableIntervals } =
useStoredInterval({
useStoredInterval(intervalStorageKey, {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question, non-blocking: Why can't the hook contain the logic to derive the storage key? Seems totally within its limited responsibilities.

site,
to: dashboardState.to,
from: dashboardState.from,
Expand Down
Loading
Loading