mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-29 18:52:19 +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