diff --git a/OFFLINE_FIRST.md b/OFFLINE_FIRST.md new file mode 100644 index 0000000..b3e8c2a --- /dev/null +++ b/OFFLINE_FIRST.md @@ -0,0 +1,181 @@ +# To AI Agent: Implement Offline-First Architecture for Mobile App & Web Browsers (Offline Photo Upload & Offline Map Routing) + +## 1. Context & Architectural Strategy +We are implementing an **Offline-First Capabilities Layer** for both browser environments (Chrome Android, iOS Safari) and mobile application wrappers. The application must remain functional when network connectivity is lost ($Network = 0$). + +### Core Requirements: +1. **Offline Photo Upload Queue:** When a user uploads a photo without an active internet connection, the system must not throw a network exception. Instead, it must store the image file (Blob/ArrayBuffer) and its accompanying metadata (GPS, Timestamp) into a browser-native transactional database (**IndexedDB**). +2. **Background Sync Resume:** As soon as the device regains cellular/Wi-Fi data telemetry, a background synchronization worker must automatically trigger, reading the IndexedDB queue and completing the multi-part upload streams to the Debian server sequentially. +3. **Offline Map Navigation Routing:** Cache critical operational Map Layout Tiles and routing geometries via a persistent client-side caching framework (**Service Workers + Cache API**). + +--- + +## 2. Technical Offline System Stack Overview + +[Offline User Interaction] +│ +├──➔ (Photo Upload) ──➔ Save Blob & EXIF Metadata ──➔ [ IndexedDB Storage ] +│ │ (Device re-connects) +│ ▼ +│ [ Background Sync ] ──➔ POST to Server +│ +└──➔ (Map Routing) ──➔ Request Map Assets ──➔ [ Service Worker Cache API ] ──➔ Render View + +## 3. Code Refactoring Blueprint + +### Step 1: Implement Offline Storage Queue Service (`offlineQueue.ts`) +Create a localized database management layer at `frontend/src/utils/offlineQueue.ts` leveraging IndexedDB to hold pending image uploads: + +```typescript +import { openDB, IDBPDatabase } from 'idb'; + +const DB_NAME = 'yotrip-offline-db'; +const STORE_NAME = 'pending-uploads'; + +interface OfflinePhoto { + id: string; + fileBlob: Blob; + fileName: string; + latitude: string | null; + longitude: string | null; + capturedAt: string | null; + timestamp: number; +} + +// Initialize the local IndexedDB container safely +const getDB = (): Promise => { + return openDB(DB_NAME, 1, { + upgrade(db) { + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }, + }); +}; + +// 1. STASH UPLOAD METADATA INTO OFFLINE STORAGE +export const queueOfflineUpload = async (photoData: Omit) => { + const db = await getDB(); + const id = crypto.randomUUID(); + const item: OfflinePhoto = { + ...photoData, + id, + timestamp: Date.now(), + }; + await db.put(STORE_NAME, item); + console.log(`📦 Photo [${id}] stashed safely into local IndexedDB queue for offline background sync.`); + return id; +}; + +// 2. RETRIEVE ALL PENDING FILES ONCE ONLINE +export const getPendingUploads = async (): Promise => { + const db = await getDB(); + return db.getAll(STORE_NAME); +}; + +// 3. REMOVE COMPLETED TRANSACTION FROM QUEUE +export const removePendingUpload = async (id: string) => { + const db = await getDB(); + await db.delete(STORE_NAME, id); +}; + +### Step 2: Implement Background Synchronization Sync Engine (backgroundSync.ts) +Create an active network monitoring service that intercepts reconnection signals and synchronizes the local database records: + +import { getPendingUploads, removePendingUpload } from './offlineQueue'; +import { api } from '../services/api'; + +export const executeBackgroundSyncEngine = async (refreshMapPins: () => void) => { + const pendingItems = await getPendingUploads(); + if (pendingItems.length === 0) return; + + console.log(`🔄 Internet restored! Syncing [${pendingItems.length}] pending items to yotrip.labz.io.vn...`); + + for (const item of pendingItems) { + const formData = new FormData(); + formData.append('photo', item.fileBlob, item.fileName); + + if (item.latitude && item.longitude) { + formData.append('latitude', item.latitude); + formData.append('longitude', item.longitude); + } + if (item.capturedAt) { + formData.append('capturedAt', item.capturedAt); + } + + try { + // Dispatch payload to Debian production server endpoint + const response = await api.post('/photos/upload-with-meta', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + if (response.status === 200 || response.status === 201) { + // Remove item from local DB once server confirms safe receipt + await removePendingUpload(item.id); + console.log(`✅ Offline photo [${item.id}] synchronized successfully.`); + } + } catch (error) { + console.error(`✖ Failed to synchronize item [${item.id}]. Will retry on next connectivity cycle:`, error); + break; // Stop loop if server goes down mid-transit to protect execution queues + } + } + + // Trigger reactive map layer update + refreshMapPins(); +}; + +// Global Connectivity Listener initialization hook +export const initNetworkStatusListeners = (refreshMapPins: () => void) => { + window.addEventListener('online', () => executeBackgroundSyncEngine(refreshMapPins)); + + // Guard check on application startup in case network recovered while app was closed + if (navigator.onLine) { + executeBackgroundSyncEngine(refreshMapPins); + } +}; + +### Step 3: Configure Service Worker for Offline Map Tiles Cache (sw.js) +Configure your service worker script block (e.g., public/sw.js) to intercept and cache vector navigation maps or stylesheet assets statically: + +const CACHE_NAME = 'yotrip-static-map-v1'; +const MAP_TILE_PATTERN = /tiles\.maps\.lincoln|openstreetmap|mapbox/; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll([ + '/', + '/index.html', + '/src/main.tsx', + '/manifest.json', + '/assets/offline-map-placeholder.png' // Fallback image asset + ]); + }) + ); +}); + +// NETWORK-FIRST FALLBACK TO CACHE STRATEGY FOR OFFLINE ROUTING MAPS +self.addEventListener('fetch', (event) => { + const requestUrl = event.request.url; + + if (MAP_TILE_PATTERN.test(requestUrl)) { + event.respondWith( + fetch(event.request) + .then((response) => { + // Clone and update cache with newly acquired dynamic tile data layers + const responseClone = response.clone(); + caches.open(CACHE_NAME).then((cache) => { + cache.put(event.request, responseClone); + }); + return response; + }) + .catch(() => { + // If offline, serve map tiles seamlessly straight from client-side Cache API + return caches.match(event.request); + }) + ); + } +}); + +## 4. Quality Verification & Acceptance Criteria for AI Agent[ ] Airplane Mode Upload Stability: Toggle Airplane mode ($Network = 0$) inside the Android Emulator/iOS Safari responsive inspector. Upload an image. The UI must transition seamlessly, displaying an "Ảnh đã được lưu tạm ngoại tuyến" notification banner without runtime exceptions.[ ] Automatic Queue Flushing Check: Disable Airplane mode. Verify through the browser network inspector tool that a series of asynchronous multi-part POST network requests fire automatically towards https://yotrip.labz.io.vn/api/ without requiring user interaction.[ ] Offline Map Cache Test: Clear your browser network connectivity states. Navigate around the map interface canvas. Previously inspected map sections and routing data blocks must remain fully rendered from the Service Worker cache layer. + diff --git a/frontend/dist/index.html b/frontend/dist/index.html index f9d498f..1358c87 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -4,6 +4,12 @@ + + + + + + Travel Planner @@ -21,7 +27,7 @@ - + diff --git a/frontend/index.html b/frontend/index.html index 4718dba..5457d8d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,12 @@ + + + + + + Travel Planner diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..583bbee --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "YoTrip - Khám phá chuyến đi", + "short_name": "YoTrip", + "description": "Chia sẻ ảnh du lịch và khám phá bản đồ cộng đồng", + "start_url": "/", + "display": "standalone", + "background_color": "#020617", + "theme_color": "#10b981", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "categories": ["travel", "social", "maps"] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..2308de5 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,116 @@ +/** + * sw.js — YoTrip Service Worker + * ───────────────────────────────────────────────────────────────────────────── + * Strategy: Network-First with Cache-API fallback. + * + * Caches: + * 1. App Shell (static assets): cached at install time + * 2. OpenStreetMap tiles: cached dynamically on first fetch, served from + * cache when offline — so previously visited map areas remain navigable + * + * Cache names are versioned so old caches get evicted on SW update. + */ + +const SHELL_CACHE = 'yotrip-shell-v1'; +const MAP_CACHE = 'yotrip-map-tiles-v1'; + +// App shell files to pre-cache at install +const SHELL_URLS = [ + '/', + '/index.html', + '/manifest.json', + '/favicon.ico', +]; + +// URL patterns for map tile providers +const MAP_TILE_ORIGINS = [ + 'tile.openstreetmap.org', + 'a.tile.openstreetmap.org', + 'b.tile.openstreetmap.org', + 'c.tile.openstreetmap.org', + 'tiles.stadiamaps.com', + 'server.arcgisonline.com', +]; + +// ─── Install ────────────────────────────────────────────────────────────── + +self.addEventListener('install', (event) => { + console.log('[SW] Installing yotrip service worker…'); + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => { + return cache.addAll(SHELL_URLS).catch((err) => { + // Non-fatal: some shell files may not exist in dev mode + console.warn('[SW] Shell pre-cache partial failure (non-fatal):', err); + }); + }) + ); + // Activate immediately without waiting for old tabs to close + self.skipWaiting(); +}); + +// ─── Activate ───────────────────────────────────────────────────────────── + +self.addEventListener('activate', (event) => { + const CURRENT_CACHES = [SHELL_CACHE, MAP_CACHE]; + event.waitUntil( + caches.keys().then((cacheNames) => + Promise.all( + cacheNames + .filter((name) => !CURRENT_CACHES.includes(name)) + .map((name) => { + console.log('[SW] Evicting stale cache:', name); + return caches.delete(name); + }) + ) + ) + ); + // Take control of all open clients immediately + self.clients.claim(); +}); + +// ─── Fetch ──────────────────────────────────────────────────────────────── + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // 1. Map tile requests — Network-First, fall back to cache + const isMapTile = MAP_TILE_ORIGINS.some((origin) => url.hostname.includes(origin)); + if (isMapTile) { + event.respondWith( + fetch(event.request) + .then((response) => { + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(MAP_CACHE).then((cache) => { + cache.put(event.request, clone); + }); + } + return response; + }) + .catch(() => caches.match(event.request)) + ); + return; + } + + // 2. API calls — Network-Only (never cache API responses) + if (url.pathname.startsWith('/api/')) { + return; // Let the browser handle normally + } + + // 3. App shell navigation — Cache-First for HTML, then network + if (event.request.mode === 'navigate') { + event.respondWith( + caches.match('/index.html').then((cached) => { + return cached || fetch(event.request); + }) + ); + return; + } + + // 4. Static assets (JS/CSS/images) — Cache-First + event.respondWith( + caches.match(event.request).then((cached) => { + return cached || fetch(event.request); + }) + ); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae3890a..e5e4038 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { LandingPage } from './pages/LandingPage'; import { ExploreMap } from './pages/ExploreMap'; import { TourDetailPage } from './pages/TourDetailPage'; @@ -13,6 +13,7 @@ import { ConfirmProvider } from './hooks/useConfirm'; import { NotificationProvider, useNotification } from './hooks/useNotification'; import { io } from 'socket.io-client'; import { Capacitor } from '@capacitor/core'; +import { initNetworkStatusListeners } from './utils/backgroundSync'; interface GlobalNotificationListenerProps { user: any; @@ -141,6 +142,29 @@ function App() { const [previousPage, setPreviousPage] = useState<'explore' | 'landing'>('explore'); const [navigationPayload, setNavigationPayload] = useState<{ tourId: string; origin: { lat: number; lng: number }; destination: { lat: number; lng: number; name: string }; tourTitle: string } | null>(null); + // ── Service Worker registration ───────────────────────────────────────── + useEffect(() => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker + .register('/sw.js', { scope: '/' }) + .then((reg) => console.log('[SW] Registered, scope:', reg.scope)) + .catch((err) => console.warn('[SW] Registration failed:', err)); + } + }, []); + + // ── Background sync network listeners ──────────────────────────────────── + // refreshLanding is a stable callback that fires fetchPublicPhotos inside LandingPage. + // We use a window CustomEvent as a lightweight cross-component bus. + const triggerMapRefresh = useCallback(() => { + window.dispatchEvent(new CustomEvent('app:offlineSyncComplete')); + }, []); + + useEffect(() => { + const cleanup = initNetworkStatusListeners(triggerMapRefresh); + return cleanup; + }, [triggerMapRefresh]); + + // ── Auth + routing bootstrap ───────────────────────────────────────────── useEffect(() => { const params = new URLSearchParams(window.location.search); const viewTourId = params.get('viewTour'); diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx index 93faa6f..e842df4 100644 --- a/frontend/src/pages/LandingPage.tsx +++ b/frontend/src/pages/LandingPage.tsx @@ -15,6 +15,7 @@ import { processImageModeration } from '../hooks/useImageModeration'; import { useTranslation } from '../hooks/useTranslation'; import { processAndResizeImage } from '../utils/imageProcessor'; import { getDeviceLocation } from '../utils/geolocation'; +import { queueOfflineUpload } from '../utils/offlineQueue'; import { Capacitor } from '@capacitor/core'; import { Camera, CameraResultType, CameraSource } from '@capacitor/camera'; @@ -182,6 +183,16 @@ export const LandingPage: React.FC = ({ fetchBlacklist(); }, []); + // Listen for background-sync completion event dispatched by App.tsx + useEffect(() => { + const handleSyncComplete = () => { + console.log('[LandingPage] Offline sync complete — refreshing public photos…'); + fetchPublicPhotos(); + }; + window.addEventListener('app:offlineSyncComplete', handleSyncComplete); + return () => window.removeEventListener('app:offlineSyncComplete', handleSyncComplete); + }, []); + useEffect(() => { if (publicPhotos.length <= 1) return; const interval = setInterval(() => { @@ -307,6 +318,46 @@ export const LandingPage: React.FC = ({ if (!pendingPhotoFile) return; setIsTagsModalOpen(false); + + // ── OFFLINE GUARD ──────────────────────────────────────────────────────────── + if (!navigator.onLine) { + try { + const token = localStorage.getItem('token') || localStorage.getItem('guest_token') || ''; + const formFields: Record = {}; + if (pendingPhotoLocation) { + formFields['latitude'] = pendingPhotoLocation.latitude.toString(); + formFields['longitude'] = pendingPhotoLocation.longitude.toString(); + } + if (selectedTags.length > 0) { + formFields['tags'] = JSON.stringify(selectedTags); + } + + await queueOfflineUpload({ + endpoint: 'https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous', + authToken: token, + fileBlob: pendingPhotoFile, + fileName: pendingPhotoFile.name, + formFields, + }); + + notify({ + title: 'Ảnh đã được lưu tạm ngoại tuyến 📦', + message: 'Kết nối lại mạng, ảnh sẽ tự động được tải lên bản đồ.', + type: 'info', + }); + } catch (err) { + notify({ title: 'Lỗi', message: 'Không thể lưu ảnh ngoại tuyến.', type: 'error' }); + } finally { + setPendingPhotoFile(null); + setPendingPhotoLocation(null); + if (photoPreviewUrl) { + URL.revokeObjectURL(photoPreviewUrl); + setPhotoPreviewUrl(''); + } + } + return; + } + // ── ONLINE PATH (original logic) ──────────────────────────────────────────────── notify({ title: 'Đang tải lên...', message: 'Vui lòng chờ trong giây lát.', type: 'info' }); try { diff --git a/frontend/src/pages/MemberDashboard.tsx b/frontend/src/pages/MemberDashboard.tsx index 8a7faab..60efb39 100644 --- a/frontend/src/pages/MemberDashboard.tsx +++ b/frontend/src/pages/MemberDashboard.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState, useRef } from 'react'; import { io, Socket } from 'socket.io-client'; import { Capacitor } from '@capacitor/core'; import { BACKEND_URL } from '@/utils/backendEndpoint'; +import { queueOfflineUpload } from '@/utils/offlineQueue'; import { Compass, Users, @@ -287,6 +288,37 @@ export const MemberDashboard: React.FC = ({ } setIsLocating(false); + // ── OFFLINE GUARD ────────────────────────────────────────────────────────── + if (!navigator.onLine) { + try { + const token = localStorage.getItem('token') || ''; + const formFields: Record = {}; + if (attachedLocation) { + formFields['latitude'] = attachedLocation.latitude.toString(); + formFields['longitude'] = attachedLocation.longitude.toString(); + } + + await queueOfflineUpload({ + endpoint: `${BACKEND_URL}/api/v1/tours/${tourId}/photos`, + authToken: token, + fileBlob: file, + fileName: file.name, + formFields, + }); + + notify({ + title: 'Ảnh đã được lưu tạm ngoại tuyến 📦', + message: 'Kết nối lại mạng, ảnh sẽ tự động được tải lên tour.', + type: 'info', + }); + } catch (err) { + notify({ title: 'Lỗi', message: 'Không thể lưu ảnh ngoại tuyến.', type: 'error' }); + } finally { + if (cameraInputRef.current) cameraInputRef.current.value = ''; + } + return; + } + // ── ONLINE PATH ─────────────────────────────────────────────────────────────── // Upload photo to tour setIsUploading(true); try { diff --git a/frontend/src/utils/backgroundSync.ts b/frontend/src/utils/backgroundSync.ts new file mode 100644 index 0000000..7652c0d --- /dev/null +++ b/frontend/src/utils/backgroundSync.ts @@ -0,0 +1,113 @@ +/** + * backgroundSync.ts + * ─────────────────────────────────────────────────────────────────────────── + * Background sync engine: + * • Reads all pending items from IndexedDB when connectivity is restored + * • Replays each upload as a multipart/form-data POST + * • Removes the item from the queue on server 2xx acknowledgement + * • Calls the optional `onSynced` callback after all items are flushed + * (so the map/photo list can refresh reactively) + * + * Also exports `initNetworkStatusListeners` to wire everything up once at + * application startup. + */ + +import { getPendingUploads, getPendingCount, removePendingUpload } from './offlineQueue'; + +let isSyncing = false; // guard against concurrent sync runs + +// ─── Core sync engine ─────────────────────────────────────────────────────── + +export const executeBackgroundSyncEngine = async ( + onSynced?: () => void +): Promise => { + if (isSyncing) return; // already running + if (!navigator.onLine) return; + + const pending = await getPendingUploads(); + if (pending.length === 0) return; + + isSyncing = true; + console.log( + `🔄 [BackgroundSync] Internet restored — syncing ${pending.length} pending upload(s) to server…` + ); + + let syncedCount = 0; + + for (const item of pending) { + try { + const formData = new FormData(); + + // Re-attach the image file + formData.append('images', item.fileBlob, item.fileName); + + // Re-attach all serialised form fields (lat, lng, tags, capturedAt …) + for (const [key, value] of Object.entries(item.formFields)) { + formData.append(key, value); + } + + const response = await fetch(item.endpoint, { + method: 'POST', + headers: { Authorization: `Bearer ${item.authToken}` }, + body: formData, + }); + + if (response.ok) { + await removePendingUpload(item.id); + syncedCount++; + console.log(`✅ [BackgroundSync] Photo "${item.fileName}" (${item.id}) synced.`); + } else { + const body = await response.text(); + console.warn( + `⚠️ [BackgroundSync] Server rejected ${item.id} (${response.status}): ${body}` + ); + // Don't break — try remaining items; server-rejected items stay in queue + } + } catch (error) { + console.error( + `✖ [BackgroundSync] Network error on ${item.id} — will retry on next reconnect:`, + error + ); + // Network error mid-sync: stop and wait for the next 'online' event + break; + } + } + + isSyncing = false; + + if (syncedCount > 0 && onSynced) { + console.log(`🗺 [BackgroundSync] ${syncedCount} photo(s) synced — refreshing map pins…`); + onSynced(); + } +}; + +// ─── Pending count helper (for optional UI badge) ─────────────────────────── + +export const getOfflinePendingCount = getPendingCount; + +// ─── App startup hook ─────────────────────────────────────────────────────── + +/** + * Call once during App mount. + * Attaches the `window.online` listener AND does an immediate + * flush in case the device was offline while the app was closed. + */ +export const initNetworkStatusListeners = (onSynced?: () => void): (() => void) => { + const handleOnline = () => { + console.log('🌐 [BackgroundSync] Network restored — triggering sync…'); + executeBackgroundSyncEngine(onSynced); + }; + + window.addEventListener('online', handleOnline); + + // Startup guard: if we're already online, attempt an immediate flush + if (navigator.onLine) { + // Defer slightly so React tree is fully mounted before map refreshes + setTimeout(() => executeBackgroundSyncEngine(onSynced), 2000); + } + + // Return cleanup function for useEffect + return () => { + window.removeEventListener('online', handleOnline); + }; +}; diff --git a/frontend/src/utils/offlineQueue.ts b/frontend/src/utils/offlineQueue.ts new file mode 100644 index 0000000..619e965 --- /dev/null +++ b/frontend/src/utils/offlineQueue.ts @@ -0,0 +1,115 @@ +/** + * offlineQueue.ts + * ─────────────────────────────────────────────────────────────────────────── + * Offline photo upload queue backed by native IndexedDB. + * No external libraries — works in all modern browsers and Capacitor WebViews. + * + * Stored schema per item: + * id — UUID generated at queue time + * endpoint — full upload URL (e.g. https://yotrip.labz.io.vn/api/v1/photos/upload-anonymous) + * authToken — Bearer token captured at queue time + * fileBlob — raw Blob of the processed image + * fileName — original filename (e.g. "photo-1718000000.jpg") + * formFields — key-value pairs: latitude, longitude, capturedAt, tags … + * timestamp — epoch ms when the item was queued + */ + +const DB_NAME = 'yotrip-offline-db'; +const DB_VERSION = 1; +const STORE_NAME = 'pending-uploads'; + +export interface OfflineUploadItem { + id: string; + endpoint: string; + authToken: string; + fileBlob: Blob; + fileName: string; + formFields: Record; + timestamp: number; +} + +// ─── Internal DB bootstrap ────────────────────────────────────────────────── + +const getDB = (): Promise => + new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + + req.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +// ─── Public API ───────────────────────────────────────────────────────────── + +/** + * Stash a failed upload into the local IndexedDB queue. + * Returns the generated item id. + */ +export const queueOfflineUpload = async ( + data: Omit +): Promise => { + const db = await getDB(); + const id = crypto.randomUUID(); + const item: OfflineUploadItem = { ...data, id, timestamp: Date.now() }; + + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.put(item); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); + + console.log(`📦 [OfflineQueue] Photo "${item.fileName}" (${id}) queued for background sync.`); + return id; +}; + +/** + * Retrieve all pending items ordered by timestamp (oldest first). + */ +export const getPendingUploads = async (): Promise => { + const db = await getDB(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.getAll(); + req.onsuccess = () => + resolve((req.result as OfflineUploadItem[]).sort((a, b) => a.timestamp - b.timestamp)); + req.onerror = () => reject(req.error); + }); +}; + +/** + * Get the total count of pending items (for UI badge). + */ +export const getPendingCount = async (): Promise => { + const db = await getDB(); + return new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readonly'); + const store = tx.objectStore(STORE_NAME); + const req = store.count(); + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +}; + +/** + * Remove a successfully synced item from the queue. + */ +export const removePendingUpload = async (id: string): Promise => { + const db = await getDB(); + await new Promise((resolve, reject) => { + const tx = db.transaction(STORE_NAME, 'readwrite'); + const store = tx.objectStore(STORE_NAME); + const req = store.delete(id); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + }); +};