mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-10 10:00:08 +00:00
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:
259
src/platform/surveys/useSurveyEligibility.test.ts
Normal file
259
src/platform/surveys/useSurveyEligibility.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
97
src/platform/surveys/useSurveyEligibility.ts
Normal file
97
src/platform/surveys/useSurveyEligibility.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user