feat: add composable to determine if user is eligible for nightly survey(s) (#8189)

## Summary

Adds `useSurveyEligibility` composable that determines whether a user
should see a survey based on multiple criteria: nightly localhost build
only (`isNightly && !isCloud && !isDesktop`), configurable usage
threshold (default 3), 14-day global cooldown between any surveys,
once-per-feature-ever display, optional percentage-based sampling, and
user opt-out support. All state persists to localStorage. Includes
extensive unit tests covering all eligibility conditions.

See:

- https://github.com/Comfy-Org/ComfyUI_frontend/pull/8149
- https://github.com/Comfy-Org/ComfyUI_frontend/pull/8175

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8189-feat-add-composable-to-determine-if-user-is-eligible-for-nightly-survey-s-2ee6d73d365081f088f2fd76032cc60a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Alexander Brown <drjkl@comfy.org>
This commit is contained in:
Christian Byrne
2026-01-27 20:31:00 -08:00
committed by GitHub
parent 34fc28a39d
commit e8b088ce50
2 changed files with 356 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSurveyEligibility } from './useSurveyEligibility'
const SURVEY_STATE_KEY = 'Comfy.SurveyState'
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
const mockDistribution = vi.hoisted(() => ({
isNightly: true,
isCloud: false,
isDesktop: false
}))
vi.mock('@/platform/distribution/types', () => ({
get isNightly() {
return mockDistribution.isNightly
},
get isCloud() {
return mockDistribution.isCloud
},
get isDesktop() {
return mockDistribution.isDesktop
}
}))
describe('useSurveyEligibility', () => {
const defaultConfig = {
featureId: 'test-feature',
typeformId: 'abc123'
}
beforeEach(() => {
localStorage.clear()
vi.useFakeTimers()
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
mockDistribution.isNightly = true
mockDistribution.isCloud = false
mockDistribution.isDesktop = 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', () => {
mockDistribution.isNightly = false
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on cloud', () => {
mockDistribution.isCloud = true
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible on desktop', () => {
mockDistribution.isDesktop = true
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible below threshold', () => {
setFeatureUsage('test-feature', 2)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is eligible when all conditions met', () => {
setFeatureUsage('test-feature', 3)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
})
it('respects custom threshold', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
triggerThreshold: 10
})
expect(isEligible.value).toBe(false)
})
it('is not eligible when survey already seen', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'test-feature': Date.now() }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible during global cooldown', () => {
setFeatureUsage('test-feature', 5)
const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'other-feature': threeDaysAgo }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is eligible after global cooldown expires', () => {
setFeatureUsage('test-feature', 5)
const fiveDaysAgo = Date.now() - 5 * 24 * 60 * 60 * 1000
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'other-feature': fiveDaysAgo }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
})
it('is not eligible when opted out', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: true,
seenSurveys: {}
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
it('is not eligible when config disabled', () => {
setFeatureUsage('test-feature', 5)
const { isEligible } = useSurveyEligibility({
...defaultConfig,
enabled: false
})
expect(isEligible.value).toBe(false)
})
})
describe('actions', () => {
it('markSurveyShown makes user ineligible', () => {
setFeatureUsage('test-feature', 5)
const { isEligible, markSurveyShown } =
useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
markSurveyShown()
expect(isEligible.value).toBe(false)
})
it('optOut prevents all future surveys', () => {
setFeatureUsage('test-feature', 5)
const { isEligible, optOut } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(true)
optOut()
expect(isEligible.value).toBe(false)
})
it('resetState restores eligibility', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: true,
seenSurveys: { 'test-feature': Date.now() }
})
)
const { isEligible, resetState } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
resetState()
expect(isEligible.value).toBe(true)
})
})
describe('config values', () => {
it('exposes delayMs from config', () => {
const { delayMs } = useSurveyEligibility({
...defaultConfig,
delayMs: 10000
})
expect(delayMs.value).toBe(10000)
})
})
describe('persistence', () => {
it('loads existing state from localStorage', () => {
setFeatureUsage('test-feature', 5)
localStorage.setItem(
SURVEY_STATE_KEY,
JSON.stringify({
optedOut: false,
seenSurveys: { 'test-feature': 1000 }
})
)
const { isEligible } = useSurveyEligibility(defaultConfig)
expect(isEligible.value).toBe(false)
})
})
})

View File

@@ -0,0 +1,97 @@
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'
interface FeatureSurveyConfig {
/** Feature identifier. Must remain static after initialization. */
featureId: string
typeformId: string
triggerThreshold?: number
delayMs?: number
enabled?: boolean
}
interface SurveyState {
optedOut: boolean
seenSurveys: Record<string, number>
}
const STORAGE_KEY = 'Comfy.SurveyState'
const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days
const DEFAULT_THRESHOLD = 3
const DEFAULT_DELAY_MS = 5000
export function useSurveyEligibility(
config: MaybeRefOrGetter<FeatureSurveyConfig>
) {
const state = useStorage<SurveyState>(STORAGE_KEY, {
optedOut: false,
seenSurveys: {}
})
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 isSurveyEnabled = 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(() => {
const timestamps = Object.values(state.value.seenSurveys)
if (timestamps.length === 0) return false
const lastShown = Math.max(...timestamps)
return Date.now() - lastShown < GLOBAL_COOLDOWN_MS
})
const hasOptedOut = computed(() => state.value.optedOut)
const isEligible = computed(() => {
if (!isSurveyEnabled.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
return true
})
function markSurveyShown() {
state.value.seenSurveys[resolvedConfig.value.featureId] = Date.now()
}
function optOut() {
state.value.optedOut = true
}
function resetState() {
state.value = {
optedOut: false,
seenSurveys: {}
}
}
return {
isEligible,
delayMs,
markSurveyShown,
optOut,
resetState
}
}