UNPKG

@andreaswissel/uiflow

Version:

Adaptive UI density management library with progressive disclosure, element dependencies, A/B testing, and intelligent behavior-based adaptation

1,433 lines (1,352 loc) โ€ข 71.4 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define(['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.UIFlow = {})); })(this, (function (exports) { 'use strict'; /** * Base class for UIFlow data sources */ class BaseDataSource { constructor(config = {}) { this.config = config; this.ready = false; } /** * Initialize the data source */ async initialize() { this.ready = true; } /** * Check if data source is ready */ isReady() { return this.ready; } /** * Push usage data to the data source * @param {string} userId - User identifier * @param {Object} data - Usage data to push */ async pushData(userId, data) { throw new Error('pushData must be implemented by subclasses'); } /** * Pull user settings from the data source * @param {string} userId - User identifier * @returns {Object} User settings data */ async pullData(userId) { throw new Error('pullData must be implemented by subclasses'); } /** * Track a UI interaction event * @param {string} userId - User identifier * @param {Object} event - Event data */ async trackEvent(userId, event) { // Default implementation - can be overridden console.log(`Tracking event for ${userId}:`, event); } /** * Clean up resources */ destroy() { this.ready = false; } } /** * Dedicated API data source for UIFlow */ class APIDataSource extends BaseDataSource { constructor(config = {}) { super(config); this.endpoint = config.endpoint; if (!this.endpoint) { throw new Error('API endpoint is required'); } } async initialize() { // Test API connectivity try { const response = await fetch(`${this.endpoint}/health`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { console.warn('UIFlow API health check failed, but continuing...'); } } catch (error) { console.warn('UIFlow API not accessible:', error.message); } this.ready = true; } async pushData(userId, data) { if (!this.isReady()) { throw new Error('API data source not ready'); } try { const response = await fetch(`${this.endpoint}/users/${userId}/ui-density`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...data, source: 'uiflow-api', timestamp: Date.now() }) }); if (!response.ok) { throw new Error(`API push failed: ${response.status}`); } return await response.json(); } catch (error) { console.error('UIFlow API push error:', error); throw error; } } async pullData(userId) { if (!this.isReady()) { throw new Error('API data source not ready'); } try { const response = await fetch(`${this.endpoint}/users/${userId}/ui-density`, { method: 'GET', headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { if (response.status === 404) { // User not found, return empty data return { areas: {}, overrides: {}, usageHistory: [] }; } throw new Error(`API pull failed: ${response.status}`); } return await response.json(); } catch (error) { console.error('UIFlow API pull error:', error); throw error; } } async trackEvent(userId, event) { if (!this.isReady()) return; try { await fetch(`${this.endpoint}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId, event: 'ui_interaction', properties: { ...event, source: 'uiflow' }, timestamp: Date.now() }) }); } catch (error) { console.warn('UIFlow API event tracking failed:', error); } } } /** * Segment data source for UIFlow */ class SegmentDataSource extends BaseDataSource { constructor(config = {}) { super(config); this.writeKey = config.writeKey; this.trackingPlan = config.trackingPlan || 'uiflow'; this.useUserProperties = config.useUserProperties !== false; // Default true this.analytics = null; if (!this.writeKey) { throw new Error('Segment write key is required'); } } async initialize() { // Load Segment Analytics.js if not already loaded if (!window.analytics) { await this.loadSegmentScript(); } this.analytics = window.analytics; // Initialize Segment if not already done if (!this.analytics.initialized) { this.analytics.load(this.writeKey); } this.ready = true; } async loadSegmentScript() { return new Promise((resolve, reject) => { // Segment's analytics.js snippet (simplified) const analytics = window.analytics = window.analytics || []; if (analytics.initialize) { resolve(); return; } if (analytics.invoked) { console.error('Segment snippet included twice.'); reject(new Error('Segment already loaded')); return; } analytics.invoked = true; analytics.methods = [ 'trackSubmit', 'trackClick', 'trackLink', 'trackForm', 'pageview', 'identify', 'reset', 'group', 'track', 'ready', 'alias', 'debug', 'page', 'once', 'off', 'on' ]; analytics.factory = function(method) { return function() { const args = Array.prototype.slice.call(arguments); args.unshift(method); analytics.push(args); return analytics; }; }; for (let i = 0; i < analytics.methods.length; i++) { const method = analytics.methods[i]; analytics[method] = analytics.factory(method); } analytics.load = function(key, options) { const script = document.createElement('script'); script.type = 'text/javascript'; script.async = true; script.src = 'https://cdn.segment.com/analytics.js/v1/' + key + '/analytics.min.js'; script.onload = resolve; script.onerror = reject; const first = document.getElementsByTagName('script')[0]; first.parentNode.insertBefore(script, first); analytics._loadOptions = options; }; analytics.SNIPPET_VERSION = '4.13.1'; }); } async pushData(userId, data) { if (!this.isReady()) { throw new Error('Segment data source not ready'); } // Store UI density preferences as user traits if (this.useUserProperties) { this.analytics.identify(userId, { uiflow_areas: data.areas, uiflow_last_updated: Date.now(), uiflow_total_interactions: this.getTotalInteractions(data.usageHistory) }); } // Track the settings update event this.analytics.track('UIFlow Settings Updated', { userId, areas: data.areas, source: this.trackingPlan, timestamp: Date.now() }); } async pullData(userId) { if (!this.isReady()) { throw new Error('Segment data source not ready'); } // Segment doesn't typically provide user data retrieval in the browser // This would need to be implemented via Segment's Personas API or similar // For now, return empty data and rely on local storage + tracking console.warn('Segment data source: pullData not implemented - Segment is primarily for tracking'); return { areas: {}, overrides: {}, usageHistory: [], note: 'Segment is write-only from browser. Use Segment Personas API for reading user data.' }; } async trackEvent(userId, event) { if (!this.isReady()) return; // Map UIFlow events to Segment events const segmentEvent = this.mapToSegmentEvent(event); this.analytics.track(segmentEvent.name, { ...segmentEvent.properties, userId, source: this.trackingPlan, timestamp: Date.now() }); } mapToSegmentEvent(event) { const { elementId, category, area, action } = event; return { name: 'UI Element Interacted', properties: { element_id: elementId, element_category: category, ui_area: area, action_type: action || 'click', density_level: event.densityLevel, is_new_feature: event.isNew || false } }; } getTotalInteractions(usageHistory) { if (!Array.isArray(usageHistory)) return 0; return usageHistory.reduce((total, [key, interactions]) => { return total + (Array.isArray(interactions) ? interactions.length : 0); }, 0); } // Segment-specific methods async identifyUser(userId, traits = {}) { if (!this.isReady()) return Promise.resolve(); this.analytics.identify(userId, { ...traits, uiflow_enabled: true, uiflow_initialized_at: Date.now() }); return Promise.resolve(); } trackPageView(userId, page = {}) { if (!this.isReady()) return; this.analytics.page({ ...page, uiflow_enabled: true, source: this.trackingPlan }); } trackDensityChange(userId, area, oldDensity, newDensity, reason) { if (!this.isReady()) return; this.analytics.track('UIFlow Density Changed', { userId, ui_area: area, old_density: oldDensity, new_density: newDensity, change_reason: reason, // 'user_manual', 'auto_adaptation', 'admin_override' source: this.trackingPlan }); } trackFeatureDiscovery(userId, elementId, category, area) { if (!this.isReady()) return; this.analytics.track('UIFlow Feature Discovered', { userId, element_id: elementId, element_category: category, ui_area: area, source: this.trackingPlan }); } } /** * Data sources for UIFlow */ /** * Data source manager for handling multiple sources */ class DataSourceManager { constructor() { this.sources = new Map(); this.primary = null; } /** * Add a data source * @param {string} name - Data source name * @param {BaseDataSource} source - Data source instance * @param {boolean} isPrimary - Whether this is the primary source for reading data */ addSource(name, source, isPrimary = false) { this.sources.set(name, source); if (isPrimary || !this.primary) { this.primary = name; } } /** * Initialize all data sources */ async initialize() { const initPromises = Array.from(this.sources.values()).map(source => source.initialize().catch(error => { console.warn('Data source initialization failed:', error); return null; }) ); await Promise.all(initPromises); } /** * Push data to all sources */ async pushData(userId, data) { const pushPromises = Array.from(this.sources.entries()).map(async ([name, source]) => { try { if (source.isReady()) { await source.pushData(userId, data); } } catch (error) { console.warn(`Data push failed for source ${name}:`, error); } }); await Promise.all(pushPromises); } /** * Pull data from primary source */ async pullData(userId) { if (!this.primary) { return { areas: {}, overrides: {}, usageHistory: [] }; } const primarySource = this.sources.get(this.primary); if (!primarySource || !primarySource.isReady()) { return { areas: {}, overrides: {}, usageHistory: [] }; } try { return await primarySource.pullData(userId); } catch (error) { console.warn('Primary data source pull failed:', error); return { areas: {}, overrides: {}, usageHistory: [] }; } } /** * Track event in all sources */ async trackEvent(userId, event) { const trackPromises = Array.from(this.sources.values()).map(async (source) => { try { if (source.isReady()) { await source.trackEvent(userId, event); } } catch (error) { console.warn('Event tracking failed for source:', error); } }); await Promise.all(trackPromises); } /** * Get source by name */ getSource(name) { return this.sources.get(name); } /** * Get all source names */ getSourceNames() { return Array.from(this.sources.keys()); } /** * Remove a source */ removeSource(name) { const source = this.sources.get(name); if (source) { source.destroy(); this.sources.delete(name); if (this.primary === name) { // Set new primary to first available source this.primary = this.sources.size > 0 ? this.sources.keys().next().value : null; } } } /** * Destroy all sources */ destroy() { for (const source of this.sources.values()) { source.destroy(); } this.sources.clear(); this.primary = null; } } /** * UIFlow - Adaptive UI density management library * Licensed under CC BY-NC 4.0 */ class UIFlow { constructor(options = {}) { this.sequenceTracker = new Map(); // Track element interaction sequences this.activeRules = []; this.abTestMetrics = new Map(); this.config = { categories: ['basic', 'advanced', 'expert'], learningRate: 0.1, storageKey: 'uiflow-data', timeAcceleration: 1, adaptationThreshold: 3, decayRate: 0.95, syncInterval: 5 * 60 * 1000, userId: null, dataSources: {}, ...options }; this.elements = new Map(); this.areas = new Map(); this.usageHistory = new Map(); this.defaultDensity = 0.3; this.initialized = false; this.syncTimer = null; this.remoteOverrides = new Map(); this.highlights = new Map(); this.highlightStyles = null; this.dataManager = new DataSourceManager(); } /** * Initialize UIFlow with configuration */ async init(options = {}) { Object.assign(this.config, options); this.loadStoredData(); this.setupObservers(); this.injectHighlightStyles(); // Initialize data sources await this.setupDataSources(); // Sync with data sources if configured if (this.config.userId && this.dataManager.getSourceNames().length > 0) { await this.syncWithDataSources(); this.startSyncTimer(); } this.initialized = true; // Emit initialization event this.emit('uiflow:initialized', { areas: this.getAreaDensities() }); return this; } /** * Categorize a UI element with optional area specification */ categorize(element, category, area = 'default', options = {}) { if (!this.config.categories.includes(category)) { throw new Error(`Invalid category: ${category}`); } const id = this.getElementId(element); this.elements.set(id, { element, category, area, visible: true, interactions: 0, lastUsed: null, helpText: options.helpText, isNew: options.isNew ?? false, dependencies: options.dependencies ?? [] }); // Initialize area if not exists if (!this.areas.has(area)) { this.areas.set(area, { density: this.defaultDensity, lastActivity: Date.now(), totalInteractions: 0 }); } element.setAttribute('data-uiflow-category', category); element.setAttribute('data-uiflow-area', area); element.setAttribute('data-uiflow-id', id); if (options.helpText) { element.setAttribute('data-uiflow-help', options.helpText); } this.updateElementVisibility(id); return this; } /** * Get density level for specific area (0-1) * Checks remote overrides first, then local data */ getDensityLevel(area = 'default') { // Check for remote override first if (this.remoteOverrides.has(area)) { return this.remoteOverrides.get(area); } const areaData = this.areas.get(area); return areaData ? areaData.density : this.defaultDensity; } /** * Get all area densities */ getAreaDensities() { const densities = {}; for (const [area, data] of this.areas) { densities[area] = data.density; } return densities; } /** * Set density level for specific area manually */ setDensityLevel(level, area = 'default', options = {}) { const clampedLevel = Math.max(0, Math.min(1, level)); if (!this.areas.has(area)) { this.areas.set(area, { density: clampedLevel, lastActivity: Date.now(), totalInteractions: 0 }); } else { this.areas.get(area).density = clampedLevel; } this.updateAreaElementsVisibility(area); this.emit('uiflow:density-changed', { area, density: clampedLevel, areas: this.getAreaDensities() }); // Push to data sources unless explicitly skipped if (!options.skipAPI && this.config.userId) { this.pushToDataSources(); } return this; } /** * Check if element should be visible based on current density */ shouldShowElement(category, area = 'default') { const categoryIndex = this.config.categories.indexOf(category); // More realistic thresholds: basic=0%, advanced=40%, expert=75% const thresholds = { 0: 0.0, // basic: 0% 1: 0.4, // advanced: 40% 2: 0.75 // expert: 75% }; const threshold = thresholds[categoryIndex] ?? (categoryIndex / (this.config.categories.length - 1)); const areaDensity = this.getDensityLevel(area); return areaDensity >= threshold; } /** * Enhanced element visibility check including dependencies */ shouldShowElementWithDependencies(elementId) { const element = this.elements.get(elementId); if (!element) return false; // First check density-based visibility const densityVisible = this.shouldShowElement(element.category, element.area); if (!densityVisible) return false; // Then check dependencies return this.validateDependencies(elementId); } /** * Override density for specific area (admin control) */ setRemoteOverride(area, density) { this.remoteOverrides.set(area, Math.max(0, Math.min(1, density))); this.updateAreaElementsVisibility(area); this.emit('uiflow:override-applied', { area, density }); } /** * Remove remote override for area */ clearRemoteOverride(area) { this.remoteOverrides.delete(area); this.updateAreaElementsVisibility(area); this.emit('uiflow:override-cleared', { area }); } /** * Get current override status */ getOverrides() { return Object.fromEntries(this.remoteOverrides); } /** * Check if area has remote override */ hasOverride(area) { return this.remoteOverrides.has(area); } /** * Highlight an element with specific style and optional tooltip */ highlightElement(elementId, style = 'default', options = {}) { const data = this.elements.get(elementId); if (!data || !data.visible) return this; const highlightInfo = { style, startTime: Date.now(), duration: options.duration ?? 5000, tooltip: options.tooltip ?? data.helpText ?? undefined, persistent: options.persistent ?? false, onDismiss: options.onDismiss }; this.highlights.set(elementId, highlightInfo); this.applyHighlight(elementId, highlightInfo); // Auto-remove highlight after duration (unless persistent) if (!highlightInfo.persistent) { setTimeout(() => { this.removeHighlight(elementId); }, highlightInfo.duration); } this.emit('uiflow:highlight-added', { elementId, style, options }); return this; } /** * Remove highlight from element */ removeHighlight(elementId) { const highlightInfo = this.highlights.get(elementId); if (!highlightInfo) return this; const data = this.elements.get(elementId); if (data) { data.element.classList.remove(`uiflow-highlight-${highlightInfo.style}`); data.element.removeAttribute('data-uiflow-tooltip'); // Remove tooltip if it exists const tooltip = data.element.querySelector('.uiflow-tooltip'); if (tooltip) { tooltip.remove(); } } this.highlights.delete(elementId); if (highlightInfo.onDismiss) { highlightInfo.onDismiss(elementId); } this.emit('uiflow:highlight-removed', { elementId }); return this; } /** * Flag element as new with optional help text */ flagAsNew(elementId, helpText, duration = 8000) { const data = this.elements.get(elementId); if (!data) return this; data.isNew = true; if (helpText) { data.helpText = helpText; data.element.setAttribute('data-uiflow-help', helpText); } // If element is currently visible, highlight it immediately if (data.visible) { this.highlightElement(elementId, 'new-feature', { duration, tooltip: helpText ?? 'New feature available!' }); } return this; } /** * Show tooltip for element */ showTooltip(elementId, text, options = {}) { return this.highlightElement(elementId, 'tooltip', { tooltip: text, duration: options.duration ?? 3000, persistent: options.persistent ?? false }); } /** * Clear all highlights */ clearAllHighlights() { for (const elementId of this.highlights.keys()) { this.removeHighlight(elementId); } return this; } /** * Get currently highlighted elements */ getHighlights() { return Array.from(this.highlights.keys()); } /** * Add a data source dynamically */ addDataSource(name, type, config, isPrimary = false) { let source; switch (type) { case 'api': source = new APIDataSource({ endpoint: config.endpoint }); break; case 'segment': source = new SegmentDataSource({ writeKey: config.writeKey, trackingPlan: config.trackingPlan, useUserProperties: config.useUserProperties }); break; default: throw new Error(`Unknown data source type: ${type}`); } this.dataManager.addSource(name, source, isPrimary); source.initialize(); this.emit('uiflow:datasource-added', { sources: [name] }); return this; } /** * Remove a data source */ removeDataSource(name) { this.dataManager.removeSource(name); this.emit('uiflow:datasource-removed', { sources: [name] }); return this; } /** * Get data source by name (for advanced usage) */ getDataSource(name) { return this.dataManager.getSource(name); } /** * Force sync with data sources and apply updates */ async forceSync() { await this.syncWithDataSources(); await this.pushToDataSources(); } /** * Simulate usage over time for testing */ simulateUsage(area, interactions, daysToSimulate = 7) { const originalAcceleration = this.config.timeAcceleration; this.config.timeAcceleration = 1000; // Speed up for simulation const msPerDay = 24 * 60 * 60 * 1000; const totalMs = daysToSimulate * msPerDay; const intervalMs = totalMs / interactions.length; interactions.forEach((interaction, index) => { const simulatedTime = Date.now() + (index * intervalMs); this.recordUsageHistory(area, interaction.category, simulatedTime * this.config.timeAcceleration); }); // Trigger adaptation after simulation this.adaptDensity(area); this.config.timeAcceleration = originalAcceleration; } /** * Simulate realistic user behavior patterns */ simulateUserType(type, areas) { const predefinedPatterns = { beginner: { basic: 0.85, advanced: 0.13, expert: 0.02 }, intermediate: { basic: 0.60, advanced: 0.35, expert: 0.05 }, 'power-user': { basic: 0.30, advanced: 0.50, expert: 0.20 }, expert: { basic: 0.15, advanced: 0.40, expert: 0.45 } }; const pattern = typeof type === 'string' ? predefinedPatterns[type] : type; const targetAreas = areas ? (Array.isArray(areas) ? areas : [areas]) : Array.from(this.areas.keys()); // Generate interactions based on pattern (50 total for realistic simulation) const totalInteractions = 50; const interactions = []; const basicCount = Math.round(totalInteractions * pattern.basic); const advancedCount = Math.round(totalInteractions * pattern.advanced); const expertCount = totalInteractions - basicCount - advancedCount; // Add interactions in shuffled order for realism for (let i = 0; i < basicCount; i++) interactions.push({ category: 'basic' }); for (let i = 0; i < advancedCount; i++) interactions.push({ category: 'advanced' }); for (let i = 0; i < expertCount; i++) interactions.push({ category: 'expert' }); // Shuffle for realistic distribution for (let i = interactions.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); const temp = interactions[i]; if (interactions[j] !== undefined && temp !== undefined) { interactions[i] = interactions[j]; interactions[j] = temp; } } // Apply to all target areas targetAreas.forEach(area => { this.simulateUsage(area, interactions, 7); }); console.log(`๐Ÿค– Simulated ${typeof type === 'string' ? type : 'custom'} user for areas: ${targetAreas.join(', ')}`); } /** * Enable/disable demo mode for testing */ setDemoMode(enabled) { // Store original config if (!this.demoModeOriginal) { this.demoModeOriginal = { learningRate: this.config.learningRate, timeAcceleration: this.config.timeAcceleration, adaptationThreshold: this.config.adaptationThreshold }; } if (enabled) { this.config.learningRate = 0.9; this.config.timeAcceleration = 2; this.config.adaptationThreshold = 1; } else { Object.assign(this.config, this.demoModeOriginal); } } /** * Directly set density for demos (bypasses learning) */ boostDensity(area, targetDensity) { const areaData = this.areas.get(area); if (areaData) { const oldDensity = areaData.density; areaData.density = Math.max(0, Math.min(1, targetDensity)); this.updateAreaElementsVisibility(area); this.saveData(); this.emit('uiflow:density-changed', { area, density: areaData.density, previousDensity: oldDensity }); console.log(`๐ŸŽฏ Boosted ${area} density to ${Math.round(targetDensity * 100)}%`); } } /** * Reset area to initial state */ resetArea(area) { const areaData = this.areas.get(area); if (areaData) { areaData.density = 0.3; // Default density areaData.totalInteractions = 0; areaData.lastActivity = 0; // Clear usage history for this area for (const category of this.config.categories) { const key = `${area}:${category}`; this.usageHistory.delete(key); } this.updateAreaElementsVisibility(area); this.saveData(); console.log(`๐Ÿ”„ Reset ${area} to initial state`); } } /** * Get comprehensive stats for an area */ getAreaStats(area) { const areaData = this.areas.get(area); if (!areaData) { return { density: 0, visibleElements: 0, totalElements: 0, recentUsage: { basic: 0, advanced: 0, expert: 0 }, adaptationEvents: 0 }; } const elements = Array.from(this.elements.values()).filter(el => el.area === area); const visibleElements = elements.filter(el => el.visible).length; const recentUsage = this.getRecentUsageByArea(area); return { density: areaData.density, visibleElements, totalElements: elements.length, recentUsage, adaptationEvents: areaData.totalInteractions }; } /** * Get stats for all areas */ getOverviewStats() { const stats = {}; for (const area of this.areas.keys()) { stats[area] = this.getAreaStats(area); } return stats; } /** * Public getters for controlled access to private properties */ getElementCount() { return this.elements.size; } getAreaCount() { return this.areas.size; } getElementsForArea(area) { return Array.from(this.elements.values()).filter(el => el.area === area); } isInitialized() { return this.initialized; } /** * Get elements for demo features (controlled access) */ getNewVisibleElements() { return Array.from(this.elements.entries()) .filter(([_, data]) => data.isNew && data.visible) .map(([elementId, data]) => ({ elementId, helpText: data.helpText })); } getVisibleElementsWithHelp() { return Array.from(this.elements.entries()) .filter(([_, data]) => data.visible && data.helpText) .map(([elementId, data]) => ({ elementId, helpText: data.helpText })); } getVisibleElementsSorted() { return Array.from(this.elements.entries()) .filter(([_, data]) => data.visible && data.helpText) .sort(([, a], [, b]) => { const order = { basic: 0, advanced: 1, expert: 2 }; return order[a.category] - order[b.category]; }) .map(([elementId, data]) => ({ elementId, category: data.category, helpText: data.helpText })); } /** * Validate element dependencies */ validateDependencies(elementId) { const elementData = this.elements.get(elementId); if (!elementData || !elementData.dependencies) { return true; // No dependencies = always valid } return elementData.dependencies.every(dep => this.validateSingleDependency(dep)); } validateSingleDependency(dependency) { switch (dependency.type) { case 'usage_count': return this.validateUsageCount(dependency); case 'sequence': return this.validateSequence(dependency); case 'time_based': return this.validateTimeBased(dependency); case 'logical_and': return dependency.elements?.every(elemId => this.validateDependencies(elemId)) ?? false; case 'logical_or': return dependency.elements?.some(elemId => this.validateDependencies(elemId)) ?? false; default: console.warn(`Unknown dependency type: ${dependency.type}`); return false; } } validateUsageCount(dependency) { if (!dependency.elementId || !dependency.threshold) return false; const targetElement = this.elements.get(dependency.elementId); if (!targetElement) return false; return targetElement.interactions >= dependency.threshold; } validateSequence(dependency) { if (!dependency.elements || dependency.elements.length === 0) return false; // Check if user has interacted with elements in the specified sequence const sequenceKey = dependency.elements.join('โ†’'); this.sequenceTracker.get(sequenceKey) || []; // Simple validation: all elements in sequence have been used return dependency.elements.every(elemId => { const element = this.elements.get(elemId); return element && element.interactions > 0; }); } validateTimeBased(dependency) { if (!dependency.elementId || !dependency.timeWindow || !dependency.minUsage) { return false; } const targetElement = this.elements.get(dependency.elementId); if (!targetElement) return false; // Parse time window (e.g., '7d', '30d', '1w') const timeWindowMs = this.parseTimeWindow(dependency.timeWindow); const now = this.getAcceleratedTime(); const cutoff = now - timeWindowMs; // Count recent usage for this element const area = targetElement.area; const category = targetElement.category; const key = `${area}:${category}`; const history = this.usageHistory.get(key) || []; const recentUsage = history.filter(timestamp => timestamp > cutoff).length; return recentUsage >= dependency.minUsage; } parseTimeWindow(timeWindow) { const match = timeWindow.match(/^(\d+)([dwmy])$/); if (!match) return 7 * 24 * 60 * 60 * 1000; // Default to 7 days const [, amount, unit] = match; const value = parseInt(amount || '7', 10); switch (unit) { case 'd': return value * 24 * 60 * 60 * 1000; case 'w': return value * 7 * 24 * 60 * 60 * 1000; case 'm': return value * 30 * 24 * 60 * 60 * 1000; case 'y': return value * 365 * 24 * 60 * 60 * 1000; default: return 7 * 24 * 60 * 60 * 1000; } } /** * Track element sequences for dependency validation and user journey analysis */ trackElementSequence(elementId) { const element = this.elements.get(elementId); if (!element) return; const timestamp = Date.now(); const areaSequenceKey = `${element.area}:sequence`; const globalSequenceKey = 'global:sequence'; // Track area-specific sequence this.addToSequence(areaSequenceKey, { elementId, timestamp, area: element.area }); // Track global sequence across all areas this.addToSequence(globalSequenceKey, { elementId, timestamp, area: element.area }); // Analyze and detect common patterns this.analyzeUserJourney(element.area); } addToSequence(key, entry) { if (!this.sequenceTracker.has(key)) { this.sequenceTracker.set(key, []); } const sequence = this.sequenceTracker.get(key); sequence.push(entry.timestamp); // Keep only last 20 interactions in sequence for pattern analysis if (sequence.length > 20) { sequence.splice(0, sequence.length - 20); } } /** * Analyze user journey patterns and detect competency progression */ analyzeUserJourney(area) { const sequenceKey = `${area}:sequence`; const sequence = this.sequenceTracker.get(sequenceKey) || []; if (sequence.length < 5) return; // Need minimum interactions for pattern analysis // Detect usage patterns const recentInteractions = sequence.slice(-10); const timeSpan = (recentInteractions[recentInteractions.length - 1] || 0) - (recentInteractions[0] || 0); const interactionRate = recentInteractions.length / (timeSpan / 1000 / 60); // per minute // Classify user behavior let userBehavior = 'exploring'; if (interactionRate > 2) { userBehavior = 'focused'; // High interaction rate suggests focused usage } const areaElements = this.getElementsForArea(area); const advancedUsage = areaElements.filter(el => (el.category === 'advanced' || el.category === 'expert') && el.interactions > 0).length; if (advancedUsage > areaElements.length * 0.5) { userBehavior = 'expert'; // Using >50% advanced features } // Emit journey insights this.emit('uiflow:journey-analyzed', { area, behavior: userBehavior, interactionRate, advancedFeatureUsage: advancedUsage, totalSequenceLength: sequence.length, recentActivity: recentInteractions.length }); // Trigger adaptive responses based on detected patterns this.adaptToUserJourney(area, userBehavior); } /** * Adapt UIFlow behavior based on detected user journey patterns */ adaptToUserJourney(area, behavior) { switch (behavior) { case 'exploring': // User is exploring - show helpful hints and basic features this.flagNewElementsInArea(area, 'basic'); break; case 'focused': // User is working focused - reduce interruptions, show relevant advanced features this.boostDensity(area, Math.min(this.getDensityLevel(area) + 0.1, 0.8)); break; case 'expert': // User is expert - unlock expert features, minimal hand-holding this.unlockCategory('expert', area); break; } } /** * Flag new elements in an area for discovery */ flagNewElementsInArea(area, category) { for (const [elementId, element] of this.elements) { if (element.area === area && element.visible) { if (!category || element.category === category) { element.isNew = true; this.flagAsNew(elementId, element.helpText || 'Try this feature!', 5000); } } } } /** * Load configuration from JSON with optional A/B testing */ async loadConfiguration(config) { let finalConfig = config; // Handle A/B testing if enabled if (config.abTest?.enabled) { const selectedVariant = this.selectABTestVariant(config.abTest); if (selectedVariant) { finalConfig = this.mergeConfiguration(config, selectedVariant.configuration); this.abTestVariant = selectedVariant.id; this.initializeABTestMetrics(config.abTest.metrics); console.log(`๐Ÿงช A/B Test Active: ${config.abTest.testId}, Variant: ${selectedVariant.name}`); } } this.loadedConfiguration = finalConfig; // Apply area configurations for (const [areaId, areaConfig] of Object.entries(finalConfig.areas)) { // Set default density for area if (!this.areas.has(areaId)) { this.areas.set(areaId, { density: areaConfig.defaultDensity, lastActivity: Date.now(), totalInteractions: 0 }); } // Auto-categorize elements from configuration for (const elementConfig of areaConfig.elements) { const element = document.querySelector(elementConfig.selector); if (element) { this.categorize(element, elementConfig.category, areaId, { helpText: elementConfig.helpText, dependencies: elementConfig.dependencies }); } else { console.warn(`Element not found for selector: ${elementConfig.selector}`); } } } // Initialize rule engine if rules are present if (finalConfig.rules && finalConfig.rules.length > 0) { this.initializeRuleEngine(finalConfig.rules); } console.log(`โœ… UIFlow configuration loaded: ${finalConfig.name} v${finalConfig.version}`); this.emit('uiflow:configuration-loaded', { config: finalConfig, abTestVariant: this.abTestVariant }); return this; } /** * Select A/B test variant based on traffic allocation */ selectABTestVariant(abTest) { if (!abTest.variants.length || !abTest.trafficAllocation.length) { return null; } // Generate a consistent hash based on user ID for stable variant assignment const userId = this.config.userId || 'anonymous'; const hash = this.hashString(userId + abTest.testId); const percentage = hash % 100; // Select variant based on traffic allocation let cumulativePercentage = 0; for (let i = 0; i < abTest.variants.length; i++) { cumulativePercentage += abTest.trafficAllocation[i] || 0; if (percentage < cumulativePercentage) { return abTest.variants[i] || null; } } return abTest.variants[0] || null; // Fallback to first variant } hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); } /** * Merge base configuration with variant configuration */ mergeConfiguration(base, variant) { return { ...base, ...variant, areas: { ...base.areas, ...variant.areas }, rules: variant.rules || base.rules || [], templates: variant.templates || base.templates || [] }; } /** * Initialize A/B test metrics tracking */ initializeABTestMetrics(metrics) { for (const metric of metrics) { this.abTestMetrics.set(metric, 0); } } /** * Track A/B test metrics */ trackABTestMetric(metric, value = 1) { if (this.abTestVariant && this.abTestMetrics.has(metric)) { const currentValue = this.abTestMetrics.get(metric) || 0; this.abTestMetrics.set(metric, currentValue + value); this.emit('uiflow:ab-test-metric', { testVariant: this.abTestVariant, metric, value: currentValue + value }); } } /** * Get A/B test results */ getABTestResults() { const metrics = {}; for (const [key, value] of this.abTestMetrics) { metrics[key] = value; } return { variant: this.abTestVariant, metrics }; } /** * Export current configuration */ exportConfiguration() { if (this.loadedConfiguration) { return this.loadedConfiguration; } // Generate configuration from current state const areas = {}; for (const [areaId, areaData] of this.areas) { const elements = this.getElementsForArea(areaId).map(elementData => ({ id: elementData.element.getAttribute('data-uiflow-id') || '', selector: `#${elementData.element.id}` || `.${elementData.element.className}`, category: elementData.category, helpText: elementData.helpText, dependencies: elementData.dependencies })); areas[areaId] = { defaultDensity: areaData.density, elements }; } return { name: 'Generated UIFlow Configuration', version: '1.0.0', areas, rules: [] }; } /** * Initialize rule engine with configuration rules */ initializeRuleEngine(rules) { this.activeRules = rules; // Start periodic rule checking (every 30 seconds) this.ruleCheckTimer = window.setInterval(() => { this.processRules(); }, 30000); console.log(`๐Ÿ”ง Rule engine initialized with ${rules.length} rules`); } /** * Process all active rules */ processRules() { for (const rule of this.activeRules) { if (this.evaluateRuleTrigger(rule.trigger)) { this.executeRuleAction(rule.action); console.log(`๐Ÿ“‹ Rule executed: ${rule.name}`); this.emit('uiflow:rule-triggered', { rule: rule.name, trigger: rule.trigger, action: rule.action }); } } } /** * Evaluate if a rule trigger condition is met */ evaluateRuleTrigger(trigger) { switch (trigger.type) { case 'usage_pattern': return this.evaluateUsagePattern(trigger); case 'time_based': return this.evaluateTimeBased(trigger); case 'element_interaction': return this.evaluateElementInteraction(trigger); case 'custom_event': // Custom events would be handled via external triggers return false; default: console.warn(`Unknown trigger type: ${trigger.type}`); return false; } } evaluateUsagePattern(trigger) { if (!trigger.elements || !trigger.frequency || !trigger.duration) { return false; } const durationMs = this.parseTimeWindow(trigger.duration); const now = this.getAcceleratedTime(); const cutoff = now - durationMs; // Check if all specified elements have been used according to frequency return trigger.elements.every(elementId => { const element = this.elements.get(elementId); if (!element) return false; const area = element.area; c