From 03ffc6b4c37c42b5c72811c84984274277e9c174 Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 19 Jan 2026 19:24:59 -0800 Subject: [PATCH 01/14] feat: add useFeatureUsageTracker composable for survey eligibility --- .../surveys/useFeatureUsageTracker.test.ts | 117 ++++++++++++++++++ .../surveys/useFeatureUsageTracker.ts | 46 +++++++ 2 files changed, 163 insertions(+) create mode 100644 src/platform/surveys/useFeatureUsageTracker.test.ts create mode 100644 src/platform/surveys/useFeatureUsageTracker.ts diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts new file mode 100644 index 00000000000..6c45ba0514a --- /dev/null +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const STORAGE_KEY = 'comfy.featureUsage' + +describe('useFeatureUsageTracker', () => { + beforeEach(() => { + localStorage.clear() + vi.resetModules() + }) + + afterEach(() => { + localStorage.clear() + }) + + it('initializes with zero count for new feature', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount } = useFeatureUsageTracker('test-feature') + + expect(useCount.value).toBe(0) + }) + + it('increments count on trackUsage', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount, trackUsage } = useFeatureUsageTracker('test-feature') + + expect(useCount.value).toBe(0) + + trackUsage() + expect(useCount.value).toBe(1) + + trackUsage() + expect(useCount.value).toBe(2) + }) + + it('sets firstUsed only on first use', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + const beforeFirst = Date.now() + trackUsage() + const afterFirst = Date.now() + + const firstUsed = usage.value?.firstUsed ?? 0 + expect(firstUsed).toBeGreaterThanOrEqual(beforeFirst) + expect(firstUsed).toBeLessThanOrEqual(afterFirst) + + trackUsage() + expect(usage.value?.firstUsed).toBe(firstUsed) + }) + + it('updates lastUsed on each use', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + trackUsage() + const firstLastUsed = usage.value?.lastUsed ?? 0 + + await new Promise((r) => setTimeout(r, 10)) + trackUsage() + + expect(usage.value?.lastUsed).toBeGreaterThan(firstLastUsed) + }) + + it('reset clears feature data', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount, trackUsage, reset } = + useFeatureUsageTracker('test-feature') + + trackUsage() + trackUsage() + expect(useCount.value).toBe(2) + + reset() + expect(useCount.value).toBe(0) + }) + + it('tracks multiple features independently', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const featureA = useFeatureUsageTracker('feature-a') + const featureB = useFeatureUsageTracker('feature-b') + + featureA.trackUsage() + featureA.trackUsage() + featureB.trackUsage() + + expect(featureA.useCount.value).toBe(2) + expect(featureB.useCount.value).toBe(1) + }) + + it('persists to localStorage', async () => { + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { trackUsage } = useFeatureUsageTracker('persisted-feature') + + trackUsage() + + // useStorage flushes async, wait a tick + await new Promise((r) => setTimeout(r, 0)) + + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored['persisted-feature']?.useCount).toBe(1) + }) + + it('loads existing data from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + 'existing-feature': { useCount: 5, firstUsed: 1000, lastUsed: 2000 } + }) + ) + + vi.resetModules() + const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') + const { useCount } = useFeatureUsageTracker('existing-feature') + + expect(useCount.value).toBe(5) + }) +}) diff --git a/src/platform/surveys/useFeatureUsageTracker.ts b/src/platform/surveys/useFeatureUsageTracker.ts new file mode 100644 index 00000000000..0488f480695 --- /dev/null +++ b/src/platform/surveys/useFeatureUsageTracker.ts @@ -0,0 +1,46 @@ +import { useStorage } from '@vueuse/core' +import { computed } from 'vue' + +interface FeatureUsage { + useCount: number + firstUsed: number + lastUsed: number +} + +type FeatureUsageRecord = Record + +const STORAGE_KEY = 'comfy.featureUsage' + +/** + * Tracks feature usage for survey eligibility. + * Persists to localStorage. + */ +export function useFeatureUsageTracker(featureId: string) { + const usageData = useStorage(STORAGE_KEY, {}) + + const usage = computed(() => usageData.value[featureId]) + + const useCount = computed(() => usage.value?.useCount ?? 0) + + function trackUsage() { + const now = Date.now() + const existing = usageData.value[featureId] + + usageData.value[featureId] = { + useCount: (existing?.useCount ?? 0) + 1, + firstUsed: existing?.firstUsed ?? now, + lastUsed: now + } + } + + function reset() { + delete usageData.value[featureId] + } + + return { + usage, + useCount, + trackUsage, + reset + } +} From 184d91cb91fa0207bebfe8427e5ed363b06eeb8a Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 19 Jan 2026 21:43:56 -0800 Subject: [PATCH 02/14] fix: use fake timers in lastUsed test for deterministic CI --- .../surveys/useFeatureUsageTracker.test.ts | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts index 6c45ba0514a..78edcfce552 100644 --- a/src/platform/surveys/useFeatureUsageTracker.test.ts +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -49,16 +49,22 @@ describe('useFeatureUsageTracker', () => { }) it('updates lastUsed on each use', async () => { - const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') - const { usage, trackUsage } = useFeatureUsageTracker('test-feature') - - trackUsage() - const firstLastUsed = usage.value?.lastUsed ?? 0 - - await new Promise((r) => setTimeout(r, 10)) - trackUsage() - - expect(usage.value?.lastUsed).toBeGreaterThan(firstLastUsed) + vi.useFakeTimers() + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + trackUsage() + const firstLastUsed = usage.value?.lastUsed ?? 0 + + vi.advanceTimersByTime(10) + trackUsage() + + expect(usage.value?.lastUsed).toBeGreaterThan(firstLastUsed) + } finally { + vi.useRealTimers() + } }) it('reset clears feature data', async () => { From c90217da59d9674544ec54e9b31801315d6619a6 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 20 Jan 2026 11:27:31 -0800 Subject: [PATCH 03/14] fix: use fake timers in persistence test for deterministic CI --- .../surveys/useFeatureUsageTracker.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts index 78edcfce552..622b190597f 100644 --- a/src/platform/surveys/useFeatureUsageTracker.test.ts +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -94,16 +94,20 @@ describe('useFeatureUsageTracker', () => { }) it('persists to localStorage', async () => { - const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') - const { trackUsage } = useFeatureUsageTracker('persisted-feature') - - trackUsage() + vi.useFakeTimers() + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { trackUsage } = useFeatureUsageTracker('persisted-feature') - // useStorage flushes async, wait a tick - await new Promise((r) => setTimeout(r, 0)) + trackUsage() + await vi.runAllTimersAsync() - const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') - expect(stored['persisted-feature']?.useCount).toBe(1) + const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') + expect(stored['persisted-feature']?.useCount).toBe(1) + } finally { + vi.useRealTimers() + } }) it('loads existing data from localStorage', async () => { From 7d2195bdeb85426afcb604cd507de98918aba6c5 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 20 Jan 2026 12:38:29 -0800 Subject: [PATCH 04/14] fix: use fake timers in firstUsed test for deterministic timestamps --- .../surveys/useFeatureUsageTracker.test.ts | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts index 622b190597f..cb99313b19d 100644 --- a/src/platform/surveys/useFeatureUsageTracker.test.ts +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -33,19 +33,23 @@ describe('useFeatureUsageTracker', () => { }) it('sets firstUsed only on first use', async () => { - const { useFeatureUsageTracker } = await import('./useFeatureUsageTracker') - const { usage, trackUsage } = useFeatureUsageTracker('test-feature') - - const beforeFirst = Date.now() - trackUsage() - const afterFirst = Date.now() + vi.useFakeTimers() + const firstTs = 1000000 + vi.setSystemTime(firstTs) + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') - const firstUsed = usage.value?.firstUsed ?? 0 - expect(firstUsed).toBeGreaterThanOrEqual(beforeFirst) - expect(firstUsed).toBeLessThanOrEqual(afterFirst) + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) - trackUsage() - expect(usage.value?.firstUsed).toBe(firstUsed) + vi.setSystemTime(firstTs + 5000) + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) + } finally { + vi.useRealTimers() + } }) it('updates lastUsed on each use', async () => { From 0ad85d10cf0465f2b7d748d4bacb040396b81c79 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 20 Jan 2026 13:28:26 -0800 Subject: [PATCH 05/14] refactor: rename storage key to Comfy.FeatureUsage for consistency --- src/platform/surveys/useFeatureUsageTracker.test.ts | 2 +- src/platform/surveys/useFeatureUsageTracker.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/surveys/useFeatureUsageTracker.test.ts b/src/platform/surveys/useFeatureUsageTracker.test.ts index cb99313b19d..5313e9eafd4 100644 --- a/src/platform/surveys/useFeatureUsageTracker.test.ts +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const STORAGE_KEY = 'comfy.featureUsage' +const STORAGE_KEY = 'Comfy.FeatureUsage' describe('useFeatureUsageTracker', () => { beforeEach(() => { diff --git a/src/platform/surveys/useFeatureUsageTracker.ts b/src/platform/surveys/useFeatureUsageTracker.ts index 0488f480695..b8d825db486 100644 --- a/src/platform/surveys/useFeatureUsageTracker.ts +++ b/src/platform/surveys/useFeatureUsageTracker.ts @@ -9,7 +9,7 @@ interface FeatureUsage { type FeatureUsageRecord = Record -const STORAGE_KEY = 'comfy.featureUsage' +const STORAGE_KEY = 'Comfy.FeatureUsage' /** * Tracks feature usage for survey eligibility. From 7291bf872fef1de718175a470b7790fd1dc7aa96 Mon Sep 17 00:00:00 2001 From: bymyself Date: Mon, 19 Jan 2026 19:25:26 -0800 Subject: [PATCH 06/14] 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 00000000000..131d88bef1d --- /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 00000000000..11bb81b40b3 --- /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 +} From c4f945f225609efe464ed8187620cb52698c075c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 20 Jan 2026 21:44:22 +0000 Subject: [PATCH 07/14] [automated] Apply ESLint and Prettier fixes --- .../assets/components/MediaAssetFilterBar.vue | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/platform/assets/components/MediaAssetFilterBar.vue b/src/platform/assets/components/MediaAssetFilterBar.vue index 0442f5f5653..6e4ec5e4c16 100644 --- a/src/platform/assets/components/MediaAssetFilterBar.vue +++ b/src/platform/assets/components/MediaAssetFilterBar.vue @@ -6,20 +6,36 @@ @update:model-value="handleSearchChange" />
- + - + - +
From bba13135d17ec767dd0402a0a677b393d63379a2 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 20 Jan 2026 14:30:05 -0800 Subject: [PATCH 08/14] refactor: remove sampling logic for small nightly cohort - Remove sampleRate config option, getUserSamplingId, isUserInSample - Simplifies eligibility to ~2k nightly users without random sampling - Addresses coderabbitai review: hash edge case bug, YAGNI for cohort size - Document featureId must remain static after initialization --- .../surveys/useSurveyEligibility.test.ts | 57 ------------------- src/platform/surveys/useSurveyEligibility.ts | 33 +---------- 2 files changed, 1 insertion(+), 89 deletions(-) diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts index 131d88bef1d..91c6deec0dc 100644 --- a/src/platform/surveys/useSurveyEligibility.test.ts +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -2,7 +2,6 @@ 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 })) @@ -263,62 +262,6 @@ describe('useSurveyEligibility', () => { }) }) - 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') diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts index 11bb81b40b3..0cdf64f672f 100644 --- a/src/platform/surveys/useSurveyEligibility.ts +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -8,11 +8,11 @@ import { useFeatureUsageTracker } from './useFeatureUsageTracker' /** @public */ export interface FeatureSurveyConfig { + /** Feature identifier. Must remain static after initialization. */ featureId: string typeformId: string triggerThreshold?: number delayMs?: number - sampleRate?: number enabled?: boolean } @@ -26,7 +26,6 @@ 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, { @@ -50,9 +49,6 @@ export function useSurveyEligibility( 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) @@ -78,11 +74,6 @@ export function useSurveyEligibility( 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 }) @@ -116,25 +107,3 @@ export function useSurveyEligibility( 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 -} From b650bbf543ab90cc11ff5be6091636e9ba01bef1 Mon Sep 17 00:00:00 2001 From: bymyself Date: Fri, 23 Jan 2026 20:50:50 -0800 Subject: [PATCH 09/14] refactor: reduce global survey cooldown from 14 to 4 days --- src/platform/surveys/useSurveyEligibility.test.ts | 12 ++++++------ src/platform/surveys/useSurveyEligibility.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts index 91c6deec0dc..a524ed68502 100644 --- a/src/platform/surveys/useSurveyEligibility.test.ts +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -134,12 +134,12 @@ describe('useSurveyEligibility', () => { it('is not eligible during global cooldown', async () => { setFeatureUsage('test-feature', 5) - const thirteenDaysAgo = Date.now() - 13 * 24 * 60 * 60 * 1000 + const threeDaysAgo = Date.now() - 3 * 24 * 60 * 60 * 1000 localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: { 'other-feature': thirteenDaysAgo }, - lastSurveyShown: thirteenDaysAgo, + seenSurveys: { 'other-feature': threeDaysAgo }, + lastSurveyShown: threeDaysAgo, optedOut: false }) ) @@ -154,12 +154,12 @@ describe('useSurveyEligibility', () => { it('is eligible after global cooldown expires', async () => { setFeatureUsage('test-feature', 5) - const fifteenDaysAgo = Date.now() - 15 * 24 * 60 * 60 * 1000 + const fiveDaysAgo = Date.now() - 5 * 24 * 60 * 60 * 1000 localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: { 'other-feature': fifteenDaysAgo }, - lastSurveyShown: fifteenDaysAgo, + seenSurveys: { 'other-feature': fiveDaysAgo }, + lastSurveyShown: fiveDaysAgo, optedOut: false }) ) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts index 0cdf64f672f..abf1914351b 100644 --- a/src/platform/surveys/useSurveyEligibility.ts +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -23,7 +23,7 @@ interface SurveyState { } const STORAGE_KEY = 'Comfy.SurveyState' -const GLOBAL_COOLDOWN_MS = 14 * 24 * 60 * 60 * 1000 // 14 days +const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days const DEFAULT_THRESHOLD = 3 const DEFAULT_DELAY_MS = 5000 From 94004f0d846164b91948aae3ed2888a51d172b6c Mon Sep 17 00:00:00 2001 From: bymyself Date: Fri, 23 Jan 2026 20:59:35 -0800 Subject: [PATCH 10/14] refactor: remove @public annotation and inline getStorageState --- src/platform/surveys/useSurveyEligibility.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts index abf1914351b..a586958bee4 100644 --- a/src/platform/surveys/useSurveyEligibility.ts +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -6,7 +6,6 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types' import { useFeatureUsageTracker } from './useFeatureUsageTracker' -/** @public */ export interface FeatureSurveyConfig { /** Feature identifier. Must remain static after initialization. */ featureId: string @@ -27,18 +26,14 @@ const GLOBAL_COOLDOWN_MS = 4 * 24 * 60 * 60 * 1000 // 4 days const DEFAULT_THRESHOLD = 3 const DEFAULT_DELAY_MS = 5000 -function getStorageState() { - return useStorage(STORAGE_KEY, { +export function useSurveyEligibility( + config: MaybeRefOrGetter +) { + const state = 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) From cf4d49fc9dea085b46d307b67f25e7677153b7b9 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Sat, 24 Jan 2026 00:11:47 -0800 Subject: [PATCH 11/14] Unused export --- src/platform/surveys/useSurveyEligibility.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts index a586958bee4..b69f3e2dc9a 100644 --- a/src/platform/surveys/useSurveyEligibility.ts +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -6,7 +6,7 @@ import { isCloud, isDesktop, isNightly } from '@/platform/distribution/types' import { useFeatureUsageTracker } from './useFeatureUsageTracker' -export interface FeatureSurveyConfig { +interface FeatureSurveyConfig { /** Feature identifier. Must remain static after initialization. */ featureId: string typeformId: string From 2c5b1fb63bfb14b2b1d2319bc429677e57565ca9 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 27 Jan 2026 18:35:16 -0800 Subject: [PATCH 12/14] refactor: address review feedback on survey eligibility - Rename 'enabled' to 'isSurveyEnabled' for clarity - Remove lastSurveyShown field, derive from seenSurveys collection - Make SurveyState interface fields optional and alphabetically sorted - Reduce exposed return values to just isEligible, delayMs, and actions - Simplify mock object in tests, use top-level import - Update tests to verify behavior through isEligible only --- .../surveys/useSurveyEligibility.test.ts | 153 +++++++----------- src/platform/surveys/useSurveyEligibility.ts | 31 ++-- 2 files changed, 69 insertions(+), 115 deletions(-) diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts index a524ed68502..e108ae07be8 100644 --- a/src/platform/surveys/useSurveyEligibility.test.ts +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -1,21 +1,25 @@ 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 mockIsNightly = vi.hoisted(() => ({ value: true })) -const mockIsCloud = vi.hoisted(() => ({ value: false })) -const mockIsDesktop = vi.hoisted(() => ({ value: false })) +const mockDistribution = vi.hoisted(() => ({ + isNightly: true, + isCloud: false, + isDesktop: false +})) vi.mock('@/platform/distribution/types', () => ({ get isNightly() { - return mockIsNightly.value + return mockDistribution.isNightly }, get isCloud() { - return mockIsCloud.value + return mockDistribution.isCloud }, get isDesktop() { - return mockIsDesktop.value + return mockDistribution.isDesktop } })) @@ -27,13 +31,12 @@ describe('useSurveyEligibility', () => { 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 + mockDistribution.isNightly = true + mockDistribution.isCloud = false + mockDistribution.isDesktop = false }) afterEach(() => { @@ -52,60 +55,52 @@ describe('useSurveyEligibility', () => { } describe('eligibility checks', () => { - it('is not eligible when not nightly', async () => { - mockIsNightly.value = false + it('is not eligible when not nightly', () => { + mockDistribution.isNightly = 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 + it('is not eligible on cloud', () => { + mockDistribution.isCloud = 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 + it('is not eligible on desktop', () => { + mockDistribution.isDesktop = 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 () => { + it('is not eligible below threshold', () => { setFeatureUsage('test-feature', 2) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, hasReachedThreshold } = - useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(hasReachedThreshold.value).toBe(false) expect(isEligible.value).toBe(false) }) - it('is eligible when all conditions met', async () => { + it('is eligible when all conditions met', () => { setFeatureUsage('test-feature', 3) - const { useSurveyEligibility } = await import('./useSurveyEligibility') const { isEligible } = useSurveyEligibility(defaultConfig) expect(isEligible.value).toBe(true) }) - it('respects custom threshold', async () => { + it('respects custom threshold', () => { setFeatureUsage('test-feature', 5) - const { useSurveyEligibility } = await import('./useSurveyEligibility') const { isEligible } = useSurveyEligibility({ ...defaultConfig, triggerThreshold: 10 @@ -114,86 +109,71 @@ describe('useSurveyEligibility', () => { expect(isEligible.value).toBe(false) }) - it('is not eligible when survey already seen', async () => { + it('is not eligible when survey already seen', () => { setFeatureUsage('test-feature', 5) localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: { 'test-feature': Date.now() }, - lastSurveyShown: null, - optedOut: false + optedOut: false, + seenSurveys: { 'test-feature': Date.now() } }) ) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, hasSeenSurvey } = useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(hasSeenSurvey.value).toBe(true) expect(isEligible.value).toBe(false) }) - it('is not eligible during global cooldown', async () => { + 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({ - seenSurveys: { 'other-feature': threeDaysAgo }, - lastSurveyShown: threeDaysAgo, - optedOut: false + optedOut: false, + seenSurveys: { 'other-feature': threeDaysAgo } }) ) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, isInGlobalCooldown } = - useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(isInGlobalCooldown.value).toBe(true) expect(isEligible.value).toBe(false) }) - it('is eligible after global cooldown expires', async () => { + 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({ - seenSurveys: { 'other-feature': fiveDaysAgo }, - lastSurveyShown: fiveDaysAgo, - optedOut: false + optedOut: false, + seenSurveys: { 'other-feature': fiveDaysAgo } }) ) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, isInGlobalCooldown } = - useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(isInGlobalCooldown.value).toBe(false) expect(isEligible.value).toBe(true) }) - it('is not eligible when opted out', async () => { + it('is not eligible when opted out', () => { setFeatureUsage('test-feature', 5) localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: {}, - lastSurveyShown: null, - optedOut: true + optedOut: true, + seenSurveys: {} }) ) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, hasOptedOut } = useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(hasOptedOut.value).toBe(true) expect(isEligible.value).toBe(false) }) - it('is not eligible when config disabled', async () => { + it('is not eligible when config disabled', () => { setFeatureUsage('test-feature', 5) - const { useSurveyEligibility } = await import('./useSurveyEligibility') const { isEligible } = useSurveyEligibility({ ...defaultConfig, enabled: false @@ -204,67 +184,52 @@ describe('useSurveyEligibility', () => { }) describe('actions', () => { - it('markSurveyShown marks feature as seen and sets cooldown', async () => { + it('markSurveyShown makes user ineligible', () => { setFeatureUsage('test-feature', 5) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, markSurveyShown, hasSeenSurvey, isInGlobalCooldown } = - useSurveyEligibility(defaultConfig) + const { isEligible, markSurveyShown } = 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 () => { + it('optOut prevents all future surveys', () => { setFeatureUsage('test-feature', 5) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { isEligible, optOut, hasOptedOut } = - useSurveyEligibility(defaultConfig) + const { isEligible, optOut } = 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 () => { + it('resetState restores eligibility', () => { setFeatureUsage('test-feature', 5) localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: { 'test-feature': Date.now() }, - lastSurveyShown: Date.now(), - optedOut: true + optedOut: true, + seenSurveys: { 'test-feature': Date.now() } }) ) - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { resetState, hasSeenSurvey, isInGlobalCooldown, hasOptedOut } = - useSurveyEligibility(defaultConfig) + const { isEligible, resetState } = useSurveyEligibility(defaultConfig) - expect(hasSeenSurvey.value).toBe(true) - expect(isInGlobalCooldown.value).toBe(true) - expect(hasOptedOut.value).toBe(true) + expect(isEligible.value).toBe(false) resetState() - expect(hasSeenSurvey.value).toBe(false) - expect(isInGlobalCooldown.value).toBe(false) - expect(hasOptedOut.value).toBe(false) + expect(isEligible.value).toBe(true) }) }) describe('config values', () => { - it('exposes delayMs from config', async () => { - const { useSurveyEligibility } = await import('./useSurveyEligibility') + it('exposes delayMs from config', () => { const { delayMs } = useSurveyEligibility({ ...defaultConfig, delayMs: 10000 @@ -273,8 +238,7 @@ describe('useSurveyEligibility', () => { expect(delayMs.value).toBe(10000) }) - it('uses default delayMs when not specified', async () => { - const { useSurveyEligibility } = await import('./useSurveyEligibility') + it('uses default delayMs when not specified', () => { const { delayMs } = useSurveyEligibility(defaultConfig) expect(delayMs.value).toBe(5000) @@ -282,22 +246,19 @@ describe('useSurveyEligibility', () => { }) describe('persistence', () => { - it('loads existing state from localStorage', async () => { + it('loads existing state from localStorage', () => { setFeatureUsage('test-feature', 5) localStorage.setItem( SURVEY_STATE_KEY, JSON.stringify({ - seenSurveys: { 'test-feature': 1000 }, - lastSurveyShown: 1000, - optedOut: false + optedOut: false, + seenSurveys: { 'test-feature': 1000 } }) ) - vi.resetModules() - const { useSurveyEligibility } = await import('./useSurveyEligibility') - const { hasSeenSurvey } = useSurveyEligibility(defaultConfig) + const { isEligible } = useSurveyEligibility(defaultConfig) - expect(hasSeenSurvey.value).toBe(true) + expect(isEligible.value).toBe(false) }) }) }) diff --git a/src/platform/surveys/useSurveyEligibility.ts b/src/platform/surveys/useSurveyEligibility.ts index b69f3e2dc9a..37929e25c83 100644 --- a/src/platform/surveys/useSurveyEligibility.ts +++ b/src/platform/surveys/useSurveyEligibility.ts @@ -16,9 +16,8 @@ interface FeatureSurveyConfig { } interface SurveyState { - seenSurveys: Record - lastSurveyShown: number | null optedOut: boolean + seenSurveys: Record } const STORAGE_KEY = 'Comfy.SurveyState' @@ -30,9 +29,8 @@ export function useSurveyEligibility( config: MaybeRefOrGetter ) { const state = useStorage(STORAGE_KEY, { - seenSurveys: {}, - lastSurveyShown: null, - optedOut: false + optedOut: false, + seenSurveys: {} }) const resolvedConfig = computed(() => toValue(config)) @@ -44,7 +42,7 @@ export function useSurveyEligibility( const delayMs = computed( () => resolvedConfig.value.delayMs ?? DEFAULT_DELAY_MS ) - const enabled = computed(() => resolvedConfig.value.enabled ?? true) + const isSurveyEnabled = computed(() => resolvedConfig.value.enabled ?? true) const isNightlyLocalhost = computed(() => isNightly && !isCloud && !isDesktop) @@ -55,14 +53,16 @@ export function useSurveyEligibility( ) const isInGlobalCooldown = computed(() => { - if (!state.value.lastSurveyShown) return false - return Date.now() - state.value.lastSurveyShown < GLOBAL_COOLDOWN_MS + 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 (!enabled.value) return false + if (!isSurveyEnabled.value) return false if (!isNightlyLocalhost.value) return false if (!hasReachedThreshold.value) return false if (hasSeenSurvey.value) return false @@ -73,9 +73,7 @@ export function useSurveyEligibility( }) function markSurveyShown() { - const now = Date.now() - state.value.seenSurveys[resolvedConfig.value.featureId] = now - state.value.lastSurveyShown = now + state.value.seenSurveys[resolvedConfig.value.featureId] = Date.now() } function optOut() { @@ -84,18 +82,13 @@ export function useSurveyEligibility( function resetState() { state.value = { - seenSurveys: {}, - lastSurveyShown: null, - optedOut: false + optedOut: false, + seenSurveys: {} } } return { isEligible, - hasReachedThreshold, - hasSeenSurvey, - isInGlobalCooldown, - hasOptedOut, delayMs, markSurveyShown, optOut, From 60be7c67883dfd71e782fa4de9c2907405d13602 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 28 Jan 2026 02:37:14 +0000 Subject: [PATCH 13/14] [automated] Apply ESLint and Oxfmt fixes --- src/platform/surveys/useSurveyEligibility.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts index e108ae07be8..713dbe2c7e5 100644 --- a/src/platform/surveys/useSurveyEligibility.test.ts +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -187,7 +187,8 @@ describe('useSurveyEligibility', () => { it('markSurveyShown makes user ineligible', () => { setFeatureUsage('test-feature', 5) - const { isEligible, markSurveyShown } = useSurveyEligibility(defaultConfig) + const { isEligible, markSurveyShown } = + useSurveyEligibility(defaultConfig) expect(isEligible.value).toBe(true) From 1c9de6993321e5042b3932c40fcbfc80bece54a9 Mon Sep 17 00:00:00 2001 From: bymyself Date: Tue, 27 Jan 2026 20:12:28 -0800 Subject: [PATCH 14/14] test: remove change-detector test for default delayMs --- src/platform/surveys/useSurveyEligibility.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/platform/surveys/useSurveyEligibility.test.ts b/src/platform/surveys/useSurveyEligibility.test.ts index 713dbe2c7e5..a99a6e1d9df 100644 --- a/src/platform/surveys/useSurveyEligibility.test.ts +++ b/src/platform/surveys/useSurveyEligibility.test.ts @@ -238,12 +238,6 @@ describe('useSurveyEligibility', () => { expect(delayMs.value).toBe(10000) }) - - it('uses default delayMs when not specified', () => { - const { delayMs } = useSurveyEligibility(defaultConfig) - - expect(delayMs.value).toBe(5000) - }) }) describe('persistence', () => {