Skip to content

Commit 03eb633

Browse files
committed
feat(product tours): add tour wait period config
1 parent b11c3c5 commit 03eb633

File tree

8 files changed

+171
-2
lines changed

8 files changed

+171
-2
lines changed

.changeset/bumpy-words-stand.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'posthog-js': minor
3+
---
4+
5+
add product tour wait period support

packages/browser/src/__tests__/extensions/product-tours-utils.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import {
33
getSpotlightStyle,
44
renderTipTapContent,
55
normalizeUrl,
6+
hasTourWaitPeriodPassed,
67
} from '../../extensions/product-tours/product-tours-utils'
78
import { doesTourActivateByEvent, doesTourActivateByAction } from '../../utils/product-tour-utils'
9+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from '../../extensions/product-tours/constants'
810

911
describe('calculateTooltipPosition', () => {
1012
const mockWindow = {
@@ -248,3 +250,43 @@ describe('doesTourActivateByAction', () => {
248250
expect(doesTourActivateByAction(tour)).toBe(false)
249251
})
250252
})
253+
254+
describe('hasTourWaitPeriodPassed', () => {
255+
beforeEach(() => localStorage.clear())
256+
257+
const setLastSeen = (type: string, daysAgo: number) => {
258+
const date = new Date()
259+
date.setDate(date.getDate() - daysAgo)
260+
localStorage.setItem(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`, JSON.stringify(date.toISOString()))
261+
}
262+
263+
it.each([
264+
['no config', undefined, true],
265+
['days is 0', { days: 0, types: ['tour' as const] }, true],
266+
['empty types', { days: 7, types: [] }, true],
267+
['no stored date', { days: 7, types: ['tour' as const] }, true],
268+
])('returns true when %s', (_desc, config, expected) => {
269+
expect(hasTourWaitPeriodPassed(config)).toBe(expected)
270+
})
271+
272+
it('returns false when within the wait period', () => {
273+
setLastSeen('tour', 0)
274+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(false)
275+
})
276+
277+
it('returns true when past the wait period', () => {
278+
setLastSeen('tour', 10)
279+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
280+
})
281+
282+
it('ignores types not in the config', () => {
283+
setLastSeen('announcement', 0)
284+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
285+
})
286+
287+
it('uses the most recent date across multiple types', () => {
288+
setLastSeen('tour', 10)
289+
setLastSeen('announcement', 0)
290+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour', 'announcement'] })).toBe(false)
291+
})
292+
})

packages/browser/src/__tests__/posthog-core.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defaultPostHog } from './helpers/posthog-instance'
22
import type { PostHogConfig } from '../types'
33
import { uuidv7 } from '../uuidv7'
44
import { SurveyEventName, SurveyEventProperties } from '../posthog-surveys-types'
5+
import { ProductTourEventName, ProductTourEventProperties } from '../posthog-product-tours-types'
56
import { SURVEY_SEEN_PREFIX } from '../utils/survey-utils'
67
import { beforeEach } from '@jest/globals'
78

@@ -376,6 +377,52 @@ describe('posthog core', () => {
376377
})
377378
})
378379

380+
describe('product tour capture()', () => {
381+
const setup = (config: Partial<PostHogConfig> = {}, token: string = uuidv7()) => {
382+
const beforeSendMock = jest.fn().mockImplementation((e) => e)
383+
const posthog = defaultPostHog().init(token, { ...config, before_send: beforeSendMock }, token)!
384+
return { posthog, beforeSendMock }
385+
}
386+
387+
it('sending product tour shown events with tour_type should set type-specific last seen date property', () => {
388+
// arrange
389+
const { posthog, beforeSendMock } = setup({ debug: false })
390+
391+
// act
392+
posthog.capture(ProductTourEventName.SHOWN, {
393+
[ProductTourEventProperties.TOUR_ID]: 'testTour1',
394+
[ProductTourEventProperties.TOUR_NAME]: 'Test Tour',
395+
[ProductTourEventProperties.TOUR_TYPE]: 'tour',
396+
})
397+
398+
// assert
399+
const capturedEvent = beforeSendMock.mock.calls[0][0]
400+
expect(capturedEvent.$set).toBeDefined()
401+
const typeSpecificKey = `${ProductTourEventProperties.TOUR_LAST_SEEN_DATE}/tour`
402+
expect(capturedEvent.$set[typeSpecificKey]).toBeDefined()
403+
// Verify it's a valid ISO date string
404+
expect(new Date(capturedEvent.$set[typeSpecificKey]).toISOString()).toBe(
405+
capturedEvent.$set[typeSpecificKey]
406+
)
407+
})
408+
409+
it('sending product tour shown events without tour_type should not set last seen date property', () => {
410+
// arrange
411+
const { posthog, beforeSendMock } = setup({ debug: false })
412+
413+
// act
414+
posthog.capture(ProductTourEventName.SHOWN, {
415+
[ProductTourEventProperties.TOUR_ID]: 'testTour1',
416+
[ProductTourEventProperties.TOUR_NAME]: 'Test Tour',
417+
})
418+
419+
// assert
420+
const capturedEvent = beforeSendMock.mock.calls[0][0]
421+
// No $set should be added when tour_type is missing
422+
expect(capturedEvent.$set).toBeUndefined()
423+
})
424+
})
425+
379426
describe('setInternalOrTestUser()', () => {
380427
const setup = (config: Partial<PostHogConfig> = {}, token: string = uuidv7()) => {
381428
const beforeSendMock = jest.fn().mockImplementation((e) => e)

packages/browser/src/extensions/product-tours/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export const TOUR_SHOWN_KEY_PREFIX = 'ph_product_tour_shown_'
22
export const TOUR_COMPLETED_KEY_PREFIX = 'ph_product_tour_completed_'
33
export const TOUR_DISMISSED_KEY_PREFIX = 'ph_product_tour_dismissed_'
44
export const ACTIVE_TOUR_SESSION_KEY = 'ph_active_product_tour'
5+
export const LAST_SEEN_TOUR_DATE_KEY_PREFIX = 'ph_last_seen_tour_date_'

packages/browser/src/extensions/product-tours/product-tours-utils.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import {
66
ProductTourSelectorError,
77
ProductTourStep,
88
DEFAULT_PRODUCT_TOUR_APPEARANCE,
9+
ProductTourWaitPeriod,
910
} from '../../posthog-product-tours-types'
1011
import { findElement } from './element-inference'
1112
import { prepareStylesheet } from '../utils/stylesheet-loader'
1213
import { document as _document, window as _window } from '../../utils/globals'
1314
import { getFontFamily, getContrastingTextColor, hexToRgba } from '../surveys/surveys-extension-utils'
15+
import { localStore } from '../../storage'
16+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from './constants'
1417

1518
import productTourStyles from './product-tour.css'
1619

@@ -309,3 +312,40 @@ export function getStepHtml(step: ProductTourStep): string {
309312
// backwards compat, will be deprecated
310313
return renderTipTapContent(step.content)
311314
}
315+
316+
export function hasTourWaitPeriodPassed(seenTourWaitPeriod?: ProductTourWaitPeriod): boolean {
317+
if (!seenTourWaitPeriod) {
318+
return true
319+
}
320+
321+
const { days, types } = seenTourWaitPeriod
322+
if (!days || !types || types.length === 0) {
323+
return true
324+
}
325+
326+
let mostRecentDate: Date | null = null
327+
328+
for (const type of types) {
329+
const raw = localStore._get(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`)
330+
if (raw) {
331+
try {
332+
const stored = JSON.parse(raw)
333+
const date = new Date(stored)
334+
if (!isNaN(date.getTime()) && (!mostRecentDate || date > mostRecentDate)) {
335+
mostRecentDate = date
336+
}
337+
} catch {
338+
// ignore malformed entries
339+
}
340+
}
341+
}
342+
343+
if (!mostRecentDate) {
344+
return true
345+
}
346+
347+
const now = new Date()
348+
const diffMs = Math.abs(now.getTime() - mostRecentDate.getTime())
349+
const diffDays = Math.ceil(diffMs / (1000 * 3600 * 24))
350+
return diffDays > days
351+
}

packages/browser/src/extensions/product-tours/product-tours.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getProductTourStylesheet,
2121
getStepImageUrls,
2222
hasElementTarget,
23+
hasTourWaitPeriodPassed,
2324
normalizeUrl,
2425
} from './product-tours-utils'
2526
import { ProductTourTooltip } from './components/ProductTourTooltip'
@@ -35,6 +36,7 @@ import {
3536
TOUR_COMPLETED_KEY_PREFIX,
3637
TOUR_DISMISSED_KEY_PREFIX,
3738
ACTIVE_TOUR_SESSION_KEY,
39+
LAST_SEEN_TOUR_DATE_KEY_PREFIX,
3840
} from './constants'
3941
import { doesTourActivateByAction, doesTourActivateByEvent } from '../../utils/product-tour-utils'
4042
import { TOOLBAR_ID } from '../../constants'
@@ -436,6 +438,13 @@ export class ProductTourManager {
436438
break
437439
}
438440

441+
if (!hasTourWaitPeriodPassed(tour.conditions?.seenTourWaitPeriod)) {
442+
logger.info(
443+
`Cannot show tour ${tour.id} until user has not seen any ${tour.conditions?.seenTourWaitPeriod?.types} within ${tour.conditions?.seenTourWaitPeriod?.days} days.`
444+
)
445+
return false
446+
}
447+
439448
if (!this._isProductToursFeatureFlagEnabled({ flagKey: tour.internal_targeting_flag_key })) {
440449
logger.info(`Tour ${tour.id} failed feature flag check: ${tour.internal_targeting_flag_key}`)
441450
return false
@@ -470,10 +479,12 @@ export class ProductTourManager {
470479
[ProductTourEventProperties.TOUR_NAME]: tour.name,
471480
[ProductTourEventProperties.TOUR_ITERATION]: tour.current_iteration || 1,
472481
[ProductTourEventProperties.TOUR_RENDER_REASON]: renderReason,
482+
[ProductTourEventProperties.TOUR_TYPE]: tour.tour_type,
473483
})
474484

475485
if (!this._isPreviewMode) {
476486
localStore._set(`${TOUR_SHOWN_KEY_PREFIX}${tour.id}`, true)
487+
localStore._set(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${tour.tour_type}`, new Date().toISOString())
477488

478489
this._instance.capture('$set', {
479490
$set: { [`$product_tour_shown/${tour.id}`]: true },
@@ -1053,7 +1064,8 @@ export class ProductTourManager {
10531064
if (
10541065
key?.startsWith(TOUR_SHOWN_KEY_PREFIX) ||
10551066
key?.startsWith(TOUR_COMPLETED_KEY_PREFIX) ||
1056-
key?.startsWith(TOUR_DISMISSED_KEY_PREFIX)
1067+
key?.startsWith(TOUR_DISMISSED_KEY_PREFIX) ||
1068+
key?.startsWith(LAST_SEEN_TOUR_DATE_KEY_PREFIX)
10571069
) {
10581070
keysToRemove.push(key)
10591071
}

packages/browser/src/posthog-core.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
SurveyEventProperties,
3535
SurveyRenderReason,
3636
} from './posthog-surveys-types'
37+
import { ProductTourEventName, ProductTourEventProperties } from './posthog-product-tours-types'
3738
import { PostHogLogs } from './posthog-logs'
3839
import { RateLimiter } from './rate-limiter'
3940
import { RemoteConfigLoader } from './remote-config'
@@ -1219,6 +1220,16 @@ export class PostHog implements PostHogInterface {
12191220
}
12201221
}
12211222

1223+
if (event_name === ProductTourEventName.SHOWN) {
1224+
const tourType = properties?.[ProductTourEventProperties.TOUR_TYPE]
1225+
if (tourType) {
1226+
data.$set = {
1227+
...data.$set,
1228+
[`${ProductTourEventProperties.TOUR_LAST_SEEN_DATE}/${tourType}`]: new Date().toISOString(),
1229+
}
1230+
}
1231+
}
1232+
12221233
// Top-level $set overriding values from the one from properties is taken from the plugin-server normalizeEvent
12231234
// This doesn't handle $set_once, because posthog-people doesn't either
12241235
const finalSet = { ...data.properties['$set'], ...data['$set'] }

packages/browser/src/posthog-product-tours-types.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ export interface ProductTourStep {
8686
bannerConfig?: ProductTourBannerConfig
8787
}
8888

89+
/** maps to main repo EffectiveProductTourType */
90+
export type ProductTourType = 'tour' | 'announcement' | 'banner'
91+
92+
export interface ProductTourWaitPeriod {
93+
days: number
94+
types: ProductTourType[]
95+
}
96+
8997
export interface ProductTourConditions {
9098
url?: string
9199
urlMatchType?: PropertyMatchType
@@ -101,6 +109,7 @@ export interface ProductTourConditions {
101109
values: SurveyActionType[]
102110
} | null
103111
linkedFlagVariant?: string
112+
seenTourWaitPeriod?: ProductTourWaitPeriod
104113
}
105114

106115
export interface ProductTourAppearance {
@@ -125,7 +134,7 @@ export interface ProductTour {
125134
id: string
126135
name: string
127136
description?: string
128-
type: 'product_tour'
137+
tour_type: ProductTourType // inferred in API based on tour content
129138
auto_launch?: boolean
130139
start_date: string | null
131140
end_date: string | null
@@ -212,4 +221,6 @@ export enum ProductTourEventProperties {
212221
TOUR_LINKED_SURVEY_ID = '$product_tour_linked_survey_id',
213222
USE_MANUAL_SELECTOR = '$use_manual_selector',
214223
INFERENCE_DATA_PRESENT = '$inference_data_present',
224+
TOUR_LAST_SEEN_DATE = '$product_tour_last_seen_date',
225+
TOUR_TYPE = '$product_tour_type',
215226
}

0 commit comments

Comments
 (0)