# Offline PWA Mode - E2E Documentation ## Overview The Tour Builder Platform implements a sophisticated Progressive Web App (PWA) system enabling complete offline functionality. The system combines Service Workers, Cache API, and IndexedDB to provide seamless offline tour experiences with intelligent caching, download resume capability, and network adaptation. --- ## Architecture ``` ┌─────────────────────────────────────────────────────────────────┐ │ Service Worker Layer │ │ Serwist (sw.ts) │ Precaching │ Runtime Caching │ Range Requests│ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Storage Layer │ │ Cache API (< 5MB) │ IndexedDB (≥ 5MB) │ StorageManager │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Download Layer │ │ DownloadManager │ DownloadEventBus │ Queue Persistence │ │ assetUrl (presigned URLs + proxy fallback) │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Asset Cache Service Layer │ │ AssetCacheService │ assetDiscovery │ discoverProjectAssets │ │ Unified asset discovery for online preload and offline mode │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ Hook Layer │ │ usePreloadOrchestrator │ usePageSwitch │ useNeighborGraph │ │ useNetworkAware │ useOfflineMode │ useStorageQuota │ │ usePreloadProgress │ usePWAPreload │ └─────────────────────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────────────────────┐ │ UI Components │ │ OfflineToggle │ OfflineStatusIndicator │ StorageUsageDisplay │ │ DownloadProgressPanel │ └─────────────────────────────────────────────────────────────────┘ ``` --- ## Service Worker (Serwist) ### Configuration Location: `frontend/src/sw.ts` Built with **Serwist** (modern Workbox successor): ```javascript // next.config.mjs { swSrc: 'src/sw.ts', swDest: 'public/sw.js', disable: process.env.NODE_ENV === 'development', maximumFileSizeToCacheInBytes: 5 * 1024 * 1024 } ``` The raised precache size limit keeps the main `_app` chunk in the Serwist precache. `next.config.mjs` also sets `outputFileTracingRoot` to the frontend directory so Next.js does not infer a parent workspace root when unrelated lockfiles exist above the project. ### Caching Strategies | Resource Type | Strategy | Cache Name | |--------------|----------|------------| | Build assets | Precache | `serwist-precache-v2` | | Images, fonts, CSS, JS | CacheFirst + cacheKeyWillBeUsed | `tour-builder-assets-v1` | | Videos | CacheFirst + Range + cacheKeyWillBeUsed | `tour-builder-assets-v1` | | API responses | NetworkFirst | `api-cache` | | Audio files | CacheFirst + cacheKeyWillBeUsed | `tour-builder-assets-v1` | ### Unified Cache Keys (cacheKeyWillBeUsed Plugin) All asset handlers use the `cacheKeyWillBeUsed` plugin to normalize URLs to storage keys. This ensures that: - Preload (DownloadManager) stores assets with storage keys: `assets/project-id/file.jpg` - Browser loads (SW interception) also use storage keys for cache lookup and storage - Cache hits occur regardless of presigned URL signature changes ```typescript // Applied to static, video, audio, and dynamic asset handlers cacheKeyWillBeUsed: async ({ request, mode }) => { const storagePath = extractStoragePathFromUrl(request.url); if (storagePath) { console.log(`[SW] Using storagePath for ${mode}:`, storagePath.slice(-40)); return new Request(storagePath); } return request; } ``` ### Cacheable Extensions ``` Images: .png, .jpg, .jpeg, .gif, .webp, .svg, .ico Video: .mp4, .webm, .mov Audio: .mp3, .wav, .ogg, .m4a Fonts: .woff, .woff2, .ttf, .eot Code: .css, .js ``` ### Range Request Support Enables video seeking in cached content via Serwist plugin: ```typescript // sw.ts - Video caching with Range support using Serwist plugin { matcher: ({ request }) => isVideoRequest(request), handler: new CacheFirst({ cacheName: OFFLINE_CONFIG.cacheNames.assets, plugins: [ { // Handle range requests for video seeking cachedResponseWillBeUsed: async ({ cachedResponse, request }) => { if (!cachedResponse) return null; const rangeHeader = request.headers.get('range'); if (!rangeHeader) return cachedResponse; // Parse range header const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); if (!match) return cachedResponse; const start = parseInt(match[1], 10); const end = match[2] ? parseInt(match[2], 10) : undefined; // Get the full response body const blob = await cachedResponse.blob(); const slicedBlob = end !== undefined ? blob.slice(start, end + 1) : blob.slice(start); // Return partial content return new Response(slicedBlob, { status: 206, statusText: 'Partial Content', headers: { 'Content-Type': cachedResponse.headers.get('Content-Type') || 'video/mp4', 'Content-Length': String(slicedBlob.size), 'Content-Range': `bytes ${start}-${end !== undefined ? end : blob.size - 1}/${blob.size}`, 'Accept-Ranges': 'bytes', }, }); }, cacheWillUpdate: async ({ response }) => { if (response && response.status === 200) { return response; } return null; }, }, ], }), }, ``` ### Message API Service worker receives commands from main thread: | Message | Payload | Description | |---------|---------|-------------| | `CACHE_ASSETS` | `{ urls: string[] }` | Cache specific asset URLs | | `CACHE_VIDEO_CHUNK` | `{ url, chunk, contentType }` | Cache video chunks with streaming | | `CLEAR_CACHE` | - | Clear all dynamic caches | | `GET_CACHE_STATUS` | - | Query cache status | | `SKIP_WAITING` | - | Force immediate activation | ```typescript // Main thread → Service Worker navigator.serviceWorker.controller.postMessage({ type: 'CACHE_ASSETS', payload: { urls: ['https://example.com/video.mp4'] } }); // Service Worker → Main thread (response to GET_CACHE_STATUS) // { type: 'CACHE_STATUS', payload: { cachedCount: number, urls: string[] } } ``` ### Lifecycle ``` 1. Registration → Next.js/Serwist auto-registers (production) 2. Installation → Caches all precache entries (build assets) 3. Activation → Deletes old cache versions, claims clients 4. Fetch → Intercepts requests, applies strategies 5. Update → Checks for updates every 60 minutes ``` --- ## Cache Organization ### Named Caches ```typescript // frontend/src/config/offline.config.ts cacheNames: { static: 'tour-builder-static-v1', // Precached build assets dynamic: 'tour-builder-dynamic-v1', // Runtime API responses (note: SW uses 'api-cache') assets: 'tour-builder-assets-v1', // Project assets (used by SW and preloading) } ``` **Note**: The actual API cache in the Service Worker is `api-cache`, not `tour-builder-dynamic-v1`. The `dynamic` config key is reserved for future use. ### Cache Invalidation Version bump clears old caches on SW activation: ``` tour-builder-static-v1 → tour-builder-static-v2 ↓ Old v1 cache automatically deleted ``` --- ## Dual Storage Strategy ### Storage Decision ``` ┌─────────────────────────────────────────┐ │ File Size Check │ └─────────────────────────────────────────┘ │ ┌─────────┴─────────┐ │ │ ▼ ▼ Size < 5MB Size ≥ 5MB │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ Cache API │ │ IndexedDB │ │ (Fast, SW) │ │ (Large files) │ └───────────────┘ └───────────────┘ ``` ### Why Dual Storage? | Storage | Pros | Cons | |---------|------|------| | **Cache API** | Fast, SW integration, simple API | Size limits, no metadata | | **IndexedDB** | Large files, rich metadata, queries | Slower, more complex | ### StorageManager Location: `frontend/src/lib/offline/StorageManager.ts` ```typescript class StorageManager { // Storage quota check (returns StorageQuotaInfo with canStore function) static async getStorageQuota(): Promise; // Request persistent storage (won't be cleared by browser) static async requestPersistentStorage(): Promise; // Determine storage location (5MB threshold) static shouldUseIndexedDB(sizeBytes: number): boolean { return sizeBytes >= OFFLINE_CONFIG.storage.indexedDbMinSize; } // Store asset (auto-selects Cache API or IndexedDB based on size) static async storeAsset(url: string, blob: Blob, metadata: AssetMetadata): Promise; // Retrieve asset (checks IndexedDB first, then Cache API) static async getAsset(url: string): Promise; // Check if asset exists (checks both storages) static async hasAsset(url: string): Promise; // Delete asset from both storages static async deleteAsset(url: string, assetId?: string): Promise; // Bulk delete for project static async deleteProjectAssets(projectId: string): Promise; // Send URLs to SW for caching static async cacheViaServiceWorker(urls: string[]): Promise; // Get SW cache status static async getServiceWorkerCacheStatus(): Promise<{ cachedCount: number; urls: string[] }>; // Get total storage used static async getTotalStorageUsed(): Promise; // Clear all offline storage static async clearAll(): Promise; } ``` --- ## IndexedDB Schema ### Database: `TourBuilderOffline` (v1) Location: `frontend/src/lib/offlineDb/schema.ts` **Built with Dexie.js:** ```typescript import Dexie, { type EntityTable } from 'dexie'; class OfflineDatabase extends Dexie { assets!: EntityTable; projects!: EntityTable; downloadQueue!: EntityTable; constructor() { super(OFFLINE_CONFIG.dbName); // 'TourBuilderOffline' this.version(OFFLINE_CONFIG.dbVersion).stores({ // Index definitions (comma-separated indexed fields) assets: 'id, projectId, url, variantType, assetType, downloadedAt', projects: 'id, slug, status, lastSyncedAt', downloadQueue: 'id, projectId, status, priority, addedAt', }); } } ``` ### Assets Table ```typescript interface OfflineAsset { id: string; projectId: string; url: string; filename: string; variantType: AssetVariantType; assetType: AssetType; mimeType: string; sizeBytes: number; blob: Blob; // Actual file data downloadedAt: number; // Timestamp } ``` ### Projects Table ```typescript interface OfflineProject { id: string; slug: string; name: string; // Project display name status: ProjectOfflineStatus; totalAssets: number; downloadedAssets: number; totalSizeBytes: number; downloadedSizeBytes: number; lastSyncedAt?: number; // Optional timestamp version?: string; // Manifest version for update detection } type ProjectOfflineStatus = | 'not_downloaded' | 'downloading' | 'downloaded' | 'outdated' | 'error'; ``` ### Download Queue Table ```typescript interface DownloadQueueItem { id: string; projectId: string; assetId: string; url: string; filename: string; status: PreloadJobStatus; // 'queued' | 'downloading' | 'completed' | 'error' | 'paused' priority: number; retryCount: number; bytesLoaded: number; totalBytes: number; addedAt: number; lastAttemptAt?: number; error?: string; } ``` ### OfflineDbManager Location: `frontend/src/lib/offlineDb/OfflineDbManager.ts` (~342 LOC) ```typescript class OfflineDbManager { // Asset operations static async storeAsset(asset: OfflineAsset): Promise; static async getAsset(id: string): Promise; static async getAssetByUrl(url: string): Promise; static async getProjectAssets(projectId: string): Promise; static async deleteAsset(id: string): Promise; static async deleteProjectAssets(projectId: string): Promise; static async hasAsset(id: string): Promise; static async hasAssetByUrl(url: string): Promise; static async getTotalAssetsSize(): Promise; static async getProjectAssetsSize(projectId: string): Promise; // Project tracking static async upsertProject(project: OfflineProject): Promise; static async getProject(id: string): Promise; static async getProjectBySlug(slug: string): Promise; static async getAllProjects(): Promise; static async updateProjectStatus(id: string, status: ProjectOfflineStatus): Promise; static async updateProjectProgress(id: string, downloadedAssets: number, downloadedSizeBytes: number): Promise; static async deleteProject(id: string): Promise; // Queue management static async addToQueue(item: DownloadQueueItem): Promise; static async getQueueItem(id: string): Promise; static async getProjectQueue(projectId: string): Promise; static async getPendingQueue(): Promise; static async updateQueueStatus(id: string, status: PreloadJobStatus, error?: string): Promise; static async updateQueueProgress(id: string, bytesLoaded: number, totalBytes: number): Promise; static async incrementRetry(id: string): Promise; static async removeFromQueue(id: string): Promise; static async clearProjectQueue(projectId: string): Promise; static async clearQueue(): Promise; static async resetFailedItems(projectId?: string): Promise; // Utility static async clearAll(): Promise; static async getStats(): Promise<{ projectCount: number; assetCount: number; queueCount: number; totalSizeBytes: number; }>; } ``` --- ## Download Manager Location: `frontend/src/lib/offline/DownloadManager.ts` ### Features - **Priority Queue** - Downloads sorted by importance - **Concurrent Downloads** - Configurable max (default: 3) - **Streaming Progress** - Uses `response.body.getReader()` - **Retry Logic** - 3 retries with linear backoff - **Queue Persistence** - Resumes after page reload via IndexedDB - **Pause/Resume** - Pause all or individual downloads via AbortController - **Cancellation** - Cancel downloads by project or individually - **Presigned URL → Proxy Fallback** - Automatic fallback when S3 presigned URLs fail (CORS) ### Priority Calculation ```typescript priority = assetTypePriority + variantPriority // Asset type weights (PRELOAD_CONFIG.priority.assetType) transition: 150 // Highest - needed immediately on navigation click image: 100 audio: 50 video: 30 // Variant weights (PRELOAD_CONFIG.priority.variant) thumbnail: 50 preview: 40 webp: 35 mp4_low: 20 mp4_high: 10 original: 5 ``` ### Download Flow ``` ┌─────────────────────────────────────────┐ │ 1. Add assets to queue │ │ └── downloadManager.addJob(params) │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 2. Persist to IndexedDB │ │ └── Resume capability on reload │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 3. Sort by priority │ │ └── Higher priority = download first│ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 4. Process queue (max 3 concurrent) │ │ └── fetch() with AbortController │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 5. Stream response body │ │ └── response.body.getReader() │ │ └── Track bytesLoaded / totalBytes │ │ └── Emit progress events │ └─────────────────────────────────────────┘ │ ┌──────┴──────┐ ▼ ▼ ┌──────────────────┐ ┌─────────────────────────┐ │ Download OK │ │ Download FAILED │ │ │ │ (e.g., CORS error) │ └────────┬─────────┘ └───────────┬─────────────┘ │ │ │ ▼ │ ┌─────────────────────────┐ │ │ 6a. Presigned URL? │ │ │ → Retry with proxy URL │ │ │ /api/file/download?... │ │ └───────────┬─────────────┘ │ │ ▼ ▼ ┌─────────────────────────────────────────┐ │ 7. Store in appropriate storage │ │ └── < 5MB → Cache API │ │ └── ≥ 5MB → IndexedDB │ │ └── With isPartial flag if partial │ └─────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 8. Emit completion event │ │ └── emitComplete({ storageKey }) │ └─────────────────────────────────────────┘ ``` ### Retry Configuration ```typescript // From PRELOAD_CONFIG retry: { maxRetries: 3, retryDelayMs: 1000, // Delay before retry } // Linear backoff: retryDelayMs * retryCount // 1s → 2s → 3s ``` ### Resume Capability Queue state persisted in IndexedDB: ```typescript // On page load await downloadManager.restoreQueue(); // Restores pending/paused items from downloadQueue table // Re-adds to in-memory queue sorted by priority // Continues processing automatically ``` ### API ```typescript const downloadManager = new DownloadManagerClass(); // Add job await downloadManager.addJob({ assetId: string; projectId: string; url: string; filename: string; variantType: AssetVariantType; assetType: AssetType; priority?: number; // Auto-calculated if not provided }); // Control downloadManager.pauseAll(); downloadManager.resumeAll(); downloadManager.cancelJob(jobId: string); downloadManager.cancelProjectDownloads(projectId: string); downloadManager.clearQueue(); // Status downloadManager.getStatus(); // { queueLength, activeCount, isPaused } // Restore await downloadManager.restoreQueue(); ``` --- ## S3 Presigned URL Integration For S3 storage, the preload system uses presigned URLs for direct downloads, bypassing the backend proxy. ### Flow ``` 1. Batch fetch presigned URLs └── POST /api/file/presign { urls: [...] } 2. Download directly from S3 └── fetch(presignedUrl) with progress tracking 3. Store in Cache API (dual key) └── caches.put(downloadUrl, blob) // By presigned URL └── caches.put(storageKey, blob) // By canonical path (e.g., "assets/vid.mp4") 4. Create blob URL └── URL.createObjectURL(blob) 5. Pre-decode (images only) └── new Image().decode() on blob URL 6. Store ready blob URL (dual key) └── readyBlobUrlsRef.set(downloadUrl, blobUrl) └── readyBlobUrlsRef.set(storageKey, blobUrl) // Enables lookup after URL change ``` **Storage Key Mapping:** Assets are stored under both download URL AND storage key (canonical path). This ensures cache hits even when presigned URLs change signatures or after page refresh: | Scenario | Lookup Key | Result | |----------|------------|--------| | Same session | `storageKey` | ✅ Instant from readyBlobUrlsRef | | After URL regeneration | `storageKey` | ✅ Instant (signature doesn't matter) | | After page refresh | `storageKey` | ✅ From Cache API | ### Key Functions | Function | Source | Purpose | |----------|--------|---------| | `queuePresignedUrls()` | `lib/assetUrl.ts` | Batch queue URLs for presigning | | `flushPresignedUrlQueue()` | `lib/assetUrl.ts` | Fetch queued presigned URLs | | `resolveAssetPlaybackUrl()` | `lib/assetUrl.ts` | Resolve URL with presigned cache | | `getReadyBlobUrl()` | `usePreloadOrchestrator` | O(1) instant lookup for pre-decoded blob URL | | `getCachedBlobUrl()` | `usePreloadOrchestrator` | Async fallback - creates blob URL from Cache API | ### Presigned URL Expiry - URLs expire after 1 hour (3600 seconds) - Frontend caches with 1-hour TTL, refreshes 5 minutes before expiry - On CORS failure, automatically falls back to backend proxy (`/api/file/download`) --- ## Download Event Bus Location: `frontend/src/lib/offline/DownloadEventBus.ts` Browser-native EventEmitter (no Socket.IO dependency): ### Events | Event | Payload Interface | |-------|-------------------| | `asset-preload-start` | `PreloadStartEvent` | | `asset-preload-progress` | `PreloadProgressEvent` | | `asset-preload-complete` | `PreloadCompleteEvent` | | `asset-preload-error` | `PreloadErrorEvent` | | `project-download-progress` | `ProjectDownloadProgressEvent` | | `project-download-complete` | `ProjectDownloadCompleteEvent` | | `queue-update` | `void` (no payload) | ### Event Payloads ```typescript interface PreloadStartEvent { jobId: string; assetId: string; url: string; } interface PreloadProgressEvent { jobId: string; progress: number; // 0-100 bytesLoaded: number; totalBytes: number; } interface PreloadCompleteEvent { jobId: string; assetId: string; storageKey: string; // Canonical storage key for cache lookup } interface PreloadErrorEvent { jobId: string; assetId: string; error: string; } interface ProjectDownloadProgressEvent { projectId: string; progress: number; // 0-100 downloadedAssets: number; totalAssets: number; downloadedBytes: number; totalBytes: number; } interface ProjectDownloadCompleteEvent { projectId: string; } ``` ### Usage ```typescript import { downloadEventBus } from '@/lib/offline/DownloadEventBus'; import { OFFLINE_CONFIG } from '@/config/offline.config'; // Subscribe to events const unsubscribe = downloadEventBus.on( OFFLINE_CONFIG.events.preloadProgress, // 'asset-preload-progress' (event: PreloadProgressEvent) => { console.log(`Downloaded ${event.progress}%`); updateProgressUI(event.progress); } ); // Unsubscribe when done unsubscribe(); // Or manually unsubscribe downloadEventBus.off(OFFLINE_CONFIG.events.preloadProgress, handler); // One-time subscription downloadEventBus.once(OFFLINE_CONFIG.events.preloadComplete, handler); // Remove all listeners downloadEventBus.removeAllListeners(); ``` ### Convenience Methods ```typescript // Pre-built emit methods on downloadEventBus downloadEventBus.emitPreloadStart(data: PreloadStartEvent); downloadEventBus.emitPreloadProgress(data: PreloadProgressEvent); downloadEventBus.emitPreloadComplete(data: PreloadCompleteEvent); downloadEventBus.emitPreloadError(data: PreloadErrorEvent); downloadEventBus.emitProjectProgress(data: ProjectDownloadProgressEvent); downloadEventBus.emitProjectComplete(data: ProjectDownloadCompleteEvent); downloadEventBus.emitQueueUpdate(); ``` --- ## Hooks ### useOfflineMode Location: `frontend/src/hooks/useOfflineMode.ts` Orchestrates offline mode for a specific project. Uses **frontend asset discovery** (same as online preloading) - no backend manifest dependency. ```typescript interface UseOfflineModeOptions { projectId: string | null; projectSlug?: string; projectName?: string; /** Pages data for frontend asset discovery (required for offline download) */ pages?: PreloadPage[]; enabled?: boolean; } interface UseOfflineModeResult { // Status isOfflineCapable: boolean; // SW + caches supported isDownloaded: boolean; // status === 'downloaded' isDownloading: boolean; // status === 'downloading' && !isPaused status: ProjectOfflineStatus; progress: number; // 0-100 downloadedAssets: number; totalAssets: number; downloadedBytes: number; totalBytes: number; error: string | null; // Actions startDownload: () => Promise; // Discover assets from pages, queue downloads pauseDownload: () => void; resumeDownload: () => void; cancelDownload: () => void; deleteOfflineData: () => Promise; checkForUpdates: () => Promise; // Info projectInfo: OfflineProject | null; estimatedSize: number; formatSize: (bytes: number) => string; } // Usage const { isDownloaded, isDownloading, progress, startDownload, deleteOfflineData, formatSize, estimatedSize, } = useOfflineMode({ projectId: '123', projectSlug: 'my-tour', projectName: 'My Tour', pages, // Required: pages data for asset discovery }); ``` ### useStorageQuota Location: `frontend/src/hooks/useStorageQuota.ts` Monitors storage quota and usage: ```typescript interface UseStorageQuotaResult extends StorageQuotaInfo { isLoading: boolean; error: string | null; refresh: () => Promise; requestPersistence: () => Promise; isPersisted: boolean; isWarning: boolean; // percentUsed >= 80% isCritical: boolean; // percentUsed >= 95% formatSize: (bytes: number) => string; } // Usage const { usage, quota, percentUsed, available, canStore, isWarning, isCritical, isPersisted, requestPersistence, formatSize, } = useStorageQuota(); // Check if we can store 100MB if (canStore(100 * 1024 * 1024)) { // Proceed with download } ``` ### useNetworkAware Location: `frontend/src/hooks/useNetworkAware.ts` Network detection and adaptive behavior: ```typescript interface NetworkInfo { isOnline: boolean; effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; downlink?: number; // Mbps rtt?: number; // Round-trip time (ms) saveData?: boolean; // Data saver mode } interface UseNetworkAwareResult { networkInfo: NetworkInfo; shouldPreloadAggressively: boolean; // 4g or downlink >= 5Mbps preferLowQuality: boolean; // slow-2g, 2g, or saveData recommendedConcurrency: number; // 1-3 based on connection suggestOfflineMode: boolean; // Poor connection detected } // Usage const { networkInfo, shouldPreloadAggressively, preferLowQuality, recommendedConcurrency, } = useNetworkAware(); if (shouldPreloadAggressively) { // Preload neighbor pages } ``` ### Concurrency Adaptation | Connection | Concurrent Downloads | |------------|---------------------| | 4g / Good | 3 | | 3g | 2 | | 2g / Slow | 1 | | Save-Data | 1 | --- ## UI Components Location: `frontend/src/components/Offline/` ### OfflineToggle Button component for toggling offline mode on a project: ```typescript interface OfflineToggleProps { projectId: string | null; projectSlug?: string; projectName?: string; /** Pages data for frontend asset discovery (required for offline download) */ pages?: PreloadPage[]; className?: string; showLabel?: boolean; // Default: true size?: 'small' | 'medium' | 'large'; // Default: 'medium' } // Usage ``` Features: - Shows download status with icons (cloud download, cloud check, cloud off) - Displays size estimate before download - Shows progress percentage during download - Downloaded state switches the same runtime control into its active/remove state; deleting offline data uses the active offline button with confirmation - Storage quota warnings - Confirmation dialogs for deletion ### OfflineStatusIndicator Displays current offline/online status with visual indicator. ### StorageUsageDisplay Shows storage quota usage with progress bar and statistics. ### DownloadProgressPanel Panel showing active downloads with individual progress bars and controls. --- ## Frontend Asset Discovery ### Overview Asset discovery is handled entirely in the frontend using the same logic for both online preloading and offline downloads. This ensures consistency and eliminates backend dependencies. Location: `frontend/src/lib/assetCache/` ### Key Components | File | Purpose | |------|---------| | `AssetCacheService.ts` | Unified entry point for asset caching | | `assetDiscovery.ts` | Shared asset extraction logic | | `index.ts` | Module exports | ### Asset Discovery Functions ```typescript // Discover all assets for a project (offline download) discoverProjectAssets( pages: PreloadPage[], pageLinks: PreloadPageLink[], elements: PreloadElement[], options?: AssetDiscoveryOptions, ): AssetToCache[] // Get prioritized assets for current page + neighbors (online preload) getPrioritizedAssets( currentPageId: string, pages: PreloadPage[], pageLinks: PreloadPageLink[], elements: PreloadElement[], neighborPageIds: Array<{ pageId: string; distance: number }>, options?: AssetDiscoveryOptions, ): AssetToCache[] ``` ### AssetToCache Interface ```typescript interface AssetToCache { storageKey: string; // Canonical storage key for caching originalUrl: string; // Original URL from page/element data assetType: AssetType; // 'image' | 'video' | 'audio' | 'transition' | 'other' pageId: string; // Page this asset belongs to priority: number; // Download priority (higher = first) sizeBytes?: number; // Estimated size in bytes isTransition?: boolean; // True for transition videos } ``` ### Asset Sources The discovery system extracts assets from: 1. **Page backgrounds** - `background_image_url`, `background_video_url`, `background_audio_url` 2. **Element content_json** - All URL fields defined in `PRELOAD_CONFIG.assetFields.all` 3. **Transition videos** - `transition.video_url` from page links 4. **Nested assets** - `galleryCards[].imageUrl`, `carouselSlides[].videoUrl`, etc. ### URL Fields Scanned ```typescript // From PRELOAD_CONFIG.assetFields.all [ 'iconUrl', 'imageUrl', 'mediaUrl', 'videoUrl', 'audioUrl', 'transitionVideoUrl', 'backgroundImageUrl', 'reverseVideoUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', 'src', 'url', 'poster', 'thumbnail' ] ``` ### AssetCacheService Unified service for both online preload and offline download: ```typescript class AssetCacheService { // Discover assets (single source of truth) static discoverProjectAssets(pages, pageLinks, elements, options?): AssetToCache[] static getPrioritizedAssets(currentPageId, pages, pageLinks, elements, neighbors, options?): AssetToCache[] // Check cache status static async getAssetInfo(storageKey): Promise static async checkCacheStatus(storageKeys): Promise> static async filterAssetsNeedingDownload(assets, mode): Promise // Queue downloads static async queueDownloads(assets, options: QueueDownloadOptions): Promise // Resolve download URL (presigned or proxy) static resolveDownloadUrl(storageKey, presignedUrls?): string // Cache utilities static async clearProjectCache(projectId): Promise static getReadyBlobUrl(url): string | null static hasReadyBlobUrl(url): boolean } ``` ### CachedAssetInfo Interface ```typescript interface CachedAssetInfo { storageKey: string; exists: boolean; isPartial: boolean; // True for partial downloads (need full for offline) sizeBytes: number; downloadedAt?: number; } ``` ### Usage in useOfflineMode ```typescript // frontend/src/hooks/useOfflineMode.ts const discoverAssets = useCallback((): AssetToCache[] => { if (!pages || pages.length === 0) return []; // Extract pageLinks and elements from all pages const { pageLinks, preloadElements } = extractPageLinksAndElements(pages); // Use shared asset discovery (same as online preload) return discoverProjectAssets(pages, pageLinks, preloadElements); }, [pages]); const startDownload = useCallback(async () => { const assets = discoverAssets(); // ... queue downloads using discovered assets }, [discoverAssets]); ``` --- ## Offline Types Location: `frontend/src/types/offline.ts` ```typescript // Project status type ProjectOfflineStatus = | 'not_downloaded' | 'downloading' | 'downloaded' | 'outdated' | 'error'; // Asset variant types type AssetVariantType = | 'thumbnail' | 'preview' | 'webp' | 'mp4_low' | 'mp4_high' | 'original'; // Asset types type AssetType = | 'image' | 'video' | 'audio' | 'transition' | 'other'; // Preload job status type PreloadJobStatus = | 'queued' | 'downloading' | 'completed' | 'error' | 'paused'; // Storage quota info interface StorageQuotaInfo { usage: number; quota: number; percentUsed: number; available: number; canStore: (bytes: number) => boolean; } // Network information interface NetworkInfo { isOnline: boolean; effectiveType?: 'slow-2g' | '2g' | '3g' | '4g'; downlink?: number; rtt?: number; saveData?: boolean; } // Cache status for an asset (from StorageManager.getAssetInfo) interface CachedAssetInfo { storageKey: string; exists: boolean; isPartial: boolean; // True for partial downloads sizeBytes: number; downloadedAt?: number; } // Asset to cache (from assetDiscovery) interface AssetToCache { storageKey: string; originalUrl: string; assetType: AssetType; pageId: string; priority: number; sizeBytes?: number; isTransition?: boolean; } // Preload state interface PreloadState { isActive: boolean; currentPageId: string | null; queuedAssets: string[]; loadingAssets: string[]; completedAssets: string[]; failedAssets: string[]; totalProgress: number; } // Neighbor graph interface NeighborGraph { connections: Map>; getNeighbors: (pageId: string, maxDepth: number) => string[]; getAssetsForPages: (pageIds: string[]) => string[]; } ``` --- ## Key Workflows ### A. Project Download for Offline Use ``` 1. User clicks OfflineToggle button └── useOfflineMode.startDownload() 2. Discover assets from pages (frontend-only, no backend call) └── discoverProjectAssets(pages, pageLinks, preloadElements) └── Same asset discovery as online preload 3. Check which assets need downloading └── StorageManager.getAssetInfo(storageKey) for each └── Skip fully cached, re-download partial for offline 4. Check storage quota └── StorageManager.getStorageQuota() └── Ensure sufficient space available 5. Create project record in IndexedDB └── OfflineDbManager.upsertProject() 6. Fetch presigned URLs for S3 assets └── queuePresignedUrls(storagePaths) └── Falls back to proxy if presigning fails 7. Initialize download queue └── downloadManager.addJob() for each asset └── Full downloads (no maxBytes) for offline 8. Process downloads └── Priority-based concurrent downloads (max 3) └── Stream with progress tracking └── Auto-fallback: presigned URL → proxy on CORS error 9. Store assets └── < 5MB → Cache API └── ≥ 5MB → IndexedDB └── With isPartial: false for offline completeness 10. Track progress via events └── Update project status in IndexedDB └── Emit events via DownloadEventBus (includes storageKey) 11. Complete └── Mark project as 'downloaded' └── Emit projectDownloadComplete event ``` ### B. Offline Asset Retrieval ``` 1. App requests asset (by storage path) └── e.g., storagePath = 'assets/project-123/video.mp4' 2. Try in-memory lookup first (O(1) instant) └── getReadyBlobUrl(storagePath) └── Returns pre-decoded blob URL if available 3. Try Cache API by storage key └── getCachedBlobUrl(storagePath) └── Works after page refresh (in-memory cleared) 4. Try resolved URL lookups (fallback) └── getReadyBlobUrl(resolvedUrl) └── getCachedBlobUrl(resolvedUrl) 5. If found └── Create blob URL: URL.createObjectURL(blob) └── Return for playback 6. If not found └── Try network fetch (if online) └── Or show offline error ``` **Lookup Priority:** ``` 1. getReadyBlobUrl(storageKey) → O(1) instant (same session) 2. getCachedBlobUrl(storageKey) → Cache API (~5ms, post-refresh) 3. getReadyBlobUrl(resolvedUrl) → fallback 4. getCachedBlobUrl(resolvedUrl) → fallback 5. Network fetch → last resort ``` ### C. Resume Interrupted Downloads ``` 1. Page loads └── downloadManager.restoreQueue() 2. Query IndexedDB └── OfflineDbManager.getPendingQueue() └── Get pending/paused queue items 3. Resume downloads └── Re-add to in-memory queue └── Sort by priority └── Continue processing 4. Update progress └── Emit events for UI ``` ### D. Check for Updates ``` 1. App calls useOfflineMode.checkForUpdates() 2. Discover current assets from pages └── discoverProjectAssets(pages, pageLinks, preloadElements) 3. Compare with stored project info └── If asset count changed, set status to 'outdated' 4. User can re-download └── deleteOfflineData() then startDownload() ``` --- ## Configuration Reference ### Preload Configuration Location: `frontend/src/config/preload.config.ts` ```typescript export const PRELOAD_CONFIG = { // Queue settings maxConcurrentDownloads: 3, maxRetries: 3, retryDelayMs: 1000, // Size thresholds largeFileThreshold: 5 * 1024 * 1024, // 5MB -> use IndexedDB videoChunkSize: 5 * 1024 * 1024, // 5MB chunks initialVideoBufferSeconds: 5, // Priority weights (higher = load first) priority: { currentPage: 1000, neighborBase: 500, assetType: { transition: 150, // Highest - needed immediately on navigation click image: 100, audio: 50, video: 30, }, variant: { thumbnail: 50, preview: 40, webp: 35, mp4_low: 20, mp4_high: 10, original: 5, }, linkCountMultiplier: 10, maxLinkBonus: 50, }, // Storage warnings storage: { warningPercent: 80, criticalPercent: 95, minFreeBuffer: 50 * 1024 * 1024, // 50MB }, // Auto-cleanup timeouts autoRemove: { completedMs: 3000, errorMs: 10000, }, // Neighbor graph traversal neighborGraph: { maxDepth: 1, // Only preload immediate neighbors (reduced from 2) constructorMaxDepth: 1, // Same as maxDepth for constructor }, // Asset URL field names in element content_json assetFields: { all: [ 'iconUrl', 'imageUrl', 'mediaUrl', 'videoUrl', 'audioUrl', 'transitionVideoUrl', 'backgroundImageUrl', 'reverseVideoUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', 'src', 'url', 'poster', 'thumbnail', ], images: [ 'iconUrl', 'imageUrl', 'backgroundImageUrl', 'carouselPrevIconUrl', 'carouselNextIconUrl', 'src', ], nested: ['galleryCards', 'carouselSlides'], nestedUrlFields: ['imageUrl', 'videoUrl'], }, }; ``` ### Offline Configuration Location: `frontend/src/config/offline.config.ts` ```typescript export const OFFLINE_CONFIG = { // IndexedDB dbName: 'TourBuilderOffline', dbVersion: 1, // Cache names (for Cache API) cacheNames: { static: 'tour-builder-static-v1', dynamic: 'tour-builder-dynamic-v1', assets: 'tour-builder-assets-v1', }, // Events (EventEmitter event names) events: { preloadStart: 'asset-preload-start', preloadProgress: 'asset-preload-progress', preloadComplete: 'asset-preload-complete', preloadError: 'asset-preload-error', projectDownloadProgress: 'project-download-progress', projectDownloadComplete: 'project-download-complete', queueUpdate: 'queue-update', }, // Service worker settings serviceWorker: { scope: '/', updateInterval: 60 * 60 * 1000, // 1 hour }, // Storage settings storage: { cacheApiMaxSize: 5 * 1024 * 1024, // 5MB indexedDbMinSize: 5 * 1024 * 1024, // 5MB }, // Retry settings retry: { maxRetries: 3, backoffMs: 1000, maxBackoffMs: 30000, }, }; ``` --- ## Key Files Reference | File | Location | Purpose | |------|----------|---------| | `sw.ts` | `frontend/src/` | Service worker implementation | | `offline.config.ts` | `frontend/src/config/` | Offline settings | | `preload.config.ts` | `frontend/src/config/` | Preload settings | | `StorageManager.ts` | `frontend/src/lib/offline/` | Dual-storage abstraction | | `DownloadManager.ts` | `frontend/src/lib/offline/` | Download queue with auto proxy fallback | | `DownloadEventBus.ts` | `frontend/src/lib/offline/` | Event emitter | | `OfflineDbManager.ts` | `frontend/src/lib/offlineDb/` | IndexedDB operations | | `schema.ts` | `frontend/src/lib/offlineDb/` | Dexie schema | | `AssetCacheService.ts` | `frontend/src/lib/assetCache/` | Unified asset caching service | | `assetDiscovery.ts` | `frontend/src/lib/assetCache/` | Shared asset extraction logic | | `assetUrl.ts` | `frontend/src/lib/` | Presigned URL cache and resolution | | `extractPageLinks.ts` | `frontend/src/lib/` | Extract navigation targets from pages | | `usePreloadOrchestrator.ts` | `frontend/src/hooks/` | Asset preloading with ready blob URLs | | `usePageSwitch.ts` | `frontend/src/hooks/` | Page navigation using preloaded assets | | `useOfflineMode.ts` | `frontend/src/hooks/` | Project offline orchestration (frontend discovery) | | `useStorageQuota.ts` | `frontend/src/hooks/` | Storage quota monitoring | | `useNetworkAware.ts` | `frontend/src/hooks/` | Network detection | | `OfflineToggle.tsx` | `frontend/src/components/Offline/` | Offline toggle button | | `OfflineStatusIndicator.tsx` | `frontend/src/components/Offline/` | Offline status display | | `StorageUsageDisplay.tsx` | `frontend/src/components/Offline/` | Storage quota visualization | | `DownloadProgressPanel.tsx` | `frontend/src/components/Offline/` | Download progress panel | | `usePWAPreload.ts` | `frontend/src/hooks/` | PWA preload orchestration | | `offline.ts` | `frontend/src/types/` | Type definitions | --- ## Browser Requirements | Feature | Support | |---------|---------| | Service Workers | All modern browsers | | IndexedDB | All modern browsers (Dexie handles differences) | | Cache API | All modern browsers | | Network Information API | Chrome, Edge, Firefox (graceful degradation) | | Blob URLs | All modern browsers | | navigator.storage.persist() | Most modern browsers | | Response streaming | All modern browsers | --- ## Troubleshooting ### Downloads Not Starting 1. Check network connectivity 2. Verify storage quota available (`useStorageQuota`) 3. Check browser console for errors 4. Ensure Service Worker registered 5. Verify project ID is valid ### Assets Not Loading Offline 1. Verify assets in IndexedDB/Cache API (DevTools → Application tab) 2. Check Service Worker fetch handler 3. Look for CORS issues 4. Verify blob URL creation 5. Check `StorageManager.hasAsset(url)` returns true ### Storage Quota Exceeded 1. Clear old project downloads (`deleteOfflineData`) 2. Delete unused cached assets 3. Request persistent storage (`requestPersistence`) 4. Reduce asset quality (use mobile variant) 5. Check `isCritical` flag from `useStorageQuota` ### Download Resume Not Working 1. Check IndexedDB downloadQueue table (DevTools → Application → IndexedDB) 2. Verify `restoreQueue()` called on load 3. Check for corrupted queue items 4. Clear queue and re-download ### Video Seeking Issues Offline 1. Verify video is in cache (check DevTools → Application → Cache Storage) 2. Check video cached with proper Content-Type header 3. Ensure Range request handler working (check Network tab for 206 responses) 4. Test with smaller video file 5. Check if video is in IndexedDB (for large files > 5MB) ### Progress Not Updating 1. Verify DownloadEventBus subscriptions 2. Check for event name mismatches (use `OFFLINE_CONFIG.events.*`) 3. Ensure handler is not unsubscribed prematurely 4. Check for errors in event handler (caught silently by bus) ### useOfflineMode Not Working 1. Verify `projectId` is not null 2. Check `enabled` option (default: true) 3. Verify `pages` prop is provided (required for asset discovery) 4. Verify `isOfflineCapable` is true (SW + caches supported) 5. Check browser console for `[useOfflineMode]` logs