From 5df793b721c02ef93e7278b4d7bbbcf26ccc11fc Mon Sep 17 00:00:00 2001 From: Christian Byrne Date: Tue, 20 Jan 2026 13:35:54 -0800 Subject: [PATCH] feat: add feature usage tracker for nightly surveys (#8175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `useFeatureUsageTracker` composable that tracks how many times a user has used a specific feature, along with first and last usage timestamps. Data persists to localStorage using `@vueuse/core`'s `useStorage`. This composable provides the foundation for triggering surveys after a configurable number of feature uses. Includes comprehensive unit tests. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-8175-feat-add-feature-usage-tracker-for-nightly-surveys-2ee6d73d36508118859ece6fcf17561d) by [Unito](https://www.unito.io) --- .../surveys/useFeatureUsageTracker.test.ts | 131 ++++++++++++++++++ .../surveys/useFeatureUsageTracker.ts | 46 ++++++ 2 files changed, 177 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 000000000..5313e9eaf --- /dev/null +++ b/src/platform/surveys/useFeatureUsageTracker.test.ts @@ -0,0 +1,131 @@ +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 () => { + vi.useFakeTimers() + const firstTs = 1000000 + vi.setSystemTime(firstTs) + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { usage, trackUsage } = useFeatureUsageTracker('test-feature') + + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) + + vi.setSystemTime(firstTs + 5000) + trackUsage() + expect(usage.value?.firstUsed).toBe(firstTs) + } finally { + vi.useRealTimers() + } + }) + + it('updates lastUsed on each use', async () => { + 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 () => { + 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 () => { + vi.useFakeTimers() + try { + const { useFeatureUsageTracker } = + await import('./useFeatureUsageTracker') + const { trackUsage } = useFeatureUsageTracker('persisted-feature') + + trackUsage() + await vi.runAllTimersAsync() + + 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 () => { + 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 000000000..b8d825db4 --- /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 + } +}