Skip to content

Commit 1d3f14c

Browse files
authored
feat(product tours): add tour wait period config (#3109)
## Problem closes PostHog/posthog#48209 (comment) <!-- Who are we building for, what are their needs, why is this important? --> ## Changes adds new person properties and local storage keys when tours are shown (incl metadata for tour type) checks tour wait period config against existing local storage keys as part of eligibility checks person property checks are handled in the feaure flag (main app PR to follow) <!-- What is changed and what information would be useful to a reviewer? --> ## Release info Sub-libraries affected ### Libraries affected <!-- Please mark which libraries will require a version bump. --> - [ ] All of them - [x] posthog-js (web) - [ ] posthog-js-lite (web lite) - [ ] posthog-node - [ ] posthog-react-native - [ ] @posthog/react - [ ] @posthog/ai - [ ] @posthog/convex - [ ] @posthog/nextjs-config - [ ] @posthog/nuxt - [ ] @posthog/rollup-plugin - [ ] @posthog/webpack-plugin - [ ] @posthog/types ## Checklist - [x] Tests for new code - [x] Accounted for the impact of any changes across different platforms - [x] Accounted for backwards compatibility of any changes (no breaking changes!) - [x] Took care not to unnecessarily increase the bundle size ### If releasing new changes - [x] Ran `pnpm changeset` to generate a changeset file - [x] Added the "release" label to the PR to indicate we're publishing new versions for the affected packages <!-- For more details check RELEASING.md -->
1 parent 3df7dc0 commit 1d3f14c

File tree

12 files changed

+362
-13
lines changed

12 files changed

+362
-13
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/playwright/mocked/product-tours/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import {
77
TOUR_COMPLETED_KEY_PREFIX,
88
TOUR_DISMISSED_KEY_PREFIX,
99
ACTIVE_TOUR_SESSION_KEY,
10+
LAST_SEEN_TOUR_DATE_KEY_PREFIX,
1011
} from '@/extensions/product-tours/constants'
1112

12-
export { ACTIVE_TOUR_SESSION_KEY }
13+
export { ACTIVE_TOUR_SESSION_KEY, LAST_SEEN_TOUR_DATE_KEY_PREFIX }
1314

1415
export const tourShownKey = (tourId: string) => `${TOUR_SHOWN_KEY_PREFIX}${tourId}`
1516
export const tourCompletedKey = (tourId: string) => `${TOUR_COMPLETED_KEY_PREFIX}${tourId}`
@@ -42,7 +43,7 @@ export function createTour(overrides: Partial<ProductTour> = {}): ProductTour {
4243
return {
4344
id,
4445
name: `Test Tour ${id}`,
45-
type: 'product_tour',
46+
tour_type: 'tour',
4647
auto_launch: true,
4748
start_date: '2021-01-01T00:00:00Z',
4849
end_date: null,
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { expect, test } from '../utils/posthog-playwright-test-base'
2+
import { start, gotoPage } from '../utils/setup'
3+
import {
4+
createTour,
5+
startOptionsWithProductTours,
6+
startWithTours,
7+
tourTooltip,
8+
tourContainer,
9+
LAST_SEEN_TOUR_DATE_KEY_PREFIX,
10+
} from './utils'
11+
12+
test.describe('product tours - wait period', () => {
13+
test('shows tour when no seenTourWaitPeriod is configured', async ({ page, context }) => {
14+
const tour = createTour({ id: 'no-wait-period', tour_type: 'tour' })
15+
await startWithTours(page, context, [tour])
16+
17+
await expect(tourTooltip(page, 'no-wait-period')).toBeVisible({ timeout: 5000 })
18+
})
19+
20+
test('blocks tour when within wait period for matching type', async ({ page, context }) => {
21+
// Navigate first to establish localStorage
22+
await gotoPage(page, './playground/cypress/index.html')
23+
24+
// Seed a recent seen date for 'tour' type (seen today)
25+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
26+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}tour`,
27+
value: JSON.stringify(new Date().toISOString()),
28+
})
29+
30+
const tour = createTour({
31+
id: 'wait-blocked',
32+
tour_type: 'tour',
33+
conditions: {
34+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
35+
},
36+
})
37+
38+
await startWithTours(page, context, [tour], {
39+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
40+
})
41+
42+
await page.waitForTimeout(2000)
43+
await expect(tourTooltip(page, 'wait-blocked')).not.toBeVisible()
44+
})
45+
46+
test('shows tour when wait period has passed', async ({ page, context }) => {
47+
// Navigate first to establish localStorage
48+
await gotoPage(page, './playground/cypress/index.html')
49+
50+
// Seed a seen date from 10 days ago (past the 7-day wait period)
51+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
52+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}tour`,
53+
value: JSON.stringify(new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()),
54+
})
55+
56+
const tour = createTour({
57+
id: 'wait-passed',
58+
tour_type: 'tour',
59+
conditions: {
60+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
61+
},
62+
})
63+
64+
await startWithTours(page, context, [tour], {
65+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
66+
})
67+
68+
await expect(tourTooltip(page, 'wait-passed')).toBeVisible({ timeout: 5000 })
69+
})
70+
71+
test('shows tour when wait period type does not match stored type', async ({ page, context }) => {
72+
// Navigate first to establish localStorage
73+
await gotoPage(page, './playground/cypress/index.html')
74+
75+
// Seed a recent seen date for 'announcement' type
76+
await page.evaluate(({ key, value }: { key: string; value: string }) => localStorage.setItem(key, value), {
77+
key: `${LAST_SEEN_TOUR_DATE_KEY_PREFIX}announcement`,
78+
value: JSON.stringify(new Date().toISOString()),
79+
})
80+
81+
// Tour with wait period only checking 'tour' type — should NOT be blocked by 'announcement'
82+
const tour = createTour({
83+
id: 'wait-type-mismatch',
84+
tour_type: 'tour',
85+
conditions: {
86+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
87+
},
88+
})
89+
90+
await startWithTours(page, context, [tour], {
91+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
92+
})
93+
94+
await expect(tourTooltip(page, 'wait-type-mismatch')).toBeVisible({ timeout: 5000 })
95+
})
96+
97+
test('blocks tour when any of the configured types was seen recently', async ({ page, context }) => {
98+
// Navigate first to establish localStorage
99+
await gotoPage(page, './playground/cypress/index.html')
100+
101+
// 'tour' was seen long ago, but 'announcement' was seen today
102+
await page.evaluate(
103+
({ prefix }: { prefix: string }) => {
104+
const oldDate = new Date(Date.now() - 10 * 24 * 3600 * 1000).toISOString()
105+
const recentDate = new Date().toISOString()
106+
localStorage.setItem(`${prefix}tour`, JSON.stringify(oldDate))
107+
localStorage.setItem(`${prefix}announcement`, JSON.stringify(recentDate))
108+
},
109+
{ prefix: LAST_SEEN_TOUR_DATE_KEY_PREFIX }
110+
)
111+
112+
const tour = createTour({
113+
id: 'wait-multi-type',
114+
tour_type: 'tour',
115+
conditions: {
116+
seenTourWaitPeriod: { days: 7, types: ['tour', 'announcement'] },
117+
},
118+
})
119+
120+
await startWithTours(page, context, [tour], {
121+
startOptions: { ...startOptionsWithProductTours, type: 'reload' },
122+
})
123+
124+
await page.waitForTimeout(2000)
125+
await expect(tourTooltip(page, 'wait-multi-type')).not.toBeVisible()
126+
})
127+
128+
test('showing a tour stores the last seen date for its tour_type', async ({ page, context }) => {
129+
const tour = createTour({ id: 'stores-date', tour_type: 'announcement' })
130+
await startWithTours(page, context, [tour])
131+
132+
await expect(tourTooltip(page, 'stores-date')).toBeVisible({ timeout: 5000 })
133+
134+
const storedValue = await page.evaluate(
135+
(key: string) => localStorage.getItem(key),
136+
`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}announcement`
137+
)
138+
139+
expect(storedValue).toBeTruthy()
140+
const parsed = new Date(JSON.parse(storedValue!))
141+
expect(parsed.getTime()).toBeGreaterThan(Date.now() - 60_000) // within last minute
142+
})
143+
144+
test('second tour blocked by wait period after first tour shown', async ({ page, context }) => {
145+
// Show first tour (type 'tour'), which sets the last seen date
146+
const firstTour = createTour({ id: 'first-tour', tour_type: 'tour' })
147+
await startWithTours(page, context, [firstTour])
148+
149+
const firstTooltip = tourTooltip(page, 'first-tour')
150+
await expect(firstTooltip).toBeVisible({ timeout: 5000 })
151+
152+
// Dismiss the first tour
153+
await tourContainer(page, 'first-tour').locator('.ph-tour-dismiss').click()
154+
await expect(firstTooltip).not.toBeVisible()
155+
156+
// Now reload with a second tour that has a wait period checking 'tour' type
157+
const secondTour = createTour({
158+
id: 'second-tour',
159+
tour_type: 'tour',
160+
conditions: {
161+
seenTourWaitPeriod: { days: 7, types: ['tour'] },
162+
},
163+
})
164+
165+
await page.route('**/api/product_tours/**', async (route) => {
166+
await route.fulfill({ json: { product_tours: [secondTour] } })
167+
})
168+
169+
await page.reload()
170+
await start({ ...startOptionsWithProductTours, type: 'reload' }, page, context)
171+
172+
await page.waitForTimeout(2000)
173+
await expect(tourTooltip(page, 'second-tour')).not.toBeVisible()
174+
})
175+
})

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

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import {
44
renderTipTapContent,
55
normalizeUrl,
66
resolveStepTranslation,
7+
hasTourWaitPeriodPassed,
78
} from '../../extensions/product-tours/product-tours-utils'
89
import { ProductTourStep } from '../../posthog-product-tours-types'
910
import { doesTourActivateByEvent, doesTourActivateByAction } from '../../utils/product-tour-utils'
11+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from '../../extensions/product-tours/constants'
1012

1113
describe('calculateTooltipPosition', () => {
1214
const mockWindow = {
@@ -323,3 +325,43 @@ describe('doesTourActivateByAction', () => {
323325
expect(doesTourActivateByAction(tour)).toBe(false)
324326
})
325327
})
328+
329+
describe('hasTourWaitPeriodPassed', () => {
330+
beforeEach(() => localStorage.clear())
331+
332+
const setLastSeen = (type: string, daysAgo: number) => {
333+
const date = new Date()
334+
date.setDate(date.getDate() - daysAgo)
335+
localStorage.setItem(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`, JSON.stringify(date.toISOString()))
336+
}
337+
338+
it.each([
339+
['no config', undefined, true],
340+
['days is 0', { days: 0, types: ['tour' as const] }, true],
341+
['empty types', { days: 7, types: [] }, true],
342+
['no stored date', { days: 7, types: ['tour' as const] }, true],
343+
])('returns true when %s', (_desc, config, expected) => {
344+
expect(hasTourWaitPeriodPassed(config)).toBe(expected)
345+
})
346+
347+
it('returns false when within the wait period', () => {
348+
setLastSeen('tour', 0)
349+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(false)
350+
})
351+
352+
it('returns true when past the wait period', () => {
353+
setLastSeen('tour', 10)
354+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
355+
})
356+
357+
it('ignores types not in the config', () => {
358+
setLastSeen('announcement', 0)
359+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour'] })).toBe(true)
360+
})
361+
362+
it('uses the most recent date across multiple types', () => {
363+
setLastSeen('tour', 10)
364+
setLastSeen('announcement', 0)
365+
expect(hasTourWaitPeriodPassed({ days: 7, types: ['tour', 'announcement'] })).toBe(false)
366+
})
367+
})

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ 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'
1415
import { createLogger } from '../../utils/logger'
16+
import { localStore } from '../../storage'
17+
import { LAST_SEEN_TOUR_DATE_KEY_PREFIX } from './constants'
1518

1619
import productTourStyles from './product-tour.css'
1720
import { isUndefined } from '@posthog/core'
21+
import { hasPeriodPassed } from '../utils/matcher-utils'
1822

1923
const logger = createLogger('[Product Tours]')
2024

@@ -352,3 +356,37 @@ export function getStepHtml(step: ProductTourStep): string {
352356
// backwards compat, will be deprecated
353357
return renderTipTapContent(step.content)
354358
}
359+
360+
export function hasTourWaitPeriodPassed(seenTourWaitPeriod?: ProductTourWaitPeriod): boolean {
361+
if (!seenTourWaitPeriod) {
362+
return true
363+
}
364+
365+
const { days, types } = seenTourWaitPeriod
366+
if (!days || !types || types.length === 0) {
367+
return true
368+
}
369+
370+
let mostRecentDate: Date | null = null
371+
372+
for (const type of types) {
373+
const raw = localStore._get(`${LAST_SEEN_TOUR_DATE_KEY_PREFIX}${type}`)
374+
if (raw) {
375+
try {
376+
const stored = JSON.parse(raw)
377+
const date = new Date(stored)
378+
if (!isNaN(date.getTime()) && (!mostRecentDate || date > mostRecentDate)) {
379+
mostRecentDate = date
380+
}
381+
} catch {
382+
// ignore malformed entries
383+
}
384+
}
385+
}
386+
387+
if (!mostRecentDate) {
388+
return true
389+
}
390+
391+
return hasPeriodPassed(days, mostRecentDate)
392+
}

0 commit comments

Comments
 (0)