navigationHistoryService.ts
1 import AsyncStorage from '@react-native-async-storage/async-storage'; 2 import { 3 NavigationHistory, 4 NavigationContext, 5 NavigationEntry, 6 NavigationContextType, 7 NavigationSettings, 8 NavigationState, 9 BreadcrumbItem, 10 NavigationAnalytics, 11 LegacyNavigationHistory, 12 } from '../types/navigation'; 13 14 const HISTORY_KEY = 'navigation_history_v2'; 15 const LEGACY_HISTORY_KEY = 'navigation_history'; 16 const ANALYTICS_KEY = 'navigation_analytics'; 17 const SETTINGS_KEY = 'navigation_settings'; 18 19 const DEFAULT_SETTINGS: NavigationSettings = { 20 maxHistorySize: 50, 21 enableGestures: true, 22 swipeSensitivity: 0.5, 23 showBreadcrumbs: false, 24 enableSmartSuggestions: true, 25 contextSeparation: false, 26 }; 27 28 const DEFAULT_ROUTE = '/mangasearch'; 29 30 class NavigationHistoryService { 31 private static instance: NavigationHistoryService; 32 private history: NavigationHistory | null = null; 33 private analytics: NavigationAnalytics | null = null; 34 private sessionId: string = ''; 35 private saveQueue: (() => Promise<void>)[] = []; 36 private isProcessingSaveQueue = false; 37 private performanceMetrics = { 38 addToHistoryTime: 0, 39 getPreviousRouteTime: 0, 40 getNavigationStateTime: 0, 41 totalOperations: 0, 42 }; 43 44 private constructor() { 45 this.generateSessionId(); 46 } 47 48 static getInstance(): NavigationHistoryService { 49 if (!NavigationHistoryService.instance) { 50 NavigationHistoryService.instance = new NavigationHistoryService(); 51 } 52 return NavigationHistoryService.instance; 53 } 54 55 private generateSessionId(): void { 56 this.sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 57 } 58 59 private async initializeHistory(): Promise<NavigationHistory> { 60 try { 61 const historyData = await AsyncStorage.getItem(HISTORY_KEY); 62 63 if (historyData) { 64 const parsed: NavigationHistory = JSON.parse(historyData); 65 // Validate version and migrate if needed 66 if (parsed.version !== 2) { 67 return await this.migrateFromLegacy(); 68 } 69 return parsed; 70 } 71 72 // Check for legacy history to migrate 73 const legacyData = await AsyncStorage.getItem(LEGACY_HISTORY_KEY); 74 if (legacyData) { 75 return await this.migrateFromLegacy(); 76 } 77 78 // Create new history 79 return this.createNewHistory(); 80 } catch (error) { 81 console.error('Error initializing history:', error); 82 return this.createNewHistory(); 83 } 84 } 85 86 private async migrateFromLegacy(): Promise<NavigationHistory> { 87 try { 88 const legacyData = await AsyncStorage.getItem(LEGACY_HISTORY_KEY); 89 const newHistory = this.createNewHistory(); 90 91 if (legacyData) { 92 const legacy: LegacyNavigationHistory = JSON.parse(legacyData); 93 const browseContext = this.createContext('browse'); 94 95 // Migrate legacy paths to new structure 96 legacy.paths.forEach((path, index) => { 97 const entry: NavigationEntry = { 98 path, 99 title: this.getPageTitle(path), 100 timestamp: 101 legacy.lastUpdated - (legacy.paths.length - index) * 1000, 102 context: this.determineContext(path), 103 metadata: this.extractMetadata(path), 104 }; 105 browseContext.stack.push(entry); 106 newHistory.globalStack.push(entry); 107 }); 108 109 newHistory.contexts.browse = browseContext; 110 newHistory.currentContext = 'browse'; 111 } 112 113 await this.saveHistory(newHistory); 114 return newHistory; 115 } catch (error) { 116 console.error('Error migrating from legacy:', error); 117 return this.createNewHistory(); 118 } 119 } 120 121 private createNewHistory(): NavigationHistory { 122 return { 123 contexts: {}, 124 globalStack: [], 125 currentContext: 'browse', 126 settings: { ...DEFAULT_SETTINGS }, 127 lastUpdated: Date.now(), 128 version: 2, 129 }; 130 } 131 132 private createContext(type: NavigationContextType): NavigationContext { 133 return { 134 id: `${type}_${Date.now()}`, 135 type, 136 stack: [], 137 metadata: { 138 lastAccessed: Date.now(), 139 sessionId: this.sessionId, 140 totalVisits: 0, 141 averageTimeSpent: 0, 142 }, 143 }; 144 } 145 146 private determineContext(path: string): NavigationContextType { 147 if (path.includes('/manga/') && path.includes('/chapter/')) { 148 return 'reading'; 149 } 150 if (path === '/mangasearch') { 151 return 'search'; 152 } 153 if (path === '/settings') { 154 return 'settings'; 155 } 156 return 'browse'; 157 } 158 159 private extractMetadata(path: string): NavigationEntry['metadata'] { 160 const metadata: NavigationEntry['metadata'] = {}; 161 162 // Extract manga ID from path 163 const mangaMatch = path.match(/\/manga\/([^\/]+)/); 164 if (mangaMatch?.[1]) { 165 metadata.mangaId = mangaMatch[1]; 166 } 167 168 // Extract chapter number 169 const chapterMatch = path.match(/\/chapter\/([^\/]+)/); 170 if (chapterMatch) { 171 metadata.chapterNumber = parseInt(chapterMatch?.[1] || '0', 10); 172 } 173 174 // Extract search query (if present in path) 175 const searchMatch = path.match(/[?&]q=([^&]+)/); 176 if (searchMatch) { 177 metadata.searchQuery = decodeURIComponent(searchMatch?.[1] || ''); 178 } 179 180 return metadata; 181 } 182 183 private getPageTitle(path: string): string { 184 const titleMap: Record<string, string> = { 185 '/': 'Home', 186 '/mangasearch': 'Search', 187 '/bookmarks': 'Bookmarks', 188 '/settings': 'Settings', 189 }; 190 191 if (titleMap[path]) { 192 return titleMap[path]; 193 } 194 195 if (path.includes('/manga/') && path.includes('/chapter/')) { 196 const chapterMatch = path.match(/\/chapter\/([^\/]+)/); 197 return chapterMatch ? `Chapter ${chapterMatch[1]}` : 'Chapter'; 198 } 199 200 if (path.includes('/manga/')) { 201 return 'Manga Details'; 202 } 203 204 return 'Page'; 205 } 206 207 private async saveHistory(history: NavigationHistory): Promise<void> { 208 return new Promise((resolve) => { 209 const saveOperation = async () => { 210 try { 211 history.lastUpdated = Date.now(); 212 await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(history)); 213 this.history = history; 214 resolve(); 215 } catch (error) { 216 console.error('Error saving history:', error); 217 resolve(); // Still resolve to not block the queue 218 } 219 }; 220 221 this.saveQueue.push(saveOperation); 222 this.processSaveQueue(); 223 }); 224 } 225 226 private async processSaveQueue(): Promise<void> { 227 if (this.isProcessingSaveQueue || this.saveQueue.length === 0) { 228 return; 229 } 230 231 this.isProcessingSaveQueue = true; 232 233 while (this.saveQueue.length > 0) { 234 const operation = this.saveQueue.shift(); 235 if (operation) { 236 await operation(); 237 } 238 } 239 240 this.isProcessingSaveQueue = false; 241 } 242 243 private measurePerformance<T>( 244 operation: () => Promise<T> | T, 245 metricKey: keyof typeof this.performanceMetrics 246 ): Promise<T> | T { 247 const start = Date.now(); 248 249 try { 250 const result = operation(); 251 252 if (result instanceof Promise) { 253 return result.finally(() => { 254 const duration = Date.now() - start; 255 this.updatePerformanceMetric(metricKey, duration); 256 }); 257 } else { 258 const duration = Date.now() - start; 259 this.updatePerformanceMetric(metricKey, duration); 260 return result; 261 } 262 } catch (error) { 263 const duration = Date.now() - start; 264 this.updatePerformanceMetric(metricKey, duration); 265 throw error; 266 } 267 } 268 269 private updatePerformanceMetric( 270 key: keyof typeof this.performanceMetrics, 271 duration: number 272 ): void { 273 if (key === 'totalOperations') { 274 this.performanceMetrics[key]++; 275 } else { 276 // Calculate running average 277 const currentCount = this.performanceMetrics.totalOperations; 278 const currentAvg = 279 this.performanceMetrics[key as keyof typeof this.performanceMetrics]; 280 this.performanceMetrics[key as keyof typeof this.performanceMetrics] = 281 (currentAvg * currentCount + duration) / (currentCount + 1); 282 } 283 } 284 285 private async getHistory(): Promise<NavigationHistory> { 286 if (!this.history) { 287 this.history = await this.initializeHistory(); 288 } 289 return this.history; 290 } 291 292 private pruneHistory(context: NavigationContext, maxSize: number): void { 293 if (context.stack.length > maxSize) { 294 const toRemove = context.stack.length - maxSize; 295 context.stack.splice(0, toRemove); 296 } 297 } 298 299 private updateAnalytics(entry: NavigationEntry): void { 300 // Update analytics in background 301 this.updateAnalyticsAsync(entry).catch(console.error); 302 } 303 304 private async updateAnalyticsAsync(entry: NavigationEntry): Promise<void> { 305 try { 306 let analytics = this.analytics; 307 308 if (!analytics) { 309 const analyticsData = await AsyncStorage.getItem(ANALYTICS_KEY); 310 analytics = analyticsData 311 ? JSON.parse(analyticsData) 312 : { 313 totalNavigations: 0, 314 averageSessionLength: 0, 315 mostVisitedPaths: {}, 316 navigationPatterns: [], 317 gestureUsageStats: { 318 swipeBack: 0, 319 tapBack: 0, 320 breadcrumbUsage: 0, 321 }, 322 }; 323 this.analytics = analytics; 324 } 325 326 if (analytics) { 327 analytics.totalNavigations++; 328 analytics.mostVisitedPaths[entry.path] = 329 (analytics.mostVisitedPaths[entry.path] || 0) + 1; 330 331 // Update patterns (last 10 paths) 332 analytics.navigationPatterns.push(entry.path); 333 if (analytics.navigationPatterns.length > 10) { 334 analytics.navigationPatterns.shift(); 335 } 336 } 337 338 await AsyncStorage.setItem(ANALYTICS_KEY, JSON.stringify(analytics)); 339 } catch (error) { 340 console.error('Error updating analytics:', error); 341 } 342 } 343 344 async addToHistory( 345 path: string, 346 options: { 347 title?: string; 348 context?: NavigationContextType; 349 metadata?: Partial<NavigationEntry['metadata']>; 350 } = {} 351 ): Promise<void> { 352 return this.measurePerformance(async () => { 353 try { 354 const history = await this.getHistory(); 355 const contextType = options.context || this.determineContext(path); 356 357 // Don't add if it's the same as the last entry in global stack 358 const lastEntry = history.globalStack[history.globalStack.length - 1]; 359 if (lastEntry && lastEntry.path === path) { 360 console.log('🔍 Navigation Debug - Skipping duplicate path:', path); 361 return; 362 } 363 364 // NEVER store chapter routes in history - they should only be accessible via SwipeChapterItem 365 if (path.includes('/manga/') && path.includes('/chapter/')) { 366 console.log( 367 '🚫 Navigation Debug - Skipping chapter route from history:', 368 path 369 ); 370 return; 371 } 372 373 console.log('🔍 Navigation Debug - Adding to history:', path); 374 375 // Create navigation entry 376 const entry: NavigationEntry = { 377 path, 378 title: options.title || this.getPageTitle(path), 379 timestamp: Date.now(), 380 context: contextType, 381 metadata: { 382 ...this.extractMetadata(path), 383 ...options.metadata, 384 }, 385 }; 386 387 // Add to global stack (primary navigation tracking) 388 history.globalStack.push(entry); 389 390 // Also maintain context stack for analytics 391 let context = history.contexts[contextType]; 392 if (!context) { 393 context = this.createContext(contextType); 394 history.contexts[contextType] = context; 395 } 396 context.stack.push(entry); 397 context.metadata.lastAccessed = Date.now(); 398 context.metadata.totalVisits++; 399 history.currentContext = contextType; 400 401 // Keep global stack manageable - this is the key for accurate navigation 402 if (history.globalStack.length > history.settings.maxHistorySize) { 403 history.globalStack.splice( 404 0, 405 history.globalStack.length - history.settings.maxHistorySize 406 ); 407 } 408 409 // Also prune context stacks 410 this.pruneHistory(context, history.settings.maxHistorySize); 411 412 await this.saveHistory(history); 413 this.updateAnalytics(entry); 414 this.updatePerformanceMetric('totalOperations', 0); 415 } catch (error) { 416 console.error('Error adding to history:', error); 417 } 418 }, 'addToHistoryTime'); 419 } 420 421 async getPreviousRoute(currentPath: string): Promise<string> { 422 return this.measurePerformance(async () => { 423 try { 424 const history = await this.getHistory(); 425 426 console.log('🔍 Navigation Debug - Current Path:', currentPath); 427 console.log( 428 '🔍 Navigation Debug - Global Stack:', 429 history.globalStack.map((entry) => entry.path) 430 ); 431 432 // Special handling for chapter pages - go back to manga detail 433 if ( 434 currentPath.includes('/manga/') && 435 currentPath.includes('/chapter/') 436 ) { 437 const mangaMatch = currentPath.match(/\/manga\/([^\/]+)/); 438 if (mangaMatch) { 439 const previousRoute = `/manga/${mangaMatch[1]}`; 440 console.log( 441 '🔍 Navigation Debug - Chapter -> Manga:', 442 previousRoute 443 ); 444 return previousRoute; 445 } 446 return DEFAULT_ROUTE; 447 } 448 449 // Use global stack for simple, accurate navigation 450 if (history.globalStack.length < 2) { 451 console.log( 452 '🔍 Navigation Debug - Insufficient history, using default' 453 ); 454 return DEFAULT_ROUTE; 455 } 456 457 // Find the current path in the global stack (search from end) 458 let currentIndex = -1; 459 for (let i = history.globalStack.length - 1; i >= 0; i--) { 460 if (history.globalStack[i]?.path === currentPath) { 461 currentIndex = i; 462 break; 463 } 464 } 465 466 console.log('🔍 Navigation Debug - Current Index:', currentIndex); 467 468 // If current path found and there's a previous entry 469 if (currentIndex > 0) { 470 const previousRoute = history.globalStack[currentIndex - 1]?.path; 471 console.log('🔍 Navigation Debug - Found previous:', previousRoute); 472 return previousRoute || DEFAULT_ROUTE; 473 } 474 475 // If current path not found or is first, return the last entry that's not current 476 for (let i = history.globalStack.length - 1; i >= 0; i--) { 477 if (history.globalStack[i]?.path !== currentPath) { 478 const previousRoute = history.globalStack[i]?.path; 479 console.log( 480 '🔍 Navigation Debug - Using last different path:', 481 previousRoute 482 ); 483 return previousRoute || DEFAULT_ROUTE; 484 } 485 } 486 487 console.log('🔍 Navigation Debug - Fallback to default'); 488 return DEFAULT_ROUTE; 489 } catch (error) { 490 console.error('Error getting previous route:', error); 491 return DEFAULT_ROUTE; 492 } 493 }, 'getPreviousRouteTime'); 494 } 495 496 497 private findPreviousContext( 498 history: NavigationHistory, 499 currentContextType: NavigationContextType 500 ): NavigationContext | null { 501 const contexts = Object.values(history.contexts); 502 const sortedContexts = contexts 503 .filter((ctx) => ctx.type !== currentContextType) 504 .sort((a, b) => b.metadata.lastAccessed - a.metadata.lastAccessed); 505 506 return sortedContexts[0] || null; 507 } 508 509 async getNavigationState(currentPath: string): Promise<NavigationState> { 510 try { 511 const history = await this.getHistory(); 512 const contextType = this.determineContext(currentPath); 513 const context = history.contexts[contextType]; 514 515 const state: NavigationState = { 516 canGoBack: false, 517 canGoForward: false, 518 currentDepth: 0, 519 contextHistory: [], 520 breadcrumbs: [], 521 suggestions: [], 522 }; 523 524 if (context) { 525 const currentIndex = context.stack.findLastIndex( 526 (entry) => entry.path === currentPath 527 ); 528 state.canGoBack = 529 currentIndex > 0 || 530 this.findPreviousContext(history, contextType) !== null; 531 state.currentDepth = context.stack.length; 532 state.contextHistory = context.stack.slice(-10); // Last 10 entries 533 534 if (history.settings.showBreadcrumbs) { 535 state.breadcrumbs = this.generateBreadcrumbs(currentPath, context); 536 } 537 538 if (history.settings.enableSmartSuggestions) { 539 state.suggestions = await this.generateSuggestions( 540 currentPath, 541 history 542 ); 543 } 544 } 545 546 return state; 547 } catch (error) { 548 console.error('Error getting navigation state:', error); 549 return { 550 canGoBack: false, 551 canGoForward: false, 552 currentDepth: 0, 553 contextHistory: [], 554 breadcrumbs: [], 555 suggestions: [], 556 }; 557 } 558 } 559 560 private generateBreadcrumbs( 561 currentPath: string, 562 _context: NavigationContext 563 ): BreadcrumbItem[] { 564 const breadcrumbs: BreadcrumbItem[] = []; 565 566 // Add home breadcrumb 567 breadcrumbs.push({ 568 path: '/', 569 title: 'Home', 570 icon: 'home', 571 isClickable: true, 572 }); 573 574 // Add context-specific breadcrumbs 575 if (currentPath.includes('/manga/')) { 576 breadcrumbs.push({ 577 path: '/mangasearch', 578 title: 'Search', 579 icon: 'search', 580 isClickable: true, 581 }); 582 583 if (currentPath.includes('/chapter/')) { 584 const mangaMatch = currentPath.match(/\/manga\/([^\/]+)/); 585 if (mangaMatch) { 586 breadcrumbs.push({ 587 path: `/manga/${mangaMatch[1]}`, 588 title: 'Manga Details', 589 icon: 'book', 590 isClickable: true, 591 }); 592 } 593 594 const chapterMatch = currentPath.match(/\/chapter\/([^\/]+)/); 595 if (chapterMatch) { 596 breadcrumbs.push({ 597 path: currentPath, 598 title: `Chapter ${chapterMatch[1]}`, 599 icon: 'bookmark', 600 isClickable: false, 601 }); 602 } 603 } 604 } 605 606 return breadcrumbs; 607 } 608 609 private async generateSuggestions( 610 currentPath: string, 611 _history: NavigationHistory 612 ): Promise<string[]> { 613 const suggestions: string[] = []; 614 615 // Add frequently visited pages 616 if (this.analytics) { 617 const sorted = Object.entries(this.analytics.mostVisitedPaths) 618 .sort(([, a], [, b]) => b - a) 619 .slice(0, 5) 620 .map(([path]) => path) 621 .filter((path) => path !== currentPath); 622 623 suggestions.push(...sorted); 624 } 625 626 // Add context-specific suggestions 627 const contextType = this.determineContext(currentPath); 628 if (contextType === 'reading' && currentPath.includes('/manga/')) { 629 suggestions.push('/bookmarks'); 630 } 631 632 return suggestions.slice(0, 3); 633 } 634 635 async clearHistory(): Promise<void> { 636 try { 637 const newHistory = this.createNewHistory(); 638 await this.saveHistory(newHistory); 639 } catch (error) { 640 console.error('Error clearing history:', error); 641 } 642 } 643 644 async getSettings(): Promise<NavigationSettings> { 645 try { 646 const settingsData = await AsyncStorage.getItem(SETTINGS_KEY); 647 if (settingsData) { 648 return { ...DEFAULT_SETTINGS, ...JSON.parse(settingsData) }; 649 } 650 return DEFAULT_SETTINGS; 651 } catch (error) { 652 console.error('Error getting settings:', error); 653 return DEFAULT_SETTINGS; 654 } 655 } 656 657 async updateSettings(settings: Partial<NavigationSettings>): Promise<void> { 658 try { 659 const currentSettings = await this.getSettings(); 660 const newSettings = { ...currentSettings, ...settings }; 661 await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(newSettings)); 662 663 // Update history settings 664 const history = await this.getHistory(); 665 history.settings = newSettings; 666 await this.saveHistory(history); 667 } catch (error) { 668 console.error('Error updating settings:', error); 669 } 670 } 671 672 async getAnalytics(): Promise<NavigationAnalytics> { 673 try { 674 const analyticsData = await AsyncStorage.getItem(ANALYTICS_KEY); 675 if (analyticsData) { 676 return JSON.parse(analyticsData); 677 } 678 return { 679 totalNavigations: 0, 680 averageSessionLength: 0, 681 mostVisitedPaths: {}, 682 navigationPatterns: [], 683 gestureUsageStats: { 684 swipeBack: 0, 685 tapBack: 0, 686 breadcrumbUsage: 0, 687 }, 688 }; 689 } catch (error) { 690 console.error('Error getting analytics:', error); 691 return { 692 totalNavigations: 0, 693 averageSessionLength: 0, 694 mostVisitedPaths: {}, 695 navigationPatterns: [], 696 gestureUsageStats: { 697 swipeBack: 0, 698 tapBack: 0, 699 breadcrumbUsage: 0, 700 }, 701 }; 702 } 703 } 704 705 async recordGestureUsage( 706 gestureType: 'swipeBack' | 'tapBack' | 'breadcrumbUsage' 707 ): Promise<void> { 708 try { 709 const analytics = await this.getAnalytics(); 710 analytics.gestureUsageStats[gestureType]++; 711 await AsyncStorage.setItem(ANALYTICS_KEY, JSON.stringify(analytics)); 712 this.analytics = analytics; 713 } catch (error) { 714 console.error('Error recording gesture usage:', error); 715 } 716 } 717 718 getPerformanceMetrics(): typeof this.performanceMetrics { 719 return { ...this.performanceMetrics }; 720 } 721 722 async optimizePerformance(): Promise<void> { 723 try { 724 const history = await this.getHistory(); 725 726 // Clean up old entries beyond reasonable limits 727 const maxGlobalSize = history.settings.maxHistorySize * 3; 728 if (history.globalStack.length > maxGlobalSize) { 729 history.globalStack.splice( 730 0, 731 history.globalStack.length - maxGlobalSize 732 ); 733 } 734 735 // Clean up contexts with no recent activity 736 const oneWeekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; 737 Object.keys(history.contexts).forEach((contextType) => { 738 const context = history.contexts[contextType]; 739 if ( 740 (context?.metadata.lastAccessed || 0) < oneWeekAgo && 741 context?.stack.length === 0 742 ) { 743 delete history.contexts[contextType]; 744 } 745 }); 746 747 await this.saveHistory(history); 748 } catch (error) { 749 console.error('Error optimizing performance:', error); 750 } 751 } 752 } 753 754 // Export singleton instance and legacy-compatible functions 755 const navigationService = NavigationHistoryService.getInstance(); 756 757 export default navigationService; 758 759 // Legacy-compatible exports 760 export const getNavigationHistory = async (): Promise<string[]> => { 761 const history = await navigationService.getNavigationState('/'); 762 return history.contextHistory.map((entry) => entry.path); 763 }; 764 765 export const updateNavigationHistory = async ( 766 newPath: string 767 ): Promise<void> => { 768 await navigationService.addToHistory(newPath); 769 }; 770 771 export const getPreviousRoute = async ( 772 currentPath: string 773 ): Promise<string> => { 774 return await navigationService.getPreviousRoute(currentPath); 775 };