/ services / navigationHistoryService.ts
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  };