From e8b088ce50cfab937d1ce8c80498de5a00867f55 Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 27 Jan 2026 20:31:00 -0800 Subject: [PATCH] feat: add composable to determine if user is eligible for nightly survey(s) (#8189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 Co-authored-by: Alexander Brown --- .../surveys/useSurveyEligibility.test.ts | 259 ++++++++++++++++++ src/platform/surveys/useSurveyEligibility.ts | 97 +++++++ 2 files changed, 356 insertions(+) create mode 100644 src/platform/surveys/useSurveyEligibility.test.ts create mode 100644 src/platform/surveys/useSurveyEligibility.ts diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts new file mode 100644 index 000000000..a99a6e1d9 --- /dev/null +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -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) + }) + }) +}) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts new file mode 100644 index 000000000..37929e25c --- /dev/null +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -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 +} + +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 +) { + const state = useStorage(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 + } +}