localgreenchain/lib/mobile/offline.ts
Claude c2a1b05677
Implement Agent 10: Mobile Optimization with PWA capabilities
This implements the mobile optimization agent (P3 - Enhancement) with:

PWA Configuration:
- Add next-pwa integration with offline caching strategies
- Create web app manifest for installability
- Add service worker with background sync support
- Create offline fallback page

Mobile Components:
- BottomNav: Touch-friendly bottom navigation bar
- MobileHeader: Responsive header with back navigation
- InstallPrompt: Smart PWA install prompt (iOS & Android)
- SwipeableCard: Gesture-based swipeable cards
- PullToRefresh: Native-like pull to refresh
- QRScanner: Camera-based QR code scanning

Mobile Library:
- camera.ts: Camera access and photo capture utilities
- offline.ts: IndexedDB-based offline storage and sync
- gestures.ts: Touch gesture detection (swipe, pinch, tap)
- pwa.ts: PWA status, install prompts, service worker management

Mobile Pages:
- /m: Mobile dashboard with quick actions and stats
- /m/scan: QR code scanner for plant lookup
- /m/quick-add: Streamlined plant registration form
- /m/profile: User profile with offline status

Dependencies added: next-pwa, idb
2025-11-23 03:56:30 +00:00

275 lines
7 KiB
TypeScript

/**
* Offline Support Utilities
* Provides IndexedDB storage and background sync for offline functionality
*/
import { openDB, DBSchema, IDBPDatabase } from 'idb';
// Database schema
interface LocalGreenChainDB extends DBSchema {
'pending-plants': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'pending-transport': {
key: string;
value: {
id: string;
data: any;
createdAt: string;
attempts: number;
};
indexes: { 'by-created': string };
};
'cached-plants': {
key: string;
value: {
id: string;
data: any;
cachedAt: string;
};
indexes: { 'by-cached': string };
};
'user-preferences': {
key: string;
value: any;
};
}
const DB_NAME = 'localgreenchain-offline';
const DB_VERSION = 1;
let dbPromise: Promise<IDBPDatabase<LocalGreenChainDB>> | null = null;
async function getDB(): Promise<IDBPDatabase<LocalGreenChainDB>> {
if (!dbPromise) {
dbPromise = openDB<LocalGreenChainDB>(DB_NAME, DB_VERSION, {
upgrade(db) {
// Pending plants store
if (!db.objectStoreNames.contains('pending-plants')) {
const plantStore = db.createObjectStore('pending-plants', { keyPath: 'id' });
plantStore.createIndex('by-created', 'createdAt');
}
// Pending transport store
if (!db.objectStoreNames.contains('pending-transport')) {
const transportStore = db.createObjectStore('pending-transport', { keyPath: 'id' });
transportStore.createIndex('by-created', 'createdAt');
}
// Cached plants store
if (!db.objectStoreNames.contains('cached-plants')) {
const cacheStore = db.createObjectStore('cached-plants', { keyPath: 'id' });
cacheStore.createIndex('by-cached', 'cachedAt');
}
// User preferences store
if (!db.objectStoreNames.contains('user-preferences')) {
db.createObjectStore('user-preferences');
}
},
});
}
return dbPromise;
}
// Network status
export function isOnline(): boolean {
return typeof navigator !== 'undefined' ? navigator.onLine : true;
}
export function onNetworkChange(callback: (online: boolean) => void): () => void {
if (typeof window === 'undefined') return () => {};
const handleOnline = () => callback(true);
const handleOffline = () => callback(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// Pending operations
export async function queuePlantRegistration(plantData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-plants', {
id,
data: plantData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-plants');
}
return id;
}
export async function queueTransportEvent(eventData: any): Promise<string> {
const db = await getDB();
const id = generateId();
await db.put('pending-transport', {
id,
data: eventData,
createdAt: new Date().toISOString(),
attempts: 0,
});
// Register for background sync if available
if ('serviceWorker' in navigator && 'sync' in ServiceWorkerRegistration.prototype) {
const registration = await navigator.serviceWorker.ready;
await (registration as any).sync.register('sync-transport');
}
return id;
}
export async function getPendingPlants(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-plants');
}
export async function getPendingTransport(): Promise<any[]> {
const db = await getDB();
return db.getAll('pending-transport');
}
export async function removePendingPlant(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-plants', id);
}
export async function removePendingTransport(id: string): Promise<void> {
const db = await getDB();
await db.delete('pending-transport', id);
}
// Plant caching
export async function cachePlant(plant: any): Promise<void> {
const db = await getDB();
await db.put('cached-plants', {
id: plant.id,
data: plant,
cachedAt: new Date().toISOString(),
});
}
export async function getCachedPlant(id: string): Promise<any | null> {
const db = await getDB();
const cached = await db.get('cached-plants', id);
return cached?.data || null;
}
export async function getCachedPlants(): Promise<any[]> {
const db = await getDB();
const all = await db.getAll('cached-plants');
return all.map((item) => item.data);
}
export async function clearCachedPlants(): Promise<void> {
const db = await getDB();
await db.clear('cached-plants');
}
// User preferences
export async function setPreference(key: string, value: any): Promise<void> {
const db = await getDB();
await db.put('user-preferences', value, key);
}
export async function getPreference<T>(key: string): Promise<T | null> {
const db = await getDB();
return db.get('user-preferences', key) as Promise<T | null>;
}
// Sync operations
export async function syncPendingPlants(): Promise<{ success: number; failed: number }> {
const pending = await getPendingPlants();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/plants/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingPlant(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncPendingTransport(): Promise<{ success: number; failed: number }> {
const pending = await getPendingTransport();
let success = 0;
let failed = 0;
for (const item of pending) {
try {
const response = await fetch('/api/transport/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.data),
});
if (response.ok) {
await removePendingTransport(item.id);
success++;
} else {
failed++;
}
} catch {
failed++;
}
}
return { success, failed };
}
export async function syncAll(): Promise<void> {
if (!isOnline()) return;
await Promise.all([
syncPendingPlants(),
syncPendingTransport(),
]);
}
// Utility
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
// Auto-sync when coming online
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
syncAll().catch(console.error);
});
}