1 // Offline storage utilities for repair guides
2 import type { RepairGuide, TroubleshootingFlow, DeviceComponent } from "@shared/schema";
3
4 const STORAGE_KEYS = {
5 GUIDES: 'jcr_offline_guides',
6 FLOWS: 'jcr_offline_flows',
7 COMPONENTS: 'jcr_offline_components',
8 FAVORITES: 'jcr_offline_favorites',
9 LAST_SYNC: 'jcr_last_sync',
10 USER_GUIDES: 'jcr_user_guides',
11 DOWNLOADS: 'jcr_downloaded_files',
12 VIDEOS: 'jcr_offline_videos',
13 IMAGES: 'jcr_cached_images',
14
15 } as const;
16
17 export interface OfflineData {
18 guides: RepairGuide[];
19 flows: TroubleshootingFlow[];
20 components: DeviceComponent[];
21 favorites: string[];
22 userGuides: RepairGuide[];
23 lastSync: string;
24 }
25
26 export interface StorageUsage {
27 used: number;
28 total: number;
29 percentage: number;
30 breakdown: {
31 guides: number;
32 flows: number;
33 components: number;
34 favorites: number;
35 userGuides: number;
36 downloads: number;
37 videos: number;
38 images: number;
39
40 };
41 }
42
43 export interface DownloadedFile {
44 id: string;
45 name: string;
46 type: 'guide' | 'video' | 'image' | 'document';
47 size: number;
48 downloadDate: string;
49 lastAccessed: string;
50 format: string;
51 relatedGuideId?: string;
52 }
53
54
55
56 // Check if browser supports offline storage
57 export const isOfflineSupported = (): boolean => {
58 try {
59 return typeof localStorage !== 'undefined' &&
60 typeof navigator !== 'undefined' &&
61 'serviceWorker' in navigator;
62 } catch {
63 return false;
64 }
65 };
66
67 // Save data for offline use
68 export const saveOfflineData = async (data: Partial<OfflineData>): Promise<void> => {
69 if (!isOfflineSupported()) return;
70
71 try {
72 if (data.guides) {
73 localStorage.setItem(STORAGE_KEYS.GUIDES, JSON.stringify(data.guides));
74 }
75 if (data.flows) {
76 localStorage.setItem(STORAGE_KEYS.FLOWS, JSON.stringify(data.flows));
77 }
78 if (data.components) {
79 localStorage.setItem(STORAGE_KEYS.COMPONENTS, JSON.stringify(data.components));
80 }
81 if (data.favorites) {
82 localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(data.favorites));
83 }
84 if (data.userGuides) {
85 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(data.userGuides));
86 }
87
88 localStorage.setItem(STORAGE_KEYS.LAST_SYNC, new Date().toISOString());
89 } catch (error) {
90 console.error('Failed to save offline data:', error);
91 }
92 };
93
94 // Load offline data
95 export const loadOfflineData = (): OfflineData => {
96 const defaultData: OfflineData = {
97 guides: [],
98 flows: [],
99 components: [],
100 favorites: [],
101 userGuides: [],
102 lastSync: '',
103 };
104
105 if (!isOfflineSupported()) return defaultData;
106
107 try {
108 return {
109 guides: JSON.parse(localStorage.getItem(STORAGE_KEYS.GUIDES) || '[]'),
110 flows: JSON.parse(localStorage.getItem(STORAGE_KEYS.FLOWS) || '[]'),
111 components: JSON.parse(localStorage.getItem(STORAGE_KEYS.COMPONENTS) || '[]'),
112 favorites: JSON.parse(localStorage.getItem(STORAGE_KEYS.FAVORITES) || '[]'),
113 userGuides: JSON.parse(localStorage.getItem(STORAGE_KEYS.USER_GUIDES) || '[]'),
114 lastSync: localStorage.getItem(STORAGE_KEYS.LAST_SYNC) || '',
115 };
116 } catch (error) {
117 console.error('Failed to load offline data:', error);
118 return defaultData;
119 }
120 };
121
122 // Save user-created guide locally
123 export const saveUserGuide = (guide: RepairGuide): void => {
124 if (!isOfflineSupported()) return;
125
126 try {
127 const userGuides = loadOfflineData().userGuides;
128 const updatedGuides = [...userGuides, { ...guide, id: `user_${Date.now()}` }];
129 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(updatedGuides));
130 } catch (error) {
131 console.error('Failed to save user guide:', error);
132 }
133 };
134
135 // Get user-created guides
136 export const getUserGuides = (): RepairGuide[] => {
137 if (!isOfflineSupported()) return [];
138 return loadOfflineData().userGuides;
139 };
140
141 // Clear all offline data
142 export const clearOfflineData = (): void => {
143 if (!isOfflineSupported()) return;
144
145 try {
146 Object.values(STORAGE_KEYS).forEach(key => {
147 localStorage.removeItem(key);
148 });
149 } catch (error) {
150 console.error('Failed to clear offline data:', error);
151 }
152 };
153
154 // Get comprehensive storage usage info
155 export const getStorageUsage = (): StorageUsage => {
156 if (!isOfflineSupported()) {
157 return {
158 used: 0,
159 total: 0,
160 percentage: 0,
161 breakdown: {
162 guides: 0,
163 flows: 0,
164 components: 0,
165 favorites: 0,
166 userGuides: 0,
167 downloads: 0,
168 videos: 0,
169 images: 0,
170 analytics: 0,
171 }
172 };
173 }
174
175 try {
176 const breakdown = {
177 guides: getItemSize(STORAGE_KEYS.GUIDES),
178 flows: getItemSize(STORAGE_KEYS.FLOWS),
179 components: getItemSize(STORAGE_KEYS.COMPONENTS),
180 favorites: getItemSize(STORAGE_KEYS.FAVORITES),
181 userGuides: getItemSize(STORAGE_KEYS.USER_GUIDES),
182 downloads: getItemSize(STORAGE_KEYS.DOWNLOADS),
183 videos: getItemSize(STORAGE_KEYS.VIDEOS),
184 images: getItemSize(STORAGE_KEYS.IMAGES),
185
186 };
187
188 const used = Object.values(breakdown).reduce((sum, size) => sum + size, 0);
189 const total = 50 * 1024 * 1024; // 50MB increased capacity
190 const percentage = Math.min((used / total) * 100, 100);
191
192 return { used, total, percentage, breakdown };
193 } catch (error) {
194 console.error('Failed to get storage usage:', error);
195 return {
196 used: 0,
197 total: 50 * 1024 * 1024,
198 percentage: 0,
199 breakdown: {
200 guides: 0,
201 flows: 0,
202 components: 0,
203 favorites: 0,
204 userGuides: 0,
205 downloads: 0,
206 videos: 0,
207 images: 0,
208 analytics: 0,
209 }
210 };
211 }
212 };
213
214 // Helper function to get size of localStorage item
215 const getItemSize = (key: string): number => {
216 try {
217 const item = localStorage.getItem(key);
218 return item ? new Blob([item]).size : 0;
219 } catch {
220 return 0;
221 }
222 };
223
224 // Check if device is online
225 export const isOnline = (): boolean => {
226 return typeof navigator !== 'undefined' ? navigator.onLine : true;
227 };
228
229 // Sync data when connection is restored
230 export const syncWhenOnline = async (): Promise<void> => {
231 if (!isOnline() || !isOfflineSupported()) return;
232
233 try {
234 // Get pending user guides
235 const userGuides = getUserGuides();
236 const pendingGuides = userGuides.filter(guide => guide.id.startsWith('user_'));
237
238 // Attempt to sync pending guides to server
239 for (const guide of pendingGuides) {
240 try {
241 const response = await fetch('/api/repair-guides', {
242 method: 'POST',
243 headers: { 'Content-Type': 'application/json' },
244 body: JSON.stringify(guide),
245 });
246
247 if (response.ok) {
248 // Remove from local storage after successful sync
249 const remainingGuides = userGuides.filter(g => g.id !== guide.id);
250 localStorage.setItem(STORAGE_KEYS.USER_GUIDES, JSON.stringify(remainingGuides));
251 }
252 } catch (error) {
253 console.error('Failed to sync guide:', error);
254 // Keep guide in local storage for next sync attempt
255 }
256 }
257
258 // Refresh cached data
259 const [guides, flows, components] = await Promise.all([
260 fetch('/api/repair-guides').then(res => res.json()).catch(() => []),
261 fetch('/api/troubleshooting-flows').then(res => res.json()).catch(() => []),
262 fetch('/api/device-components').then(res => res.json()).catch(() => []),
263 ]);
264
265 await saveOfflineData({ guides, flows, components });
266 } catch (error) {
267 console.error('Sync failed:', error);
268 }
269 };
270
271 // Format storage size for display
272 export const formatStorageSize = (bytes: number): string => {
273 if (bytes === 0) return '0 B';
274 const k = 1024;
275 const sizes = ['B', 'KB', 'MB', 'GB'];
276 const i = Math.floor(Math.log(bytes) / Math.log(k));
277 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
278 };
279
280 // Save downloaded file record
281 export const saveDownloadedFile = (file: DownloadedFile): void => {
282 if (!isOfflineSupported()) return;
283
284 try {
285 const downloads = getDownloadedFiles();
286 const updatedDownloads = [...downloads, { ...file, lastAccessed: new Date().toISOString() }];
287 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updatedDownloads));
288
289 } catch (error) {
290 console.error('Failed to save downloaded file record:', error);
291 }
292 };
293
294 // Get all downloaded files
295 export const getDownloadedFiles = (): DownloadedFile[] => {
296 if (!isOfflineSupported()) return [];
297
298 try {
299 const downloads = localStorage.getItem(STORAGE_KEYS.DOWNLOADS);
300 return downloads ? JSON.parse(downloads) : [];
301 } catch (error) {
302 console.error('Failed to get downloaded files:', error);
303 return [];
304 }
305 };
306
307 // Update file access time
308 export const updateFileAccess = (fileId: string): void => {
309 if (!isOfflineSupported()) return;
310
311 try {
312 const downloads = getDownloadedFiles();
313 const updated = downloads.map(file =>
314 file.id === fileId
315 ? { ...file, lastAccessed: new Date().toISOString() }
316 : file
317 );
318 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(updated));
319 } catch (error) {
320 console.error('Failed to update file access:', error);
321 }
322 };
323
324 // Remove downloaded file record
325 export const removeDownloadedFile = (fileId: string): void => {
326 if (!isOfflineSupported()) return;
327
328 try {
329 const downloads = getDownloadedFiles();
330 const filtered = downloads.filter(file => file.id !== fileId);
331 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(filtered));
332 } catch (error) {
333 console.error('Failed to remove downloaded file:', error);
334 }
335 };
336
337
338
339
340
341
342
343 // Clean up old downloads based on usage
344 export const cleanupOldDownloads = (maxAgeInDays: number = 30): void => {
345 if (!isOfflineSupported()) return;
346
347 try {
348 const downloads = getDownloadedFiles();
349 const cutoffDate = new Date();
350 cutoffDate.setDate(cutoffDate.getDate() - maxAgeInDays);
351
352 const activeDownloads = downloads.filter(file => {
353 const lastAccessed = new Date(file.lastAccessed);
354 return lastAccessed > cutoffDate;
355 });
356
357 localStorage.setItem(STORAGE_KEYS.DOWNLOADS, JSON.stringify(activeDownloads));
358 } catch (error) {
359 console.error('Failed to cleanup old downloads:', error);
360 }
361 };
362
363 // Get storage recommendations
364 export const getStorageRecommendations = (): string[] => {
365 const usage = getStorageUsage();
366 const recommendations: string[] = [];
367
368 if (usage.percentage > 90) {
369 recommendations.push('Storage is almost full. Consider cleaning up old downloads.');
370 }
371
372 if (usage.percentage > 75) {
373 recommendations.push('Storage usage is high. Remove unused content to free up space.');
374 }
375
376 if (usage.breakdown.videos > usage.breakdown.guides) {
377 recommendations.push('Videos take up more space than guides. Consider downloading fewer videos.');
378 }
379
380 if (usage.breakdown.images > 5 * 1024 * 1024) { // 5MB
381 recommendations.push('Cached images are using significant space. Clear image cache if needed.');
382 }
383
384
385
386 return recommendations;
387 };
388
389 // Export cache management for videos and images
390 // Memory-optimized video caching with size limits
391 export const cacheVideo = (videoId: string, videoData: Blob): Promise<boolean> => {
392 return new Promise((resolve) => {
393 if (!isOfflineSupported()) {
394 resolve(false);
395 return;
396 }
397
398 // Check video size limit (max 10MB per video)
399 const MAX_VIDEO_SIZE = 10 * 1024 * 1024;
400 if (videoData.size > MAX_VIDEO_SIZE) {
401 console.warn(`Video ${videoId} too large (${videoData.size} bytes), skipping cache`);
402 resolve(false);
403 return;
404 }
405
406 try {
407 // Check if we need to clear space first
408 const currentUsage = getStorageUsage();
409 const estimatedNewSize = videoData.size * 1.4; // Base64 overhead
410
411 if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) {
412 // Clean up old videos if approaching limit
413 cleanupOldVideos();
414 }
415
416 // Store metadata instead of full data for large videos
417 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
418
419 // For videos larger than 5MB, store only metadata
420 if (videoData.size > 5 * 1024 * 1024) {
421 videos[videoId] = {
422 metadata: {
423 size: videoData.size,
424 type: videoData.type,
425 cached: false,
426 timestamp: Date.now()
427 }
428 };
429 } else {
430 // Store smaller videos as before but with compression info
431 const reader = new FileReader();
432 reader.onload = () => {
433 try {
434 videos[videoId] = {
435 data: reader.result,
436 metadata: {
437 size: videoData.size,
438 type: videoData.type,
439 cached: true,
440 timestamp: Date.now()
441 }
442 };
443 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
444 resolve(true);
445 } catch (error) {
446 console.error('Failed to cache video:', error);
447 resolve(false);
448 }
449 };
450 reader.onerror = () => resolve(false);
451 reader.readAsDataURL(videoData);
452 return;
453 }
454
455 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
456 resolve(true);
457 } catch (error) {
458 console.error('Failed to cache video:', error);
459 resolve(false);
460 }
461 });
462 };
463
464 export const getCachedVideo = (videoId: string): string | null => {
465 if (!isOfflineSupported()) return null;
466
467 try {
468 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
469 const video = videos[videoId];
470
471 if (!video) return null;
472
473 // Return actual data if cached, null if only metadata
474 if (video.metadata && video.metadata.cached && video.data) {
475 return video.data;
476 }
477
478 return null;
479 } catch (error) {
480 console.error('Failed to get cached video:', error);
481 return null;
482 }
483 };
484
485 // Memory-optimized image caching with compression and limits
486 export const cacheImage = (imageUrl: string, imageData: Blob): Promise<boolean> => {
487 return new Promise((resolve) => {
488 if (!isOfflineSupported()) {
489 resolve(false);
490 return;
491 }
492
493 // Check image size limit (max 2MB per image)
494 const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
495 if (imageData.size > MAX_IMAGE_SIZE) {
496 console.warn(`Image ${imageUrl} too large (${imageData.size} bytes), skipping cache`);
497 resolve(false);
498 return;
499 }
500
501 try {
502 // Check if we need to clear space first
503 const currentUsage = getStorageUsage();
504 const estimatedNewSize = imageData.size * 1.4; // Base64 overhead
505
506 if (currentUsage.used + estimatedNewSize > currentUsage.total * 0.9) {
507 // Clean up old images if approaching limit
508 cleanupOldImages();
509 }
510
511 // Compress image if it's large
512 if (imageData.size > 500 * 1024) { // 500KB
513 compressAndCacheImage(imageUrl, imageData, resolve);
514 } else {
515 // Store smaller images directly
516 const reader = new FileReader();
517 reader.onload = () => {
518 try {
519 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
520 images[imageUrl] = {
521 data: reader.result,
522 metadata: {
523 size: imageData.size,
524 type: imageData.type,
525 timestamp: Date.now(),
526 compressed: false
527 }
528 };
529 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
530 resolve(true);
531 } catch (error) {
532 console.error('Failed to cache image:', error);
533 resolve(false);
534 }
535 };
536 reader.onerror = () => resolve(false);
537 reader.readAsDataURL(imageData);
538 }
539 } catch (error) {
540 console.error('Failed to cache image:', error);
541 resolve(false);
542 }
543 });
544 };
545
546 export const getCachedImage = (imageUrl: string): string | null => {
547 if (!isOfflineSupported()) return null;
548
549 try {
550 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
551 const image = images[imageUrl];
552
553 if (!image) return null;
554
555 // Handle both old format (direct data) and new format (with metadata)
556 if (typeof image === 'string') {
557 return image; // Legacy format
558 }
559
560 if (image.data) {
561 // Update access time for LRU cleanup
562 image.metadata.lastAccessed = Date.now();
563 images[imageUrl] = image;
564 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
565 return image.data;
566 }
567
568 return null;
569 } catch (error) {
570 console.error('Failed to get cached image:', error);
571 return null;
572 }
573 };
574
575 // Helper function for image compression during caching
576 function compressAndCacheImage(imageUrl: string, imageData: Blob, resolve: (value: boolean) => void): void {
577 const canvas = document.createElement('canvas');
578 const ctx = canvas.getContext('2d');
579 const img = new Image();
580
581 img.onload = () => {
582 // Calculate compressed dimensions (max 1024x1024)
583 const MAX_SIZE = 1024;
584 let { width, height } = img;
585
586 if (width > MAX_SIZE || height > MAX_SIZE) {
587 if (width > height) {
588 height = (height * MAX_SIZE) / width;
589 width = MAX_SIZE;
590 } else {
591 width = (width * MAX_SIZE) / height;
592 height = MAX_SIZE;
593 }
594 }
595
596 canvas.width = width;
597 canvas.height = height;
598 ctx?.drawImage(img, 0, 0, width, height);
599
600 canvas.toBlob((compressedBlob) => {
601 if (compressedBlob) {
602 const reader = new FileReader();
603 reader.onload = () => {
604 try {
605 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
606 images[imageUrl] = {
607 data: reader.result,
608 metadata: {
609 originalSize: imageData.size,
610 compressedSize: compressedBlob.size,
611 type: compressedBlob.type,
612 timestamp: Date.now(),
613 compressed: true
614 }
615 };
616 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
617 resolve(true);
618 } catch (error) {
619 console.error('Failed to cache compressed image:', error);
620 resolve(false);
621 }
622 };
623 reader.readAsDataURL(compressedBlob);
624 } else {
625 resolve(false);
626 }
627 }, 'image/jpeg', 0.8);
628 };
629
630 img.onerror = () => resolve(false);
631 img.src = URL.createObjectURL(imageData);
632 }
633
634 // Cleanup old videos using LRU strategy
635 function cleanupOldVideos(): void {
636 try {
637 const videos = JSON.parse(localStorage.getItem(STORAGE_KEYS.VIDEOS) || '{}');
638 const videoEntries = Object.entries(videos)
639 .map(([id, data]: [string, any]) => ({
640 id,
641 timestamp: data.metadata?.timestamp || 0
642 }))
643 .sort((a, b) => a.timestamp - b.timestamp); // Oldest first
644
645 // Remove oldest 30% of videos
646 const removeCount = Math.ceil(videoEntries.length * 0.3);
647 const toRemove = videoEntries.slice(0, removeCount);
648
649 toRemove.forEach(({ id }) => {
650 delete videos[id];
651 });
652
653 localStorage.setItem(STORAGE_KEYS.VIDEOS, JSON.stringify(videos));
654 console.log(`Cleaned up ${removeCount} old videos`);
655 } catch (error) {
656 console.error('Failed to cleanup old videos:', error);
657 }
658 }
659
660 // Cleanup old images using LRU strategy
661 function cleanupOldImages(): void {
662 try {
663 const images = JSON.parse(localStorage.getItem(STORAGE_KEYS.IMAGES) || '{}');
664 const imageEntries = Object.entries(images)
665 .map(([url, data]: [string, any]) => {
666 const timestamp = typeof data === 'object' && data.metadata
667 ? data.metadata.lastAccessed || data.metadata.timestamp || 0
668 : 0;
669 return { url, timestamp };
670 })
671 .sort((a, b) => a.timestamp - b.timestamp); // Oldest first
672
673 // Remove oldest 30% of images
674 const removeCount = Math.ceil(imageEntries.length * 0.3);
675 const toRemove = imageEntries.slice(0, removeCount);
676
677 toRemove.forEach(({ url }) => {
678 delete images[url];
679 });
680
681 localStorage.setItem(STORAGE_KEYS.IMAGES, JSON.stringify(images));
682 console.log(`Cleaned up ${removeCount} old images`);
683 } catch (error) {
684 console.error('Failed to cleanup old images:', error);
685 }
686 }
687
688 // Get memory usage summary for monitoring
689 export const getMemoryUsageSummary = (): string => {
690 const usage = getStorageUsage();
691 const breakdown = Object.entries(usage.breakdown)
692 .filter(([_, size]) => size > 0)
693 .map(([type, size]) => `${type}: ${formatStorageSize(size)}`)
694 .join(', ');
695
696 return `Total: ${formatStorageSize(usage.used)} / ${formatStorageSize(usage.total)} (${usage.percentage.toFixed(1)}%). Breakdown: ${breakdown}`;
697 };
698
699 // Force cleanup when storage is nearly full
700 export const forceCleanupIfNeeded = (): boolean => {
701 const usage = getStorageUsage();
702
703 if (usage.percentage > 90) {
704 console.warn('Storage nearly full, forcing cleanup...');
705 cleanupOldImages();
706 cleanupOldVideos();
707
708 // Also clean up old downloaded files
709 try {
710 const downloads = getDownloadedFiles();
711 const oldDownloads = downloads
712 .filter(file => {
713 const daysSinceAccess = (Date.now() - new Date(file.lastAccessed).getTime()) / (1000 * 60 * 60 * 24);
714 return daysSinceAccess > 30; // Older than 30 days
715 })
716 .map(file => file.id);
717
718 oldDownloads.forEach(fileId => removeDownloadedFile(fileId));
719
720 if (oldDownloads.length > 0) {
721 console.log(`Cleaned up ${oldDownloads.length} old download records`);
722 }
723 } catch (error) {
724 console.error('Failed to cleanup old downloads:', error);
725 }
726
727 return true;
728 }
729
730 return false;
731 };