Cloud/tracking v2 (#6400)

Backport of #6400

---------

Co-authored-by: Arjan Singh <arjan@comfy.org>
This commit is contained in:
Christian Byrne
2025-10-30 20:46:42 -07:00
committed by Arjan Singh
parent 2383a38aa0
commit b7cbc220e9
12 changed files with 1635 additions and 235 deletions

View File

@@ -43,8 +43,10 @@ import Tag from 'primevue/tag'
import { onBeforeUnmount, ref } from 'vue'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useTelemetry } from '@/platform/telemetry'
const authActions = useFirebaseAuthActions()
const telemetry = useTelemetry()
const {
amount,
@@ -61,8 +63,11 @@ const didClickBuyNow = ref(false)
const loading = ref(false)
const handleBuyNow = async () => {
const creditAmount = editable ? customAmount.value : amount
telemetry?.trackApiCreditTopupButtonPurchaseClicked(creditAmount)
loading.value = true
await authActions.purchaseCredits(editable ? customAmount.value : amount)
await authActions.purchaseCredits(creditAmount)
loading.value = false
didClickBuyNow.value = true
}

View File

@@ -51,7 +51,7 @@
multiple
:option-label="'display_name'"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@option-select="onAddNode($event.value)"
@focused-option-changed="setHoverSuggestion($event)"
>
<template #option="{ option }">
@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import { debounce } from 'es-toolkit/compat'
import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { computed, nextTick, onMounted, ref } from 'vue'
@@ -88,6 +89,7 @@ import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
@@ -96,6 +98,7 @@ import SearchFilterChip from '../common/SearchFilterChip.vue'
const settingStore = useSettingStore()
const { t } = useI18n()
const telemetry = useTelemetry()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
@@ -117,6 +120,14 @@ const placeholder = computed(() => {
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
// Debounced search tracking (500ms as per implementation plan)
const debouncedTrackSearch = debounce((query: string) => {
if (query.trim()) {
telemetry?.trackNodeSearch({ query })
}
}, 500)
const search = (query: string) => {
const queryIsEmpty = query === '' && filters.length === 0
currentQuery.value = query
@@ -127,10 +138,22 @@ const search = (query: string) => {
limit: searchLimit
})
]
// Track search queries with debounce
debouncedTrackSearch(query)
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
// Track node selection and emit addNode event
const onAddNode = (nodeDef: ComfyNodeDefImpl) => {
telemetry?.trackNodeSearchResultSelected({
node_type: nodeDef.name,
last_query: currentQuery.value
})
emit('addNode', nodeDef)
}
let inputElement: HTMLInputElement | null = null
const reFocusInput = async () => {
inputElement ??= document.getElementById(inputId) as HTMLInputElement

View File

@@ -1,8 +1,10 @@
import { refDebounced } from '@vueuse/core'
import { debounce } from 'es-toolkit/compat'
import Fuse from 'fuse.js'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { useTelemetry } from '@/platform/telemetry'
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
export function useTemplateFiltering(
@@ -189,6 +191,38 @@ export function useTemplateFiltering(
const filteredCount = computed(() => filteredTemplates.value.length)
const totalCount = computed(() => templatesArray.value.length)
// Template filter tracking (debounced to avoid excessive events)
const debouncedTrackFilterChange = debounce(() => {
useTelemetry()?.trackTemplateFilterChanged({
search_query: searchQuery.value || undefined,
selected_models: selectedModels.value,
selected_use_cases: selectedUseCases.value,
selected_licenses: selectedLicenses.value,
sort_by: sortBy.value,
filtered_count: filteredCount.value,
total_count: totalCount.value
})
}, 500)
// Watch for filter changes and track them
watch(
[searchQuery, selectedModels, selectedUseCases, selectedLicenses, sortBy],
() => {
// Only track if at least one filter is active (to avoid tracking initial state)
const hasActiveFilters =
searchQuery.value.trim() !== '' ||
selectedModels.value.length > 0 ||
selectedUseCases.value.length > 0 ||
selectedLicenses.value.length > 0 ||
sortBy.value !== 'default'
if (hasActiveFilters) {
debouncedTrackFilterChange()
}
},
{ deep: true }
)
return {
// State
searchQuery,

View File

@@ -52,6 +52,7 @@ const emit = defineEmits<{
}>()
const { subscribe, isActiveSubscription, fetchStatus } = useSubscription()
const telemetry = useTelemetry()
const isLoading = ref(false)
const isPolling = ref(false)
@@ -77,6 +78,7 @@ const startPollingSubscriptionStatus = () => {
if (isActiveSubscription.value) {
stopPolling()
telemetry?.trackMonthlySubscriptionSucceeded()
emit('subscribed')
}
} catch (error) {

View File

@@ -1,7 +1,16 @@
import type { OverridedMixpanel } from 'mixpanel-browser'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import { app } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
import { reduceAllNodes } from '@/utils/graphTraversalUtil'
import type {
AuthMetadata,
CreditTopupMetadata,
ExecutionContext,
ExecutionErrorMetadata,
ExecutionSuccessMetadata,
@@ -21,6 +30,7 @@ import type {
WorkflowImportMetadata
} from '../../types'
import { TelemetryEvents } from '../../types'
import { normalizeSurveyResponses } from '../../utils/surveyNormalization'
interface QueuedEvent {
eventName: TelemetryEventName
@@ -45,16 +55,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
private eventQueue: QueuedEvent[] = []
private isInitialized = false
// Onboarding mode - starts true, set to false when app is fully ready
private isOnboardingMode = true
// Lazy-loaded composables - only imported once when app is ready
private _workflowStore: any = null
private _templatesStore: any = null
private _currentUser: any = null
private _settingStore: any = null
private _composablesReady = false
constructor() {
const token = window.__CONFIG__?.mixpanel_token
@@ -74,6 +74,11 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
loaded: () => {
this.isInitialized = true
this.flushEventQueue() // flush events that were queued while initializing
useCurrentUser().onUserResolved((user) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
}
})
}
})
})
@@ -108,140 +113,17 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
/**
* Identify the current user for telemetry tracking.
* Can be called during onboarding without circular dependencies.
*/
identifyUser(userId: string): void {
if (!this.mixpanel) return
try {
this.mixpanel.identify(userId)
// If we have pending survey responses, set them now that user is identified
if (this.pendingSurveyResponses) {
this.setSurveyUserProperties(this.pendingSurveyResponses)
this.pendingSurveyResponses = null
}
// Load existing survey data if available (only when app is ready)
if (!this.isOnboardingMode) {
this.initializeExistingSurveyData()
}
} catch (error) {
console.error('Failed to identify user:', error)
}
}
/**
* Mark that the main app is fully initialized and advanced telemetry features can be used.
* Call this after the app bootstrap is complete.
*/
markAppReady(): void {
this.isOnboardingMode = false
// Trigger composable initialization now that it's safe
void this.initializeComposables()
}
/**
* Lazy initialization of Vue composables to avoid circular dependencies during module loading.
* Only imports and initializes composables once when app is ready.
*/
private async initializeComposables(): Promise<boolean> {
if (this._composablesReady || this.isOnboardingMode) {
return this._composablesReady
}
try {
// Dynamic imports to avoid circular dependencies during module loading
const [
{ useWorkflowStore },
{ useWorkflowTemplatesStore },
{ useCurrentUser },
{ useSettingStore }
] = await Promise.all([
import('@/platform/workflow/management/stores/workflowStore'),
import(
'@/platform/workflow/templates/repositories/workflowTemplatesStore'
),
import('@/composables/auth/useCurrentUser'),
import('@/platform/settings/settingStore')
])
// Initialize composables once
this._workflowStore = useWorkflowStore()
this._templatesStore = useWorkflowTemplatesStore()
this._currentUser = useCurrentUser()
this._settingStore = useSettingStore()
this._composablesReady = true
// Now that composables are ready, set up user tracking
if (this.mixpanel) {
this._currentUser.onUserResolved((user: any) => {
if (this.mixpanel && user.id) {
this.mixpanel.identify(user.id)
this.initializeExistingSurveyData()
}
})
}
return true
} catch (error) {
console.error('Failed to initialize composables:', error)
return false
}
}
private initializeExistingSurveyData(): void {
if (!this.mixpanel) return
try {
// If composables are ready, use cached store
if (this._settingStore) {
const surveyData = this._settingStore.get('onboarding_survey')
if (surveyData && typeof surveyData === 'object') {
const survey = surveyData as any
this.mixpanel.people.set({
survey_industry: survey.industry,
survey_use_case: survey.useCase,
survey_familiarity: survey.familiarity,
survey_making: survey.making
})
}
}
// If in onboarding mode, try dynamic import (safe since user is identified)
else if (this.isOnboardingMode) {
import('@/platform/settings/settingStore')
.then(({ useSettingStore }) => {
try {
const settingStore = useSettingStore()
const surveyData = settingStore.get('onboarding_survey')
if (surveyData && typeof surveyData === 'object') {
const survey = surveyData as any
this.mixpanel?.people.set({
survey_industry: survey.industry,
survey_use_case: survey.useCase,
survey_familiarity: survey.familiarity,
survey_making: survey.making
})
}
} catch (error) {
console.error(
'Failed to load existing survey data during onboarding:',
error
)
}
})
.catch((error) => {
console.error('Failed to import settings store:', error)
})
}
} catch (error) {
console.error('Failed to initialize existing survey data:', error)
}
}
private trackEvent(
eventName: TelemetryEventName,
properties?: TelemetryEventProperties
@@ -273,6 +155,10 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(TelemetryEvents.USER_AUTH_COMPLETED, metadata)
}
trackUserLoggedIn(): void {
this.trackEvent(TelemetryEvents.USER_LOGGED_IN)
}
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void {
const eventName =
event === 'modal_opened'
@@ -282,17 +168,21 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
this.trackEvent(eventName)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
if (this.isOnboardingMode) {
// During onboarding, track basic run button click without workflow context
this.trackEvent(TelemetryEvents.RUN_BUTTON_CLICKED, {
subscribe_to_run: options?.subscribe_to_run || false,
workflow_type: 'custom',
workflow_name: 'untitled'
})
return
}
trackMonthlySubscriptionSucceeded(): void {
this.trackEvent(TelemetryEvents.MONTHLY_SUBSCRIPTION_SUCCEEDED)
}
trackApiCreditTopupButtonPurchaseClicked(amount: number): void {
const metadata: CreditTopupMetadata = {
credit_amount: amount
}
this.trackEvent(
TelemetryEvents.API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED,
metadata
)
}
trackRunButton(options?: { subscribe_to_run?: boolean }): void {
const executionContext = this.getExecutionContext()
const runButtonProperties: RunButtonProperties = {
@@ -313,46 +203,39 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
? TelemetryEvents.USER_SURVEY_OPENED
: TelemetryEvents.USER_SURVEY_SUBMITTED
// Include survey responses as event properties for submitted events
const eventProperties =
stage === 'submitted' && responses
? {
industry: responses.industry,
useCase: responses.useCase,
familiarity: responses.familiarity,
making: responses.making
}
: undefined
// Apply normalization to survey responses
const normalizedResponses = responses
? normalizeSurveyResponses(responses)
: undefined
this.trackEvent(eventName, eventProperties)
this.trackEvent(eventName, normalizedResponses)
// Also set survey responses as persistent user properties
if (stage === 'submitted' && responses && this.mixpanel) {
// During onboarding, we need to defer user property setting until user is identified
if (this.isOnboardingMode) {
// Store responses to be set once user is identified
this.pendingSurveyResponses = responses
} else {
this.setSurveyUserProperties(responses)
// If this is a survey submission, also set user properties with normalized data
if (stage === 'submitted' && normalizedResponses && this.mixpanel) {
try {
this.mixpanel.people.set(normalizedResponses)
} catch (error) {
console.error('Failed to set survey user properties:', error)
}
}
}
private pendingSurveyResponses: SurveyResponses | null = null
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void {
let eventName: TelemetryEventName
private setSurveyUserProperties(responses: SurveyResponses): void {
if (!this.mixpanel) return
try {
this.mixpanel.people.set({
survey_industry: responses.industry,
survey_use_case: responses.useCase,
survey_familiarity: responses.familiarity,
survey_making: responses.making
})
} catch (error) {
console.error('Failed to set survey user properties:', error)
switch (stage) {
case 'opened':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_OPENED
break
case 'requested':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_REQUESTED
break
case 'completed':
eventName = TelemetryEvents.USER_EMAIL_VERIFY_COMPLETED
break
}
this.trackEvent(eventName)
}
trackTemplate(metadata: TemplateMetadata): void {
@@ -392,15 +275,6 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
trackWorkflowExecution(): void {
if (this.isOnboardingMode) {
// During onboarding, track basic execution without workflow context
this.trackEvent(TelemetryEvents.EXECUTION_START, {
is_template: false,
workflow_name: undefined
})
return
}
const context = this.getExecutionContext()
this.trackEvent(TelemetryEvents.EXECUTION_START, context)
}
@@ -414,67 +288,96 @@ export class MixpanelTelemetryProvider implements TelemetryProvider {
}
getExecutionContext(): ExecutionContext {
// Try to initialize composables if not ready and not in onboarding mode
if (!this._composablesReady && !this.isOnboardingMode) {
void this.initializeComposables()
const workflowStore = useWorkflowStore()
const templatesStore = useWorkflowTemplatesStore()
const nodeDefStore = useNodeDefStore()
const activeWorkflow = workflowStore.activeWorkflow
// Calculate node metrics in a single traversal
type NodeMetrics = {
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
if (
!this._composablesReady ||
!this._workflowStore ||
!this._templatesStore
) {
return {
is_template: false,
workflow_name: undefined
}
}
const nodeCounts = reduceAllNodes<NodeMetrics>(
app.graph,
(metrics, node) => {
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const isCustomNode =
nodeDef?.nodeSource?.type === NodeSourceType.CustomNodes
const isApiNode = nodeDef?.api_node === true
const isSubgraph = node.isSubgraphNode?.() === true
try {
const activeWorkflow = this._workflowStore.activeWorkflow
if (activeWorkflow?.filename) {
const isTemplate = this._templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = this._templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = this._templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license
if (isApiNode) {
metrics.has_api_nodes = true
const canonicalName = nodeDef?.name
if (
canonicalName &&
!metrics.api_node_names.includes(canonicalName)
) {
metrics.api_node_names.push(canonicalName)
}
}
metrics.custom_node_count += isCustomNode ? 1 : 0
metrics.api_node_count += isApiNode ? 1 : 0
metrics.subgraph_count += isSubgraph ? 1 : 0
metrics.total_node_count += 1
return metrics
},
{
custom_node_count: 0,
api_node_count: 0,
subgraph_count: 0,
total_node_count: 0,
has_api_nodes: false,
api_node_names: []
}
)
if (activeWorkflow?.filename) {
const isTemplate = templatesStore.knownTemplateNames.has(
activeWorkflow.filename
)
if (isTemplate) {
const template = templatesStore.getTemplateByName(
activeWorkflow.filename
)
const englishMetadata = templatesStore.getEnglishMetadata(
activeWorkflow.filename
)
return {
is_template: false,
workflow_name: activeWorkflow.filename
is_template: true,
workflow_name: activeWorkflow.filename,
template_source: template?.sourceModule,
template_category: englishMetadata?.category ?? template?.category,
template_tags: englishMetadata?.tags ?? template?.tags,
template_models: englishMetadata?.models ?? template?.models,
template_use_case: englishMetadata?.useCase ?? template?.useCase,
template_license: englishMetadata?.license ?? template?.license,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined
}
} catch (error) {
console.error('Failed to get execution context:', error)
return {
is_template: false,
workflow_name: undefined
workflow_name: activeWorkflow.filename,
...nodeCounts
}
}
return {
is_template: false,
workflow_name: undefined,
...nodeCounts
}
}
}

View File

@@ -57,6 +57,13 @@ export interface ExecutionContext {
template_models?: string[]
template_use_case?: string
template_license?: string
// Node composition metrics
custom_node_count: number
api_node_count: number
subgraph_count: number
total_node_count: number
has_api_nodes: boolean
api_node_names: string[]
}
/**
@@ -89,6 +96,13 @@ export interface TemplateMetadata {
template_license?: string
}
/**
* Credit topup metadata
*/
export interface CreditTopupMetadata {
credit_amount: number
}
/**
* Workflow import metadata
*/
@@ -166,14 +180,20 @@ export interface TelemetryProvider {
// Authentication flow events
trackSignupOpened(): void
trackAuth(metadata: AuthMetadata): void
trackUserLoggedIn(): void
// Subscription flow events
trackSubscription(event: 'modal_opened' | 'subscribe_clicked'): void
trackMonthlySubscriptionSucceeded(): void
trackApiCreditTopupButtonPurchaseClicked(amount: number): void
trackRunButton(options?: { subscribe_to_run?: boolean }): void
// Survey flow events
trackSurvey(stage: 'opened' | 'submitted', responses?: SurveyResponses): void
// Email verification events
trackEmailVerification(stage: 'opened' | 'requested' | 'completed'): void
// Template workflow events
trackTemplate(metadata: TemplateMetadata): void
trackTemplateLibraryOpened(metadata: TemplateLibraryMetadata): void
@@ -216,16 +236,25 @@ export const TelemetryEvents = {
// Authentication Flow
USER_SIGN_UP_OPENED: 'app:user_sign_up_opened',
USER_AUTH_COMPLETED: 'app:user_auth_completed',
USER_LOGGED_IN: 'app:user_logged_in',
// Subscription Flow
RUN_BUTTON_CLICKED: 'app:run_button_click',
SUBSCRIPTION_REQUIRED_MODAL_OPENED: 'app:subscription_required_modal_opened',
SUBSCRIBE_NOW_BUTTON_CLICKED: 'app:subscribe_now_button_clicked',
MONTHLY_SUBSCRIPTION_SUCCEEDED: 'app:monthly_subscription_succeeded',
API_CREDIT_TOPUP_BUTTON_PURCHASE_CLICKED:
'app:api_credit_topup_button_purchase_clicked',
// Onboarding Survey
USER_SURVEY_OPENED: 'app:user_survey_opened',
USER_SURVEY_SUBMITTED: 'app:user_survey_submitted',
// Email Verification
USER_EMAIL_VERIFY_OPENED: 'app:user_email_verify_opened',
USER_EMAIL_VERIFY_REQUESTED: 'app:user_email_verify_requested',
USER_EMAIL_VERIFY_COMPLETED: 'app:user_email_verify_completed',
// Template Tracking
TEMPLATE_WORKFLOW_OPENED: 'app:template_workflow_opened',
TEMPLATE_LIBRARY_OPENED: 'app:template_library_opened',
@@ -267,6 +296,7 @@ export type TelemetryEventProperties =
| RunButtonProperties
| ExecutionErrorMetadata
| ExecutionSuccessMetadata
| CreditTopupMetadata
| WorkflowImportMetadata
| TemplateLibraryMetadata
| TemplateLibraryClosedMetadata

View File

@@ -0,0 +1,682 @@
/**
* Unit tests for survey normalization utilities
* Uses real example data from migration script to verify categorization accuracy
*/
import { describe, expect, it } from 'vitest'
import {
normalizeIndustry,
normalizeSurveyResponses,
normalizeUseCase
} from '../surveyNormalization'
describe('normalizeIndustry', () => {
describe('Film / TV / Animation category', () => {
it('should categorize film and television production', () => {
expect(normalizeIndustry('Film and television production')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('film')).toBe('Film / TV / Animation')
expect(normalizeIndustry('TV production')).toBe('Film / TV / Animation')
expect(normalizeIndustry('animation studio')).toBe(
'Film / TV / Animation'
)
expect(normalizeIndustry('VFX artist')).toBe('Film / TV / Animation')
expect(normalizeIndustry('cinema')).toBe('Film / TV / Animation')
expect(normalizeIndustry('documentary filmmaker')).toBe(
'Film / TV / Animation'
)
})
it('should handle typos and variations', () => {
expect(normalizeIndustry('animtion')).toBe('Film / TV / Animation')
expect(normalizeIndustry('film prod')).toBe('Film / TV / Animation')
expect(normalizeIndustry('movie production')).toBe(
'Film / TV / Animation'
)
})
})
describe('Marketing / Advertising / Social Media category', () => {
it('should categorize marketing and social media', () => {
expect(normalizeIndustry('Marketing & Social Media')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('digital marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('YouTube content creation')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('TikTok marketing')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('brand promotion')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('influencer marketing')).toBe(
'Marketing / Advertising / Social Media'
)
})
it('should handle social media variations', () => {
expect(normalizeIndustry('social content')).toBe(
'Marketing / Advertising / Social Media'
)
expect(normalizeIndustry('content creation')).toBe(
'Marketing / Advertising / Social Media'
)
})
})
describe('Software / IT / AI category', () => {
it('should categorize software development', () => {
expect(normalizeIndustry('Software Development')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('tech startup')).toBe('Software / IT / AI')
expect(normalizeIndustry('AI research')).toBe('Software / IT / AI')
expect(normalizeIndustry('web development')).toBe('Software / IT / AI')
expect(normalizeIndustry('machine learning')).toBe('Software / IT / AI')
expect(normalizeIndustry('data science')).toBe('Software / IT / AI')
expect(normalizeIndustry('programming')).toBe('Software / IT / AI')
})
it('should handle IT variations', () => {
expect(normalizeIndustry('software engineer')).toBe('Software / IT / AI')
expect(normalizeIndustry('app developer')).toBe('Software / IT / AI')
})
it('should categorize corporate AI research', () => {
expect(normalizeIndustry('corporate AI research')).toBe(
'Software / IT / AI'
)
expect(normalizeIndustry('AI research lab')).toBe('Software / IT / AI')
expect(normalizeIndustry('tech company AI research')).toBe(
'Software / IT / AI'
)
})
})
describe('Gaming / Interactive Media category', () => {
it('should categorize gaming industry', () => {
expect(normalizeIndustry('Indie Game Studio')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('game development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('VR development')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('interactive media')).toBe(
'Gaming / Interactive Media'
)
expect(normalizeIndustry('metaverse')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('Unity developer')).toBe(
'Gaming / Interactive Media'
)
})
it('should handle game dev variations', () => {
expect(normalizeIndustry('game dev')).toBe('Gaming / Interactive Media')
expect(normalizeIndustry('indie games')).toBe(
'Gaming / Interactive Media'
)
})
})
describe('Architecture / Engineering / Construction category', () => {
it('should categorize architecture and construction', () => {
expect(normalizeIndustry('Architecture firm')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('construction')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('civil engineering')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('interior design')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('landscape architecture')).toBe(
'Architecture / Engineering / Construction'
)
expect(normalizeIndustry('real estate')).toBe(
'Architecture / Engineering / Construction'
)
})
})
describe('Fashion / Beauty / Retail category', () => {
it('should categorize fashion and beauty', () => {
expect(normalizeIndustry('Custom Jewelry Design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('fashion design')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('beauty industry')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('retail store')).toBe(
'Fashion / Beauty / Retail'
)
expect(normalizeIndustry('cosmetics')).toBe('Fashion / Beauty / Retail')
})
})
describe('Healthcare / Medical / Life Science category', () => {
it('should categorize medical and health fields', () => {
expect(normalizeIndustry('Medical Research')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('healthcare')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('biotech')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('pharmaceutical')).toBe(
'Healthcare / Medical / Life Science'
)
expect(normalizeIndustry('clinical research')).toBe(
'Healthcare / Medical / Life Science'
)
})
})
describe('Education / Research category', () => {
it('should categorize education and research', () => {
expect(normalizeIndustry('university research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('academic')).toBe('Education / Research')
expect(normalizeIndustry('teaching')).toBe('Education / Research')
expect(normalizeIndustry('student')).toBe('Education / Research')
expect(normalizeIndustry('professor')).toBe('Education / Research')
})
it('should categorize academic AI research', () => {
expect(normalizeIndustry('academic AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('university AI research')).toBe(
'Education / Research'
)
expect(normalizeIndustry('AI research at university')).toBe(
'Education / Research'
)
})
})
describe('Fine Art / Contemporary Art category', () => {
it('should categorize art fields', () => {
expect(normalizeIndustry('fine art')).toBe('Fine Art / Contemporary Art')
expect(normalizeIndustry('contemporary artist')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('digital art')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('illustration')).toBe(
'Fine Art / Contemporary Art'
)
expect(normalizeIndustry('gallery')).toBe('Fine Art / Contemporary Art')
})
})
describe('Photography / Videography category', () => {
it('should categorize photography fields', () => {
expect(normalizeIndustry('photography')).toBe('Photography / Videography')
expect(normalizeIndustry('wedding photography')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('commercial photo')).toBe(
'Photography / Videography'
)
expect(normalizeIndustry('videography')).toBe('Photography / Videography')
})
})
describe('Product & Industrial Design category', () => {
it('should categorize product design', () => {
expect(normalizeIndustry('product design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('industrial design')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('manufacturing')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('3d rendering')).toBe(
'Product & Industrial Design'
)
expect(normalizeIndustry('automotive design')).toBe(
'Product & Industrial Design'
)
})
})
describe('Music / Performing Arts category', () => {
it('should categorize music and performing arts', () => {
expect(normalizeIndustry('music production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('theater')).toBe('Music / Performing Arts')
expect(normalizeIndustry('concert production')).toBe(
'Music / Performing Arts'
)
expect(normalizeIndustry('live events')).toBe('Music / Performing Arts')
})
})
describe('E-commerce / Print-on-Demand / Business category', () => {
it('should categorize business fields', () => {
expect(normalizeIndustry('ecommerce')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('print on demand')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('startup')).toBe(
'E-commerce / Print-on-Demand / Business'
)
expect(normalizeIndustry('online store')).toBe(
'E-commerce / Print-on-Demand / Business'
)
})
})
describe('Nonprofit / Government / Public Sector category', () => {
it('should categorize nonprofit and government', () => {
expect(normalizeIndustry('nonprofit')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('government agency')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('public service')).toBe(
'Nonprofit / Government / Public Sector'
)
expect(normalizeIndustry('charity')).toBe(
'Nonprofit / Government / Public Sector'
)
})
})
describe('Adult / NSFW category', () => {
it('should categorize adult content', () => {
expect(normalizeIndustry('adult entertainment')).toBe('Adult / NSFW')
expect(normalizeIndustry('NSFW content')).toBe('Adult / NSFW')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeIndustry('other')).toBe('Other / Undefined')
expect(normalizeIndustry('none')).toBe('Other / Undefined')
expect(normalizeIndustry('undefined')).toBe('Other / Undefined')
expect(normalizeIndustry('unknown')).toBe('Other / Undefined')
expect(normalizeIndustry('n/a')).toBe('Other / Undefined')
expect(normalizeIndustry('not applicable')).toBe('Other / Undefined')
expect(normalizeIndustry('-')).toBe('Other / Undefined')
expect(normalizeIndustry('')).toBe('Other / Undefined')
})
it('should handle null and invalid inputs', () => {
expect(normalizeIndustry(null as any)).toBe('Other / Undefined')
expect(normalizeIndustry(undefined as any)).toBe('Other / Undefined')
expect(normalizeIndustry(123 as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown creative fields with prefix', () => {
expect(normalizeIndustry('Unknown Creative Field')).toBe(
'Uncategorized: Unknown Creative Field'
)
expect(normalizeIndustry('Completely Novel Field')).toBe(
'Uncategorized: Completely Novel Field'
)
})
})
})
describe('normalizeUseCase', () => {
describe('Content Creation & Marketing', () => {
it('should categorize content creation', () => {
expect(normalizeUseCase('YouTube thumbnail generation')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('social media content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('marketing campaigns')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('TikTok content')).toBe(
'Content Creation & Marketing'
)
expect(normalizeUseCase('brand content creation')).toBe(
'Content Creation & Marketing'
)
})
})
describe('Art & Illustration', () => {
it('should categorize art and illustration', () => {
expect(normalizeUseCase('Creating concept art for movies')).toBe(
'Art & Illustration'
)
expect(normalizeUseCase('digital art')).toBe('Art & Illustration')
expect(normalizeUseCase('character design')).toBe('Art & Illustration')
expect(normalizeUseCase('illustration work')).toBe('Art & Illustration')
expect(normalizeUseCase('fantasy art')).toBe('Art & Illustration')
})
})
describe('Product Visualization & Design', () => {
it('should categorize product work', () => {
expect(normalizeUseCase('Product mockup creation')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('3d product rendering')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('prototype visualization')).toBe(
'Product Visualization & Design'
)
expect(normalizeUseCase('industrial design')).toBe(
'Product Visualization & Design'
)
})
})
describe('Gaming & Interactive Media', () => {
it('should categorize gaming use cases', () => {
expect(normalizeUseCase('Game asset generation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game development')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('VR content creation')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('interactive media')).toBe(
'Gaming & Interactive Media'
)
expect(normalizeUseCase('game textures')).toBe(
'Gaming & Interactive Media'
)
})
})
describe('Architecture & Construction', () => {
it('should categorize architecture use cases', () => {
expect(normalizeUseCase('Building visualization')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('architectural rendering')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('interior design mockups')).toBe(
'Architecture & Construction'
)
expect(normalizeUseCase('real estate visualization')).toBe(
'Architecture & Construction'
)
})
})
describe('Photography & Image Processing', () => {
it('should categorize photography work', () => {
expect(normalizeUseCase('Product photography')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('photo editing')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('image enhancement')).toBe(
'Photography & Image Processing'
)
expect(normalizeUseCase('portrait photography')).toBe(
'Photography & Image Processing'
)
})
})
describe('Research & Development', () => {
it('should categorize research work', () => {
expect(normalizeUseCase('Scientific visualization')).toBe(
'Research & Development'
)
expect(normalizeUseCase('research experiments')).toBe(
'Research & Development'
)
expect(normalizeUseCase('prototype testing')).toBe(
'Research & Development'
)
expect(normalizeUseCase('innovation projects')).toBe(
'Research & Development'
)
})
})
describe('Personal & Hobby', () => {
it('should categorize personal projects', () => {
expect(normalizeUseCase('Personal art projects')).toBe('Personal & Hobby')
expect(normalizeUseCase('hobby work')).toBe('Personal & Hobby')
expect(normalizeUseCase('creative exploration')).toBe('Personal & Hobby')
expect(normalizeUseCase('fun experiments')).toBe('Personal & Hobby')
})
})
describe('Film & Video Production', () => {
it('should categorize film work', () => {
expect(normalizeUseCase('movie production')).toBe(
'Film & Video Production'
)
expect(normalizeUseCase('video editing')).toBe('Film & Video Production')
expect(normalizeUseCase('visual effects')).toBe('Film & Video Production')
expect(normalizeUseCase('storyboard creation')).toBe(
'Film & Video Production'
)
})
})
describe('Education & Training', () => {
it('should categorize educational use cases', () => {
expect(normalizeUseCase('educational content')).toBe(
'Education & Training'
)
expect(normalizeUseCase('training materials')).toBe(
'Education & Training'
)
expect(normalizeUseCase('tutorial creation')).toBe('Education & Training')
expect(normalizeUseCase('academic projects')).toBe('Education & Training')
})
})
describe('Other / Undefined category', () => {
it('should handle undefined responses', () => {
expect(normalizeUseCase('other')).toBe('Other / Undefined')
expect(normalizeUseCase('none')).toBe('Other / Undefined')
expect(normalizeUseCase('undefined')).toBe('Other / Undefined')
expect(normalizeUseCase('')).toBe('Other / Undefined')
expect(normalizeUseCase(null as any)).toBe('Other / Undefined')
})
})
describe('Uncategorized responses', () => {
it('should preserve unknown use cases with prefix', () => {
expect(normalizeUseCase('Mysterious Use Case')).toBe(
'Uncategorized: Mysterious Use Case'
)
})
})
})
describe('normalizeSurveyResponses', () => {
it('should normalize both industry and use case', () => {
const input = {
industry: 'Film and television production',
useCase: 'Creating concept art for movies',
familiarity: 'Expert'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Film and television production',
industry_normalized: 'Film / TV / Animation',
industry_raw: 'Film and television production',
useCase: 'Creating concept art for movies',
useCase_normalized: 'Art & Illustration',
useCase_raw: 'Creating concept art for movies',
familiarity: 'Expert'
})
})
it('should handle partial responses', () => {
const input = {
industry: 'Software Development',
familiarity: 'Beginner'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Software Development',
industry_normalized: 'Software / IT / AI',
industry_raw: 'Software Development',
familiarity: 'Beginner'
})
})
it('should handle empty responses', () => {
const input = {
familiarity: 'Intermediate'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
familiarity: 'Intermediate'
})
})
it('should handle uncategorized responses', () => {
const input = {
industry: 'Unknown Creative Field',
useCase: 'Mysterious Use Case'
}
const result = normalizeSurveyResponses(input)
expect(result).toEqual({
industry: 'Unknown Creative Field',
industry_normalized: 'Uncategorized: Unknown Creative Field',
industry_raw: 'Unknown Creative Field',
useCase: 'Mysterious Use Case',
useCase_normalized: 'Uncategorized: Mysterious Use Case',
useCase_raw: 'Mysterious Use Case'
})
})
describe('Migration script example data validation', () => {
it('should correctly categorize all migration script examples', () => {
const examples = [
{
input: {
industry: 'Film and television production',
useCase: 'Creating concept art for movies'
},
expected: {
industry: 'Film / TV / Animation',
useCase: 'Art & Illustration'
}
},
{
input: {
industry: 'Marketing & Social Media',
useCase: 'YouTube thumbnail generation'
},
expected: {
industry: 'Marketing / Advertising / Social Media',
useCase: 'Content Creation & Marketing'
}
},
{
input: {
industry: 'Software Development',
useCase: 'Product mockup creation'
},
expected: {
industry: 'Software / IT / AI',
useCase: 'Product Visualization & Design'
}
},
{
input: {
industry: 'Indie Game Studio',
useCase: 'Game asset generation'
},
expected: {
industry: 'Gaming / Interactive Media',
useCase: 'Gaming & Interactive Media'
}
},
{
input: {
industry: 'Architecture firm',
useCase: 'Building visualization'
},
expected: {
industry: 'Architecture / Engineering / Construction',
useCase: 'Architecture & Construction'
}
},
{
input: {
industry: 'Custom Jewelry Design',
useCase: 'Product photography'
},
expected: {
industry: 'Fashion / Beauty / Retail',
useCase: 'Photography & Image Processing'
}
},
{
input: {
industry: 'Medical Research',
useCase: 'Scientific visualization'
},
expected: {
industry: 'Healthcare / Medical / Life Science',
useCase: 'Research & Development'
}
},
{
input: {
industry: 'Unknown Creative Field',
useCase: 'Personal art projects'
},
expected: {
industry: 'Uncategorized: Unknown Creative Field',
useCase: 'Personal & Hobby'
}
}
]
examples.forEach(({ input, expected }) => {
const result = normalizeSurveyResponses(input)
expect(result.industry_normalized).toBe(expected.industry)
expect(result.useCase_normalized).toBe(expected.useCase)
})
})
})
})

View File

@@ -0,0 +1,606 @@
/**
* Survey Response Normalization Utilities
*
* Smart categorization system to normalize free-text survey responses
* into standardized categories for better analytics breakdowns.
* Uses Fuse.js for fuzzy matching against category keywords.
*/
import Fuse from 'fuse.js'
interface CategoryMapping {
name: string
keywords: string[]
userCount?: number // For reference from analysis
}
/**
* Industry category mappings based on ~9,000 user analysis
*/
const INDUSTRY_CATEGORIES: CategoryMapping[] = [
{
name: 'Film / TV / Animation',
userCount: 2885,
keywords: [
'film',
'tv',
'television',
'animation',
'animation studio',
'tv production',
'film production',
'story',
'anime',
'video',
'cinematography',
'visual effects',
'vfx',
'vfx artist',
'movie',
'cinema',
'documentary',
'documentary filmmaker',
'broadcast',
'streaming',
'production',
'director',
'filmmaker',
'post-production',
'editing'
]
},
{
name: 'Marketing / Advertising / Social Media',
userCount: 1340,
keywords: [
'marketing',
'advertising',
'youtube',
'tiktok',
'social media',
'content creation',
'influencer',
'brand',
'promotion',
'digital marketing',
'seo',
'campaigns',
'copywriting',
'growth',
'engagement'
]
},
{
name: 'Software / IT / AI',
userCount: 1100,
keywords: [
'software',
'software development',
'software engineer',
'it',
'ai',
'ai research',
'corporate ai research',
'ai research lab',
'tech company ai research',
'developer',
'app developer',
'consulting',
'tech',
'tech startup',
'programmer',
'data science',
'machine learning',
'coding',
'programming',
'web development',
'app development',
'saas'
]
},
{
name: 'Product & Industrial Design',
userCount: 1050,
keywords: [
'product design',
'industrial',
'manufacturing',
'3d rendering',
'product visualization',
'mechanical',
'automotive',
'cad',
'prototype',
'design engineering',
'invention'
]
},
{
name: 'Fine Art / Contemporary Art',
userCount: 780,
keywords: [
'fine art',
'art',
'illustration',
'contemporary',
'artist',
'painting',
'drawing',
'sculpture',
'gallery',
'canvas',
'digital art',
'mixed media',
'abstract',
'portrait'
]
},
{
name: 'Education / Research',
userCount: 640,
keywords: [
'education',
'student',
'teacher',
'research',
'university research',
'academic ai research',
'university ai research',
'ai research at university',
'learning',
'university',
'school',
'academic',
'professor',
'curriculum',
'training',
'instruction',
'pedagogy'
]
},
{
name: 'Architecture / Engineering / Construction',
userCount: 420,
keywords: [
'architecture',
'architecture firm',
'construction',
'engineering',
'civil',
'civil engineering',
'cad',
'building',
'structural',
'landscape',
'landscape architecture',
'interior design',
'real estate',
'planning',
'blueprints'
]
},
{
name: 'Gaming / Interactive Media',
userCount: 410,
keywords: [
'gaming',
'game dev',
'game development',
'indie game studio',
'vr development',
'roblox',
'interactive',
'interactive media',
'virtual world',
'vr',
'ar',
'metaverse',
'simulation',
'unity',
'unity developer',
'unreal',
'indie games'
]
},
{
name: 'Photography / Videography',
userCount: 70,
keywords: [
'photography',
'photo',
'videography',
'camera',
'image',
'portrait',
'wedding',
'commercial photo',
'stock photography',
'photojournalism',
'event photography'
]
},
{
name: 'Fashion / Beauty / Retail',
userCount: 25,
keywords: [
'fashion',
'fashion design',
'beauty',
'beauty industry',
'jewelry',
'jewelry design',
'custom jewelry design',
'retail',
'retail store',
'style',
'clothing',
'cosmetics',
'makeup',
'accessories',
'boutique'
]
},
{
name: 'Music / Performing Arts',
userCount: 25,
keywords: [
'music',
'music production',
'vj',
'dance',
'projection mapping',
'audio visual',
'concert',
'concert production',
'performance',
'theater',
'stage',
'live events'
]
},
{
name: 'Healthcare / Medical / Life Science',
userCount: 30,
keywords: [
'healthcare',
'medical',
'medical research',
'doctor',
'biotech',
'life science',
'pharmaceutical',
'clinical',
'clinical research',
'hospital',
'medicine',
'health'
]
},
{
name: 'E-commerce / Print-on-Demand / Business',
userCount: 15,
keywords: [
'ecommerce',
'e-commerce',
'print on demand',
'shop',
'business',
'commercial',
'startup',
'entrepreneur',
'sales',
'online store'
]
},
{
name: 'Nonprofit / Government / Public Sector',
userCount: 15,
keywords: [
'501c3',
'ngo',
'government',
'public service',
'policy',
'nonprofit',
'charity',
'civic',
'community',
'social impact'
]
},
{
name: 'Adult / NSFW',
userCount: 10,
keywords: [
'nsfw',
'nsfw content',
'adult',
'adult entertainment',
'erotic',
'explicit',
'xxx',
'porn'
]
}
]
/**
* Use case category mappings based on common patterns
*/
const USE_CASE_CATEGORIES: CategoryMapping[] = [
{
name: 'Content Creation & Marketing',
keywords: [
'content creation',
'social media',
'marketing',
'marketing campaigns',
'advertising',
'youtube',
'youtube thumbnail',
'youtube thumbnail generation',
'tiktok',
'instagram',
'thumbnails',
'posts',
'campaigns',
'brand content'
]
},
{
name: 'Art & Illustration',
keywords: [
'art',
'illustration',
'drawing',
'painting',
'concept art',
'creating concept art',
'character design',
'digital art',
'fantasy art',
'portraits'
]
},
{
name: 'Product Visualization & Design',
keywords: [
'product',
'product mockup',
'product mockup creation',
'visualization',
'prototype visualization',
'design',
'prototype',
'mockup',
'3d rendering',
'industrial design',
'product photos'
]
},
{
name: 'Film & Video Production',
keywords: [
'film',
'video',
'video editing',
'movie',
'movie production',
'animation',
'vfx',
'visual effects',
'storyboard',
'storyboard creation',
'cinematography',
'post production'
]
},
{
name: 'Gaming & Interactive Media',
keywords: [
'game',
'gaming',
'game asset generation',
'game assets',
'game development',
'game textures',
'interactive',
'vr',
'vr content creation',
'ar',
'virtual',
'simulation',
'metaverse',
'textures'
]
},
{
name: 'Architecture & Construction',
keywords: [
'architecture',
'architectural rendering',
'building',
'building visualization',
'construction',
'interior design',
'interior design mockups',
'landscape',
'real estate',
'real estate visualization',
'floor plans',
'renderings'
]
},
{
name: 'Education & Training',
keywords: [
'education',
'educational',
'educational content',
'training',
'training materials',
'learning',
'teaching',
'tutorial',
'tutorial creation',
'course',
'academic',
'academic projects',
'instructional',
'workshops'
]
},
{
name: 'Research & Development',
keywords: [
'research',
'research experiments',
'development',
'experiment',
'prototype',
'prototype testing',
'testing',
'analysis',
'study',
'innovation',
'innovation projects',
'r&d',
'scientific visualization'
]
},
{
name: 'Personal & Hobby',
keywords: [
'personal',
'personal art projects',
'hobby',
'hobby work',
'fun',
'fun experiments',
'experiment',
'learning',
'curiosity',
'explore',
'creative',
'creative exploration',
'side project'
]
},
{
name: 'Photography & Image Processing',
keywords: [
'photography',
'product photography',
'portrait photography',
'photo',
'photo editing',
'image',
'image enhancement',
'portrait',
'editing',
'enhancement',
'restoration',
'photo manipulation'
]
}
]
/**
* Fuse.js configuration for category matching
*/
const FUSE_OPTIONS = {
keys: ['keywords'],
threshold: 0.53, // Higher = more lenient matching
minMatchCharLength: 5,
includeScore: true,
includeMatches: true,
ignoreLocation: true,
findAllMatches: true
}
/**
* Create Fuse instances for category matching
*/
const industryFuse = new Fuse(INDUSTRY_CATEGORIES, FUSE_OPTIONS)
const useCaseFuse = new Fuse(USE_CASE_CATEGORIES, FUSE_OPTIONS)
/**
* Normalize industry responses using Fuse.js fuzzy search
*/
export function normalizeIndustry(rawIndustry: string): string {
if (!rawIndustry || typeof rawIndustry !== 'string') {
return 'Other / Undefined'
}
const industry = rawIndustry.toLowerCase().trim()
// Handle common undefined responses
if (
industry.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = industryFuse.search(rawIndustry)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawIndustry}`
}
/**
* Normalize use case responses using Fuse.js fuzzy search
*/
export function normalizeUseCase(rawUseCase: string): string {
if (!rawUseCase || typeof rawUseCase !== 'string') {
return 'Other / Undefined'
}
const useCase = rawUseCase.toLowerCase().trim()
// Handle common undefined responses
if (
useCase.match(/^(other|none|undefined|unknown|n\/a|not applicable|-|)$/)
) {
return 'Other / Undefined'
}
// Fuse.js fuzzy search for best category match
const results = useCaseFuse.search(rawUseCase)
if (results.length > 0) {
return results[0].item.name
}
// No good match found - preserve original with prefix
return `Uncategorized: ${rawUseCase}`
}
/**
* Apply normalization to survey responses
* Creates both normalized and raw versions of responses
*/
export function normalizeSurveyResponses(responses: {
industry?: string
useCase?: string
[key: string]: any
}) {
const normalized = { ...responses }
// Normalize industry
if (responses.industry) {
normalized.industry_normalized = normalizeIndustry(responses.industry)
normalized.industry_raw = responses.industry
}
// Normalize use case
if (responses.useCase) {
normalized.useCase_normalized = normalizeUseCase(responses.useCase)
normalized.useCase_raw = responses.useCase
}
return normalized
}

View File

@@ -1361,6 +1361,14 @@ export class ComfyApp {
'afterConfigureGraph',
missingNodeTypes
)
// Track workflow import with missing node information
useTelemetry()?.trackWorkflowImported({
missing_node_count: missingNodeTypes.length,
missing_node_types: missingNodeTypes.map((node) =>
typeof node === 'string' ? node : node.type
)
})
await useWorkflowService().afterLoadNewGraph(
workflow,
this.graph.serialize() as unknown as ComfyWorkflowJSON

View File

@@ -463,6 +463,27 @@ export function traverseNodesDepthFirst<T = void>(
}
}
/**
* Reduces all nodes in a graph hierarchy to a single value using a reducer function.
* Single-pass traversal for efficient aggregation.
*
* @param graph - The root graph to traverse
* @param reducer - Function that reduces each node into the accumulator
* @param initialValue - The initial accumulator value
* @returns The final reduced value
*/
export function reduceAllNodes<T>(
graph: LGraph | Subgraph,
reducer: (accumulator: T, node: LGraphNode) => T,
initialValue: T
): T {
let result = initialValue
forEachNode(graph, (node) => {
result = reducer(result, node)
})
return result
}
/**
* Options for collectFromNodes function
*/

View File

@@ -46,7 +46,9 @@ import { useErrorHandling } from '@/composables/useErrorHandling'
import { useProgressFavicon } from '@/composables/useProgressFavicon'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
import { i18n, loadLocale } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useTelemetry } from '@/platform/telemetry'
import { useFrontendVersionMismatchWarning } from '@/platform/updates/common/useFrontendVersionMismatchWarning'
import { useVersionCompatibilityStore } from '@/platform/updates/common/versionCompatibilityStore'
import type { StatusWsMessageStatus } from '@/schemas/apiSchema'
@@ -56,6 +58,7 @@ import { setupAutoQueueHandler } from '@/services/autoQueueService'
import { useKeybindingService } from '@/services/keybindingService'
import { useCommandStore } from '@/stores/commandStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useModelStore } from '@/stores/modelStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
@@ -89,6 +92,13 @@ const showBottomMenu = computed(
() => !isMobile.value && useNewMenu.value === 'Bottom'
)
const telemetry = useTelemetry()
const firebaseAuthStore = useFirebaseAuthStore()
let hasTrackedLogin = false
let visibilityListener: (() => void) | null = null
let tabCountInterval: number | null = null
let tabCountChannel: BroadcastChannel | null = null
watch(
() => colorPaletteStore.completedActivePalette,
(newTheme) => {
@@ -244,6 +254,22 @@ onBeforeUnmount(() => {
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
executionStore.unbindExecutionEvents()
// Clean up page visibility listener
if (visibilityListener) {
document.removeEventListener('visibilitychange', visibilityListener)
visibilityListener = null
}
// Clean up tab count tracking
if (tabCountInterval) {
window.clearInterval(tabCountInterval)
tabCountInterval = null
}
if (tabCountChannel) {
tabCountChannel.close()
tabCountChannel = null
}
})
useEventListener(window, 'keydown', useKeybindingService().keybindHandler)
@@ -262,6 +288,61 @@ void nextTick(() => {
const onGraphReady = () => {
runWhenGlobalIdle(() => {
// Track user login when app is ready in graph view (cloud only)
if (isCloud && firebaseAuthStore.isAuthenticated && !hasTrackedLogin) {
telemetry?.trackUserLoggedIn()
hasTrackedLogin = true
}
// Set up page visibility tracking (cloud only)
if (isCloud && telemetry && !visibilityListener) {
visibilityListener = () => {
telemetry.trackPageVisibilityChanged({
visibility_state: document.visibilityState as 'visible' | 'hidden'
})
}
document.addEventListener('visibilitychange', visibilityListener)
}
// Set up tab count tracking (cloud only)
if (isCloud && telemetry && !tabCountInterval) {
tabCountChannel = new BroadcastChannel('comfyui-tab-count')
const activeTabs = new Map<string, number>()
const currentTabId = crypto.randomUUID()
// Listen for heartbeats from other tabs
tabCountChannel.onmessage = (event) => {
if (
event.data.type === 'heartbeat' &&
event.data.tabId !== currentTabId
) {
activeTabs.set(event.data.tabId, Date.now())
}
}
// 30-second heartbeat interval
tabCountInterval = window.setInterval(() => {
const now = Date.now()
// Clean up stale tabs (no heartbeat for 45 seconds)
activeTabs.forEach((lastHeartbeat, tabId) => {
if (now - lastHeartbeat > 45000) {
activeTabs.delete(tabId)
}
})
// Broadcast our heartbeat
tabCountChannel?.postMessage({ type: 'heartbeat', tabId: currentTabId })
// Track tab count (include current tab)
const tabCount = activeTabs.size + 1
telemetry.trackTabCount({ tab_count: tabCount })
}, 30000)
// Send initial heartbeat
tabCountChannel.postMessage({ type: 'heartbeat', tabId: currentTabId })
}
// Setting values now available after comfyApp.setup.
// Load keybindings.
wrapWithErrorHandling(useKeybindingService().registerUserKeybindings)()

View File

@@ -13,6 +13,11 @@ import {
createTestSubgraphNode
} from '../litegraph/subgraph/fixtures/subgraphHelpers'
// Mock telemetry to break circular dependency (telemetry → workflowStore → app → telemetry)
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => null
}))
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {