/** * Onboarding System for TimeTracker * Interactive product tours and first-time user experience */ class OnboardingManager { constructor() { this.currentStep = 0; this.steps = []; this.overlay = null; this.tooltip = null; this.storageKey = 'onboarding_completed'; } /** * Initialize onboarding */ init(steps) { if (this.isCompleted()) { return; } // Disable tour on mobile devices (width < 768px) const isMobile = window.innerWidth <= 768; if (isMobile) { // Mark as completed to prevent future attempts localStorage.setItem(this.storageKey, 'true'); return; } this.steps = steps; this.createOverlay(); this.createTooltip(); // Add resize handler to handle window resizing during tour this.resizeHandler = () => { const isMobile = window.innerWidth <= 768; if (isMobile) { // If window is resized to mobile size, cancel the tour this.complete(); } }; window.addEventListener('resize', this.resizeHandler); this.showStep(0); } /** * Create overlay element */ createOverlay() { this.overlay = document.createElement('div'); this.overlay.className = 'onboarding-overlay'; this.overlay.innerHTML = ` `; document.body.appendChild(this.overlay); } /** * Create tooltip element */ createTooltip() { this.tooltip = document.createElement('div'); this.tooltip.className = 'onboarding-tooltip'; document.body.appendChild(this.tooltip); } /** * Show a specific step */ showStep(index) { if (index < 0 || index >= this.steps.length) { this.complete(); return; } this.currentStep = index; const step = this.steps[index]; // Find target element with better selector handling let target = null; // Try to find the element const elements = document.querySelectorAll(step.target); if (elements.length === 0) { console.warn(`Onboarding target not found: ${step.target}`); // Try once more after a short delay in case element is still loading setTimeout(() => { const retryElements = document.querySelectorAll(step.target); if (retryElements.length > 0) { target = retryElements[0]; this.displayStep(target, step, index); } else { console.warn(`Onboarding target still not found after retry: ${step.target}, skipping step`); this.showStep(index + 1); } }, 200); return; } // If multiple elements found, prefer visible ones or first one for (const el of elements) { const rect = el.getBoundingClientRect(); const style = window.getComputedStyle(el); // Check if element is visible (not hidden by display:none or visibility:hidden) if (rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0') { target = el; break; } } // If no visible element found, use first one anyway if (!target && elements.length > 0) { target = elements[0]; // Try to make it visible if it's in a hidden dropdown const dropdown = target.closest('[id*="Dropdown"]'); if (dropdown && dropdown.classList.contains('hidden')) { dropdown.classList.remove('hidden'); } } if (!target) { console.warn(`Onboarding target not accessible: ${step.target}`); this.showStep(index + 1); return; } this.displayStep(target, step, index); } /** * Display a step for a given target element */ displayStep(target, step, index) { // Ensure element is visible (expand dropdowns, etc.) const dropdown = target.closest('[id*="Dropdown"]'); if (dropdown && dropdown.classList.contains('hidden')) { dropdown.classList.remove('hidden'); } // Scroll target into view first target.scrollIntoView({ behavior: 'smooth', block: 'center', inline: 'nearest' }); // Wait for scroll animation to complete, then proceed setTimeout(() => { // Update tooltip content first (so we can measure it) this.updateTooltip(step, index); // Wait for tooltip to render with content, then position both highlight and tooltip requestAnimationFrame(() => { requestAnimationFrame(() => { // Get fresh element position after scroll const finalRect = target.getBoundingClientRect(); const style = window.getComputedStyle(target); // Validate that element is actually visible if (finalRect.width === 0 || finalRect.height === 0) { console.warn('Target element has zero dimensions, but continuing anyway'); // Don't skip - just proceed with positioning } // Highlight target after content is rendered this.highlightElement(target); // Position tooltip this.positionTooltip(target, step); }); }); }, 400); } /** * Highlight target element */ highlightElement(element) { let highlight = document.querySelector('.onboarding-highlight'); let mask = document.querySelector('.onboarding-mask'); if (!highlight) { highlight = document.createElement('div'); highlight.className = 'onboarding-highlight'; document.body.appendChild(highlight); } if (!mask) { mask = document.createElement('div'); mask.className = 'onboarding-mask'; mask.style.cssText = ` position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); z-index: 9999; pointer-events: none; transition: opacity 0.3s ease-out; `; document.body.appendChild(mask); } // Use getBoundingClientRect which already accounts for scroll const rect = element.getBoundingClientRect(); const padding = 10; // Calculate highlight position const highlightTop = Math.round(rect.top - padding); const highlightLeft = Math.round(rect.left - padding); const highlightWidth = Math.round(rect.width + padding * 2); const highlightHeight = Math.round(rect.height + padding * 2); // Use fixed positioning to match viewport coordinates highlight.style.position = 'fixed'; highlight.style.top = `${highlightTop}px`; highlight.style.left = `${highlightLeft}px`; highlight.style.width = `${highlightWidth}px`; highlight.style.height = `${highlightHeight}px`; highlight.style.display = 'block'; highlight.style.zIndex = '10000'; highlight.style.visibility = 'visible'; // Create a mask that reveals the highlighted area // Use CSS radial gradient for the cutout const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; // Make cutout larger than the highlight for better visibility const ellipseWidth = Math.max(highlightWidth * 2, 200); const ellipseHeight = Math.max(highlightHeight * 2, 200); // Apply mask to overlay mask element using CSS radial gradient // In CSS mask-image: white/opaque = visible (shows), black/transparent = hidden (hides) // We want: hide overlay at center (reveal element), show overlay at edges (cover everything else) // So: transparent at center (hides overlay), white at edges (shows overlay) const overlayMask = document.querySelector('.onboarding-mask'); if (overlayMask) { // Radial gradient: transparent at center (hides overlay, reveals element), white at edges (shows overlay, covers background) const maskGradient = `radial-gradient(ellipse ${ellipseWidth}px ${ellipseHeight}px at ${centerX}px ${centerY}px, transparent 0%, transparent 50%, rgba(255,255,255,0.2) 65%, rgba(255,255,255,0.6) 80%, white 100%)`; overlayMask.style.maskImage = maskGradient; overlayMask.style.webkitMaskImage = maskGradient; overlayMask.style.maskSize = '100% 100%'; overlayMask.style.maskPosition = '0 0'; overlayMask.style.maskRepeat = 'no-repeat'; } // Also apply mask to the base overlay const baseOverlay = document.querySelector('.onboarding-overlay'); if (baseOverlay) { const overlayGradient = `radial-gradient(ellipse ${ellipseWidth}px ${ellipseHeight}px at ${centerX}px ${centerY}px, transparent 0%, transparent 50%, rgba(255,255,255,0.2) 65%, rgba(255,255,255,0.6) 80%, white 100%)`; baseOverlay.style.maskImage = overlayGradient; baseOverlay.style.webkitMaskImage = overlayGradient; baseOverlay.style.maskSize = '100% 100%'; baseOverlay.style.maskPosition = '0 0'; baseOverlay.style.maskRepeat = 'no-repeat'; } } /** * Position tooltip relative to target */ positionTooltip(element, step) { // Get element position first - getBoundingClientRect already accounts for scroll const rect = element.getBoundingClientRect(); // Validate element is visible if (rect.width === 0 || rect.height === 0) { console.warn('Cannot position tooltip: element has zero dimensions'); return; } // Ensure tooltip is positioned off-screen for measurement this.tooltip.style.position = 'fixed'; this.tooltip.style.top = '-9999px'; this.tooltip.style.left = '-9999px'; this.tooltip.style.visibility = 'hidden'; this.tooltip.style.display = 'block'; this.tooltip.style.opacity = '0'; this.tooltip.style.transform = 'scale(0)'; // Force a reflow to ensure tooltip is rendered void this.tooltip.offsetWidth; // Now measure the tooltip const tooltipRect = this.tooltip.getBoundingClientRect(); const position = step.position || 'bottom'; // If tooltip has no dimensions, use default dimensions const tooltipWidth = tooltipRect.width > 0 ? tooltipRect.width : 400; const tooltipHeight = tooltipRect.height > 0 ? tooltipRect.height : 200; let top, left; // Calculate position based on preference // All coordinates are relative to viewport (from getBoundingClientRect) switch (position) { case 'top': top = rect.top - tooltipHeight - 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); break; case 'bottom': top = rect.bottom + 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); break; case 'left': top = rect.top + (rect.height / 2) - (tooltipHeight / 2); left = rect.left - tooltipWidth - 20; break; case 'right': top = rect.top + (rect.height / 2) - (tooltipHeight / 2); left = rect.right + 20; break; default: top = rect.bottom + 20; left = rect.left + (rect.width / 2) - (tooltipWidth / 2); } // Keep within viewport const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const padding = 10; // Adjust horizontal position if needed if (left < padding) { left = padding; } else if (left + tooltipWidth > viewportWidth - padding) { left = Math.max(padding, viewportWidth - tooltipWidth - padding); } // Adjust vertical position if needed if (top < padding) { // If tooltip would go above viewport, try bottom instead if (position === 'top') { top = rect.bottom + 20; } else { top = padding; } } else if (top + tooltipHeight > viewportHeight - padding) { // If tooltip would go below viewport, try top instead if (position === 'bottom') { top = Math.max(padding, rect.top - tooltipHeight - 20); } else { top = Math.max(padding, viewportHeight - tooltipHeight - padding); } } // Validate final coordinates are reasonable if (isNaN(top) || isNaN(left) || top < 0 || left < 0) { console.error('Invalid tooltip position calculated:', { top, left, rect }); // Fallback to center of viewport top = (viewportHeight - tooltipHeight) / 2; left = (viewportWidth - tooltipWidth) / 2; } // Apply final position using fixed positioning (viewport coordinates) this.tooltip.style.position = 'fixed'; this.tooltip.style.top = `${Math.round(top)}px`; this.tooltip.style.left = `${Math.round(left)}px`; this.tooltip.style.visibility = 'visible'; this.tooltip.style.display = 'block'; this.tooltip.style.opacity = '1'; this.tooltip.style.transform = 'scale(1)'; this.tooltip.style.zIndex = '10001'; // Ensure it's above overlay (z-index 9998) and highlight (10000) } /** * Update tooltip content */ updateTooltip(step, index) { const isLast = index === this.steps.length - 1; // Store reference to manager for event handlers const manager = this; this.tooltip.innerHTML = `

${step.title}

${step.content}
`; // Attach event listeners using event delegation this.tooltip.querySelectorAll('[data-action]').forEach(btn => { const action = btn.getAttribute('data-action'); btn.addEventListener('click', (e) => { e.stopPropagation(); if (action === 'skip') { manager.skip(); } else if (action === 'next') { manager.next(); } else if (action === 'previous') { manager.previous(); } }); }); } /** * Go to next step */ next() { this.showStep(this.currentStep + 1); } /** * Go to previous step */ previous() { this.showStep(this.currentStep - 1); } /** * Skip the tour */ async skip() { // Temporarily lower the mask and overlay z-index so the confirmation dialog (z-index 2000) appears above them const mask = document.querySelector('.onboarding-mask'); const highlight = document.querySelector('.onboarding-highlight'); const originalMaskZ = mask?.style.zIndex; const originalOverlayZ = this.overlay?.style.zIndex; const originalHighlightZ = highlight?.style.zIndex; // Lower mask and overlay z-index so confirmation dialog (z-index 2000) appears above if (mask) { mask.style.zIndex = '1500'; } if (this.overlay) { this.overlay.style.zIndex = '1500'; } if (highlight) { highlight.style.zIndex = '1501'; } const confirmed = await showConfirm( 'Are you sure you want to skip the tour? You can restart it later from the Help menu.', { title: 'Skip Tour', confirmText: 'Skip', cancelText: 'Continue Tour', variant: 'warning' } ); // Restore original z-index values if (mask) { if (originalMaskZ) { mask.style.zIndex = originalMaskZ; } else { mask.style.zIndex = ''; } } if (this.overlay) { if (originalOverlayZ) { this.overlay.style.zIndex = originalOverlayZ; } else { this.overlay.style.zIndex = ''; } } if (highlight) { if (originalHighlightZ) { highlight.style.zIndex = originalHighlightZ; } else { highlight.style.zIndex = ''; } } if (confirmed) { this.complete(); } } /** * Complete the tour */ complete() { // Remove elements if (this.overlay) this.overlay.remove(); if (this.tooltip) this.tooltip.remove(); document.querySelector('.onboarding-highlight')?.remove(); document.querySelector('.onboarding-mask')?.remove(); // Remove resize listener if it exists if (this.resizeHandler) { window.removeEventListener('resize', this.resizeHandler); this.resizeHandler = null; } // Mark as completed localStorage.setItem(this.storageKey, 'true'); // Show success message if (window.toastManager) { window.toastManager.success('Tour completed! You\'re all set to start tracking time.'); } // Trigger completion callback if provided if (this.onComplete) { this.onComplete(); } } /** * Check if onboarding is completed */ isCompleted() { return localStorage.getItem(this.storageKey) === 'true'; } /** * Reset onboarding (for testing) */ reset() { localStorage.removeItem(this.storageKey); } } // Default tour steps for TimeTracker const defaultTourSteps = [ { target: '#sidebar', title: 'Welcome to TimeTracker! 👋', content: 'Let\'s take a quick tour to help you get started. This is your main navigation where you can access all features. Tip: You can collapse the sidebar by clicking the arrow icon to maximize your workspace.', position: 'right' }, { target: 'a[href*="dashboard"]', title: 'Dashboard', content: 'Your command center! View today\'s hours, active timers, recent entries, top projects, and activity timeline. Pro tip: Customize widgets to see what matters most to you. You can also see your time tracking at a glance without navigating away.', position: 'right' }, { target: 'a[href*="timer"]', title: 'Time Tracking', content: 'Start timers or manually log your time. Key features:
• Timers run server-side (even if browser closes!)
• Press T to quickly toggle timer
• Use bulk entry for multiple days
• Save time entry templates for recurring work
• Idle detection auto-pauses after inactivity', position: 'right' }, { target: 'a[href*="projects"]', title: 'Projects', content: 'Organize your work with projects. What you can do:
• Link projects to clients for billing
• Set hourly rates per project
• Track billable vs non-billable hours
• Monitor project budgets and costs
• Add project descriptions with Markdown
• Archive completed projects', position: 'right' }, { target: 'a[href*="clients"]', title: 'Clients', content: 'Manage your clients and their information. Store contact details, billing rates, and company information. Clients are automatically linked to projects for streamlined invoicing and reporting.', position: 'right' }, { target: 'a[href*="tasks"]', title: 'Tasks', content: 'Break down projects into manageable tasks. Features:
• Track time against specific tasks
• Set priorities and due dates
• Assign tasks to team members
• Monitor progress with status tracking
• Use estimates vs actuals for planning
• Add comments for collaboration', position: 'right' }, { target: 'a[href*="kanban"]', title: 'Kanban Board', content: 'Visual task management with drag-and-drop! Move tasks between columns (To Do, In Progress, Review, Done) to track progress. Perfect for agile workflows and visual project management. Power feature: Customize columns to match your workflow.', position: 'right' }, { target: 'a[href*="calendar"]', title: 'Calendar View', content: 'Visualize your time entries on a calendar. See your schedule at a glance, spot gaps, and plan your time more effectively. Drag and drop entries to reschedule them quickly.', position: 'right' }, { target: 'a[href*="reports"]', title: 'Reports & Analytics', content: 'Gain insights into your time usage. Available reports:
• Time breakdown by project, user, or date
• Billable vs non-billable analysis
• Export to PDF or CSV
• Custom date ranges
• Save filters for quick access
• Visual charts and graphs', position: 'right' }, { target: 'a[href*="invoices"]', title: 'Invoicing', content: 'Generate professional invoices from your tracked time. Features:
• Auto-generate from time entries
• Add custom line items and expenses
• Tax calculations
• PDF export with branding
• Track payment status
• Send invoices to clients', position: 'right' }, { target: 'a[href*="analytics"]', title: 'Analytics Dashboard', content: 'Deep insights into your productivity and time patterns. View trends, project analytics, and performance metrics. Identify your most productive times and optimize your workflow.', position: 'right' }, { target: '#header-search', title: 'Global Search', content: 'Quickly find anything! Search across projects, tasks, clients, and time entries. Keyboard shortcut: Press Ctrl+/ (or Cmd+/ on Mac) to focus the search bar instantly.', position: 'bottom' }, { target: 'button[onclick*="openCommandPalette"]', title: 'Command Palette', content: 'Power user feature! Press Ctrl+K (or Cmd+K on Mac) to open the command palette. Navigate to any feature, execute actions, and access shortcuts without using your mouse. Try it: Press Ctrl+K now!', position: 'bottom' }, { target: '#theme-toggle', title: 'Theme Toggle', content: 'Switch between light and dark mode. Your preference is saved automatically. Keyboard shortcut: Press Ctrl+Shift+L to toggle themes quickly.', position: 'bottom' } ]; // Initialize global onboarding manager window.onboardingManager = new OnboardingManager(); // Auto-start onboarding for new users document.addEventListener('DOMContentLoaded', () => { // Check if user is on dashboard and hasn't completed onboarding if (window.location.pathname === '/main/dashboard' || window.location.pathname === '/') { setTimeout(() => { // Skip on mobile devices (width < 768px) const isMobile = window.innerWidth <= 768; if (!isMobile && !window.onboardingManager.isCompleted()) { window.onboardingManager.init(defaultTourSteps); } else if (isMobile) { // Mark as completed on mobile to prevent future attempts localStorage.setItem('onboarding_completed', 'true'); } }, 1000); } }); // Add restart tour button to help menu function restartTour() { window.onboardingManager.reset(); window.onboardingManager.init(defaultTourSteps); }