feat: add useSurveyEligibility composable for nightly surveys

This commit is contained in:
bymyself
2026-01-19 19:25:26 -08:00
parent 0ad85d10cf
commit 7291bf872f
2 changed files with 500 additions and 0 deletions

View File

@@ -0,0 +1,360 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const SURVEY_STATE_KEY = 'Comfy.SurveyState'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const USER_SAMPLING_ID_KEY = 'Comfy.SurveyUserId'
const mockIsNightly = vi.hoisted(() => ({ value: true }))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockIsNightly.value
},
get isCloud() {
return mockIsCloud.value
},
get isDesktop() {
return mockIsDesktop.value
}
}))
describe('useSurveyEligibility', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123'
}
beforeEach(() => {
localStorage.clear()
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
mockIsNightly.value = true
mockIsCloud.value = false
mockIsDesktop.value = false
})
afterEach(() => {
localStorage.clear()
vi.useRealTimers()
})
function setFeatureUsage(featureId: string, useCount: number) {
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
existing[featureId] = {
useCount,
firstUsed: Date.now() - 1000,
lastUsed: Date.now()
}
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
}
describe('eligibility checks', () => {
it('is not eligible when not nightly', async () => {
mockIsNightly.value = false
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on cloud', async () => {
mockIsCloud.value = true
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on desktop', async () => {
mockIsDesktop.value = true
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible below threshold', async () => {
setFeatureUsage('test-feature', 2)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, hasReachedThreshold } =
useSurveyEligibility(defaultConfig)
expect(hasReachedThreshold.value).toBe(false)
expect(isEligible.value).toBe(false)
})
it('is eligible when all conditions met', async () => {
setFeatureUsage('test-feature', 3)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
})
it('respects custom threshold', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility({
...defaultConfig,
triggerThreshold: 10
})
expect(isEligible.value).toBe(false)
})
it('is not eligible when survey already seen', async () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: { 'test-feature': Date.now() },
lastSurveyShown: null,
optedOut: false
})
)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, hasSeenSurvey } = useSurveyEligibility(defaultConfig)
expect(hasSeenSurvey.value).toBe(true)
expect(isEligible.value).toBe(false)
})
it('is not eligible during global cooldown', async () => {
setFeatureUsage('test-feature', 5)
const thirteenDaysAgo = Date.now() - 13 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: { 'other-feature': thirteenDaysAgo },
lastSurveyShown: thirteenDaysAgo,
optedOut: false
})
)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, isInGlobalCooldown } =
useSurveyEligibility(defaultConfig)
expect(isInGlobalCooldown.value).toBe(true)
expect(isEligible.value).toBe(false)
})
it('is eligible after global cooldown expires', async () => {
setFeatureUsage('test-feature', 5)
const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: { 'other-feature': fifteenDaysAgo },
lastSurveyShown: fifteenDaysAgo,
optedOut: false
})
)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, isInGlobalCooldown } =
useSurveyEligibility(defaultConfig)
expect(isInGlobalCooldown.value).toBe(false)
expect(isEligible.value).toBe(true)
})
it('is not eligible when opted out', async () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: {},
lastSurveyShown: null,
optedOut: true
})
)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, hasOptedOut } = useSurveyEligibility(defaultConfig)
expect(hasOptedOut.value).toBe(true)
expect(isEligible.value).toBe(false)
})
it('is not eligible when config disabled', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility({
...defaultConfig,
enabled: false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {
it('markSurveyShown marks feature as seen and sets cooldown', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, markSurveyShown, hasSeenSurvey, isInGlobalCooldown } =
useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
markSurveyShown()
expect(hasSeenSurvey.value).toBe(true)
expect(isInGlobalCooldown.value).toBe(true)
expect(isEligible.value).toBe(false)
})
it('optOut prevents all future surveys', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible, optOut, hasOptedOut } =
useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
optOut()
expect(hasOptedOut.value).toBe(true)
expect(isEligible.value).toBe(false)
})
it('resetState clears all survey state', async () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: { 'test-feature': Date.now() },
lastSurveyShown: Date.now(),
optedOut: true
})
)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { resetState, hasSeenSurvey, isInGlobalCooldown, hasOptedOut } =
useSurveyEligibility(defaultConfig)
expect(hasSeenSurvey.value).toBe(true)
expect(isInGlobalCooldown.value).toBe(true)
expect(hasOptedOut.value).toBe(true)
resetState()
expect(hasSeenSurvey.value).toBe(false)
expect(isInGlobalCooldown.value).toBe(false)
expect(hasOptedOut.value).toBe(false)
})
})
describe('sampling', () => {
it('creates stable user sampling ID', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility({
...defaultConfig,
sampleRate: 0.5
})
// Access isEligible to trigger sampling check
void isEligible.value
const userId = localStorage.getItem(USER_SAMPLING_ID_KEY)
expect(userId).toBeTruthy()
expect(userId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
)
})
it('reuses existing user sampling ID', async () => {
const existingId = '12345678-1234-1234-1234-123456789012'
localStorage.setItem(USER_SAMPLING_ID_KEY, existingId)
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
useSurveyEligibility({ ...defaultConfig, sampleRate: 0.5 })
expect(localStorage.getItem(USER_SAMPLING_ID_KEY)).toBe(existingId)
})
it('sample rate of 0 excludes all users', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility({
...defaultConfig,
sampleRate: 0
})
expect(isEligible.value).toBe(false)
})
it('sample rate of 1 includes all users', async () => {
setFeatureUsage('test-feature', 5)
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { isEligible } = useSurveyEligibility({
...defaultConfig,
sampleRate: 1
})
expect(isEligible.value).toBe(true)
})
})
describe('config values', () => {
it('exposes delayMs from config', async () => {
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { delayMs } = useSurveyEligibility({
...defaultConfig,
delayMs: 10000
})
expect(delayMs.value).toBe(10000)
})
it('uses default delayMs when not specified', async () => {
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { delayMs } = useSurveyEligibility(defaultConfig)
expect(delayMs.value).toBe(5000)
})
})
describe('persistence', () => {
it('loads existing state from localStorage', async () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
seenSurveys: { 'test-feature': 1000 },
lastSurveyShown: 1000,
optedOut: false
})
)
vi.resetModules()
const { useSurveyEligibility } = await import('./useSurveyEligibility')
const { hasSeenSurvey } = useSurveyEligibility(defaultConfig)
expect(hasSeenSurvey.value).toBe(true)
})
})
})

View File

@@ -0,0 +1,140 @@
import { useStorage } from '@vueuse/core'
import type { MaybeRefOrGetter } from 'vue'
import { computed, toValue } from 'vue'
import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types'
import { useFeatureUsageTracker } from './useFeatureUsageTracker'
/** @public */
export interface FeatureSurveyConfig {
featureId: string
typeformId: string
triggerThreshold?: number
delayMs?: number
sampleRate?: number
enabled?: boolean
}
interface SurveyState {
seenSurveys: Record<string, number>
lastSurveyShown: number | null
optedOut: boolean
}
const STORAGE_KEY = 'Comfy.SurveyState'
const GLOBAL_COOLDOWN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days
const DEFAULT_THRESHOLD = 3
const DEFAULT_DELAY_MS = 5000
const DEFAULT_SAMPLE_RATE = 1
function getStorageState() {
return useStorage<SurveyState>(STORAGE_KEY, {
seenSurveys: {},
lastSurveyShown: null,
optedOut: false
})
}
export function useSurveyEligibility(
config: MaybeRefOrGetter<FeatureSurveyConfig>
) {
const state = getStorageState()
const resolvedConfig = computed(() => toValue(config))
const { useCount } = useFeatureUsageTracker(resolvedConfig.value.featureId)
const threshold = computed(
() => resolvedConfig.value.triggerThreshold ?? DEFAULT_THRESHOLD
)
const delayMs = computed(
() => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS
)
const sampleRate = computed(
() => resolvedConfig.value.sampleRate ?? DEFAULT_SAMPLE_RATE
)
const enabled = computed(() => resolvedConfig.value.enabled ?? true)
const isNightlyLocalhost = computed(() => isNightly && !isCloud && !isDesktop)
const hasReachedThreshold = computed(() => useCount.value >= threshold.value)
const hasSeenSurvey = computed(
() => !!state.value.seenSurveys[resolvedConfig.value.featureId]
)
const isInGlobalCooldown = computed(() => {
if (!state.value.lastSurveyShown) return false
return Date.now() - state.value.lastSurveyShown < GLOBAL_COOLDOWN_MS
})
const hasOptedOut = computed(() => state.value.optedOut)
const isEligible = computed(() => {
if (!enabled.value) return false
if (!isNightlyLocalhost.value) return false
if (!hasReachedThreshold.value) return false
if (hasSeenSurvey.value) return false
if (isInGlobalCooldown.value) return false
if (hasOptedOut.value) return false
if (sampleRate.value < 1) {
const userId = getUserSamplingId()
if (!isUserInSample(userId, sampleRate.value)) return false
}
return true
})
function markSurveyShown() {
const now = Date.now()
state.value.seenSurveys[resolvedConfig.value.featureId] = now
state.value.lastSurveyShown = now
}
function optOut() {
state.value.optedOut = true
}
function resetState() {
state.value = {
seenSurveys: {},
lastSurveyShown: null,
optedOut: false
}
}
return {
isEligible,
hasReachedThreshold,
hasSeenSurvey,
isInGlobalCooldown,
hasOptedOut,
delayMs,
markSurveyShown,
optOut,
resetState
}
}
const USER_SAMPLING_ID_KEY = 'Comfy.SurveyUserId'
function getUserSamplingId(): string {
let id = localStorage.getItem(USER_SAMPLING_ID_KEY)
if (!id) {
id = crypto.randomUUID()
localStorage.setItem(USER_SAMPLING_ID_KEY, id)
}
return id
}
function isUserInSample(userId: string, sampleRate: number): boolean {
let hash = 0
for (let i = 0; i < userId.length; i++) {
const char = userId.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
const normalized = Math.abs(hash) / 0x7fffffff
return normalized < sampleRate
}