From 7291bf872fef1de718175a470b7790fd1dc7aa96 Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 19 Jan 2026 19:25:26 -0800 Subject: [PATCH] feat: add useSurveyEligibility composable for nightly surveys --- .../surveys/useSurveyEligibility.test.ts | 360 ++++++++++++++++++ src/platform/surveys/useSurveyEligibility.ts | 140 +++++++ 2 files changed, 500 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 0000000000..131d88bef1 --- /dev/null +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -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) + }) + }) +}) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts new file mode 100644 index 0000000000..11bb81b40b --- /dev/null +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -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 + 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(STORAGE_KEY, { + seenSurveys: {}, + lastSurveyShown: null, + optedOut: false + }) +} + +export function useSurveyEligibility( + config: MaybeRefOrGetter +) { + 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 +}