Practice and reinforce the concepts from Lesson 18
Master offline-first app development by:
Time Limit: 10 minutes
Install Offline Storage Libraries:
npx expo install expo-sqlite
npm install @react-native-async-storage/async-storage
npm install react-query # For caching and sync
npm install redux-persist # For state persistence
Basic Offline Storage Setup:
import * as SQLite from 'expo-sqlite';
import AsyncStorage from '@react-native-async-storage/async-storage';
import NetInfo from '@react-native-community/netinfo';
// Offline database manager
class OfflineDatabase {
constructor() {
this.db = null;
this.isInitialized = false;
}
async initialize() {
try {
this.db = SQLite.openDatabase('offline_app.db');
await this.createTables();
this.isInitialized = true;
console.log('Offline database initialized');
} catch (error) {
console.error('Failed to initialize offline database:', error);
}
}
createTables() {
return new Promise((resolve, reject) => {
this.db.transaction(tx => {
// Workouts table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS workouts (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT,
duration INTEGER,
difficulty TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sync_status TEXT DEFAULT 'pending',
is_deleted INTEGER DEFAULT 0
)
`);
// User progress table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS user_progress (
id TEXT PRIMARY KEY,
workout_id TEXT,
completed_at DATETIME,
duration INTEGER,
calories_burned INTEGER,
data TEXT,
sync_status TEXT DEFAULT 'pending',
FOREIGN KEY (workout_id) REFERENCES workouts (id)
)
`);
// Sync queue table
tx.executeSql(`
CREATE TABLE IF NOT EXISTS sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT,
record_id TEXT,
action TEXT,
data TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
retry_count INTEGER DEFAULT 0
)
`);
}, reject, resolve);
});
}
async insertWorkout(workout) {
return new Promise((resolve, reject) => {
this.db.transaction(tx => {
tx.executeSql(
'INSERT INTO workouts (id, name, category, duration, difficulty, data) VALUES (?, ?, ?, ?, ?, ?)',
[workout.id, workout.name, workout.category, workout.duration, workout.difficulty, JSON.stringify(workout)],
(_, result) => resolve(result),
(_, error) => reject(error)
);
});
});
}
async getWorkouts(category = null) {
return new Promise((resolve, reject) => {
const query = category
? 'SELECT * FROM workouts WHERE category = ? AND is_deleted = 0 ORDER BY created_at DESC'
: 'SELECT * FROM workouts WHERE is_deleted = 0 ORDER BY created_at DESC';
const params = category ? [category] : [];
this.db.transaction(tx => {
tx.executeSql(
query,
params,
(_, { rows }) => {
const workouts = rows._array.map(row => ({
...JSON.parse(row.data),
sync_status: row.sync_status,
offline_id: row.id
}));
resolve(workouts);
},
(_, error) => reject(error)
);
});
});
}
async addToSyncQueue(tableName, recordId, action, data) {
return new Promise((resolve, reject) => {
this.db.transaction(tx => {
tx.executeSql(
'INSERT INTO sync_queue (table_name, record_id, action, data) VALUES (?, ?, ?, ?)',
[tableName, recordId, action, JSON.stringify(data)],
(_, result) => resolve(result),
(_, error) => reject(error)
);
});
});
}
}
// Network state manager
class NetworkManager {
constructor() {
this.isOnline = false;
this.listeners = new Set();
this.connectionType = 'unknown';
}
initialize() {
// Subscribe to network state changes
NetInfo.addEventListener(state => {
const wasOnline = this.isOnline;
this.isOnline = state.isConnected;
this.connectionType = state.type;
if (!wasOnline && this.isOnline) {
this.notifyOnlineListeners();
} else if (wasOnline && !this.isOnline) {
this.notifyOfflineListeners();
}
this.notifyConnectionChangeListeners(state);
});
// Get initial state
NetInfo.fetch().then(state => {
this.isOnline = state.isConnected;
this.connectionType = state.type;
});
}
addConnectionListener(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notifyOnlineListeners() {
console.log('Device came online - triggering sync');
this.listeners.forEach(listener => {
if (listener.onOnline) {
listener.onOnline();
}
});
}
notifyOfflineListeners() {
console.log('Device went offline');
this.listeners.forEach(listener => {
if (listener.onOffline) {
listener.onOffline();
}
});
}
notifyConnectionChangeListeners(state) {
this.listeners.forEach(listener => {
if (listener.onConnectionChange) {
listener.onConnectionChange(state);
}
});
}
getConnectionQuality() {
// Estimate connection quality based on type
const qualityMap = {
'wifi': 'excellent',
'4g': 'good',
'3g': 'fair',
'2g': 'poor',
'cellular': 'good',
'ethernet': 'excellent'
};
return qualityMap[this.connectionType] || 'unknown';
}
}
// Global instances
export const offlineDB = new OfflineDatabase();
export const networkManager = new NetworkManager();
✅ Checkpoint: Offline database is set up and storing data locally!
Time Limit: 5 minutes
Create a component that works offline:
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, Alert } from 'react-native';
const OfflineWorkoutList = () => {
const [workouts, setWorkouts] = useState([]);
const [isOnline, setIsOnline] = useState(networkManager.isOnline);
useEffect(() => {
// Initialize offline database
initializeOfflineData();
// Listen for network changes
const unsubscribe = networkManager.addConnectionListener({
onConnectionChange: (state) => {
setIsOnline(state.isConnected);
if (state.isConnected) {
syncPendingData();
}
}
});
return unsubscribe;
}, []);
const initializeOfflineData = async () => {
await offlineDB.initialize();
await loadWorkoutsFromCache();
};
const loadWorkoutsFromCache = async () => {
try {
const cachedWorkouts = await offlineDB.getWorkouts();
setWorkouts(cachedWorkouts);
} catch (error) {
console.error('Failed to load workouts from cache:', error);
}
};
const addWorkoutOffline = async () => {
const newWorkout = {
id: Date.now().toString(),
name: 'Offline Workout',
category: 'strength',
duration: 30,
difficulty: 'medium',
createdOffline: true
};
try {
// Save to offline database
await offlineDB.insertWorkout(newWorkout);
// Add to sync queue
await offlineDB.addToSyncQueue('workouts', newWorkout.id, 'create', newWorkout);
// Update local state
setWorkouts(prev => [newWorkout, ...prev]);
Alert.alert(
'Workout Added Offline',
'Your workout will sync when you\'re back online'
);
} catch (error) {
console.error('Failed to add workout offline:', error);
}
};
const syncPendingData = async () => {
console.log('Syncing pending offline data...');
// Sync logic will be implemented in the main activity
};
return (
<View style={styles.container}>
<View style={styles.statusBar}>
<Text style={[styles.statusText, { color: isOnline ? '#2ecc71' : '#e74c3c' }]}>
{isOnline ? '🟢 Online' : '🔴 Offline'}
</Text>
</View>
<TouchableOpacity style={styles.addButton} onPress={addWorkoutOffline}>
<Text style={styles.addButtonText}>Add Workout (Offline Capable)</Text>
</TouchableOpacity>
<Text style={styles.workoutsTitle}>Workouts ({workouts.length})</Text>
{workouts.map(workout => (
<View key={workout.id} style={styles.workoutItem}>
<Text style={styles.workoutName}>{workout.name}</Text>
<View style={styles.workoutMeta}>
<Text>{workout.duration}min • {workout.difficulty}</Text>
{workout.sync_status === 'pending' && (
<Text style={styles.syncPending}>📤 Pending Sync</Text>
)}
</View>
</View>
))}
</View>
);
};
✅ Checkpoint: App works offline and queues data for sync!
Build a comprehensive offline-first data system:
import React, { useState, useEffect, useRef } from 'react';
import { View, Text, FlatList, TouchableOpacity, Alert, ActivityIndicator } from 'react-native';
// Advanced offline sync manager
class OfflineSyncManager {
constructor(database, networkManager) {
this.db = database;
this.network = networkManager;
this.syncQueue = [];
this.issyncing = false;
this.syncListeners = new Set();
this.conflictResolvers = new Map();
this.syncStrategy = 'incremental'; // 'full', 'incremental', 'smart'
}
async initialize() {
await this.loadSyncQueue();
this.setupAutoSync();
this.setupConflictResolvers();
}
async loadSyncQueue() {
return new Promise((resolve, reject) => {
this.db.db.transaction(tx => {
tx.executeSql(
'SELECT * FROM sync_queue ORDER BY created_at ASC',
[],
(_, { rows }) => {
this.syncQueue = rows._array;
resolve(this.syncQueue);
},
(_, error) => reject(error)
);
});
});
}
setupAutoSync() {
// Listen for network connectivity
this.network.addConnectionListener({
onOnline: () => {
this.startBackgroundSync();
}
});
// Periodic sync when online
this.syncInterval = setInterval(() => {
if (this.network.isOnline && !this.isSync
&& this.syncQueue.length > 0) {
this.startBackgroundSync();
}
}, 30000); // Every 30 seconds
}
setupConflictResolvers() {
// Last-write-wins resolver
this.conflictResolvers.set('last_write_wins', (local, remote) => {
return new Date(local.updated_at) > new Date(remote.updated_at) ? local : remote;
});
// User preference resolver
this.conflictResolvers.set('user_preference', async (local, remote) => {
return new Promise(resolve => {
Alert.alert(
'Data Conflict',
'Your local data conflicts with server data. Which version would you like to keep?',
[
{ text: 'Keep Local', onPress: () => resolve(local) },
{ text: 'Keep Server', onPress: () => resolve(remote) },
{ text: 'Merge Both', onPress: () => resolve(this.mergeRecords(local, remote)) }
]
);
});
});
// Smart merge resolver
this.conflictResolvers.set('smart_merge', (local, remote) => {
return this.mergeRecords(local, remote);
});
}
mergeRecords(local, remote) {
// Intelligent merging logic
const merged = { ...remote };
// Keep local changes that are more recent
Object.keys(local).forEach(key => {
if (key.endsWith('_at') && new Date(local[key]) > new Date(remote[key] || 0)) {
merged[key] = local[key];
} else if (local[key] !== null && local[key] !== undefined && remote[key] === null) {
merged[key] = local[key];
}
});
merged.merged_from_conflict = true;
merged.conflict_resolved_at = new Date().toISOString();
return merged;
}
async startBackgroundSync() {
if (this.isSync
|| !this.network.isOnline) {
return;
}
this.isSync
= true;
this.notifySync
Listeners('sync_started');
try {
await this.processSyncQueue();
await this.performIncrementalSync();
this.notifySync
Listeners('sync_completed');
} catch (error) {
console.error('Sync failed:', error);
this.notifySync
Listeners('sync_failed', error);
} finally {
this.isSync
= false;
}
}
async processSyncQueue() {
const batchSize = 10;
let processedCount = 0;
for (let i = 0; i < this.syncQueue.length; i += batchSize) {
const batch = this.syncQueue.slice(i, i + batchSize);
try {
await Promise.all(batch.map(item => this.processSyncItem(item)));
// Remove successful items from queue
await this.removeSyncItems(batch.map(item => item.id));
processedCount += batch.length;
this.notifySync
Listeners('sync_progress', {
processed: processedCount,
total: this.syncQueue.length
});
} catch (error) {
console.error('Batch sync failed:', error);
// Update retry count for failed items
await Promise.all(batch.map(item =>
this.updateSyncItemRetryCount(item.id)
));
}
}
// Reload sync queue after processing
await this.loadSyncQueue();
}
async processSyncItem(syncItem) {
const { table_name, record_id, action, data } = syncItem;
const parsedData = JSON.parse(data);
try {
let response;
switch (action) {
case 'create':
response = await this.createRecordOnServer(table_name, parsedData);
break;
case 'update':
response = await this.updateRecordOnServer(table_name, record_id, parsedData);
break;
case 'delete':
response = await this.deleteRecordOnServer(table_name, record_id);
break;
default:
throw new Error(`Unknown sync action: ${action}`);
}
// Update local record with server response
if (response && action !== 'delete') {
await this.updateLocalRecord(table_name, record_id, response);
}
return response;
} catch (error) {
if (error.status === 409) { // Conflict
await this.handleConflict(table_name, record_id, parsedData);
} else {
throw error;
}
}
}
async handleConflict(tableName, recordId, localData) {
try {
// Fetch current server state
const serverData = await this.fetchRecordFromServer(tableName, recordId);
// Use configured conflict resolution strategy
const resolver = this.conflictResolvers.get('smart_merge');
const resolved = await resolver(localData, serverData);
// Update both local and server with resolved data
await this.updateLocalRecord(tableName, recordId, resolved);
await this.updateRecordOnServer(tableName, recordId, resolved);
console.log(`Conflict resolved for ${tableName}:${recordId}`);
} catch (error) {
console.error('Conflict resolution failed:', error);
throw error;
}
}
async performIncrementalSync() {
const lastSyncTime = await AsyncStorage.getItem('last_sync_time') || '1970-01-01T00:00:00.000Z';
try {
// Fetch updates from server since last sync
const updates = await this.fetchUpdatesFromServer(lastSyncTime);
// Apply updates to local database
await this.applyServerUpdates(updates);
// Update last sync time
await AsyncStorage.setItem('last_sync_time', new Date().toISOString());
console.log(`Incremental sync completed: ${updates.length} updates applied`);
} catch (error) {
console.error('Incremental sync failed:', error);
throw error;
}
}
async applyServerUpdates(updates) {
for (const update of updates) {
try {
const { table_name, record_id, action, data } = update;
switch (action) {
case 'create':
case 'update':
await this.upsertLocalRecord(table_name, record_id, data);
break;
case 'delete':
await this.deleteLocalRecord(table_name, record_id);
break;
}
} catch (error) {
console.error(`Failed to apply update for ${update.table_name}:${update.record_id}`, error);
}
}
}
// API methods (would connect to actual backend)
async createRecordOnServer(tableName, data) {
const response = await fetch(`/api/${tableName}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
throw new Error(`Server create failed: ${response.status}`);
}
return response.json();
}
async updateRecordOnServer(tableName, recordId, data) {
const response = await fetch(`/api/${tableName}/${recordId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (!response.ok) {
if (response.status === 409) {
const error = new Error('Conflict detected');
error.status = 409;
throw error;
}
throw new Error(`Server update failed: ${response.status}`);
}
return response.json();
}
async fetchUpdatesFromServer(since) {
const response = await fetch(`/api/sync/updates?since=${since}`);
if (!response.ok) {
throw new Error(`Failed to fetch updates: ${response.status}`);
}
return response.json();
}
// Local database operations
async upsertLocalRecord(tableName, recordId, data) {
return new Promise((resolve, reject) => {
this.db.db.transaction(tx => {
const fields = Object.keys(data).join(', ');
const placeholders = Object.keys(data).map(() => '?').join(', ');
const values = Object.values(data);
tx.executeSql(
`INSERT OR REPLACE INTO ${tableName} (${fields}, sync_status) VALUES (${placeholders}, ?)`,
[...values, 'synced'],
(_, result) => resolve(result),
(_, error) => reject(error)
);
});
});
}
addSync
Listener(listener) {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
}
notifySync
Listeners(event, data = null) {
this.syncListeners.forEach(listener => {
if (listener[event]) {
listener[event](data);
}
});
}
// Cleanup
destroy() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
}
}
// Offline-first workout manager component
const OfflineWorkoutManager = () => {
const [workouts, setWorkouts] = useState([]);
const [sync
Status, setSyncStatus] = useState({
isSync
: false,
queueCount: 0,
lastSync
: null
});
const [network
Status, setNetworkStatus] = useState({
isOnline: false,
connectionType: 'unknown'
});
const sync
Manager = useRef(null);
useEffect(() => {
initializeOfflineManager();
return () => {
if (sync
Manager.current) {
sync
Manager.current.destroy();
}
};
}, []);
const initializeOfflineManager = async () => {
// Initialize database and network manager
await offlineDB.initialize();
await networkManager.initialize();
// Create sync manager
sync
Manager.current = new OfflineSyncManager(offlineDB, networkManager);
await sync
Manager.current.initialize();
// Set up listeners
setupListeners();
// Load initial data
await loadWorkouts();
};
const setupListeners = () => {
// Network status listener
const networkUnsubscribe = networkManager.addConnectionListener({
onConnectionChange: (state) => {
setNetworkStatus({
isOnline: state.isConnected,
connectionType: state.type
});
}
});
// Sync status listener
const sync
Unsubscribe = sync
Manager.current.addSync
Listener({
sync_started: () => {
setSyncStatus(prev => ({ ...prev, isSync
: true }));
},
sync_progress: (data) => {
console.log(`Sync progress: ${data.processed}/${data.total}`);
},
sync_completed: async () => {
setSyncStatus(prev => ({
...prev,
isSync
: false,
lastSync
: new Date().toLocaleTimeString()
}));
await loadWorkouts(); // Refresh data after sync
},
sync_failed: (error) => {
setSyncStatus(prev => ({ ...prev, isSync
: false }));
Alert.alert('Sync Failed', error.message);
}
});
return () => {
networkUnsubscribe();
sync
Unsubscribe();
};
};
const loadWorkouts = async () => {
try {
const cachedWorkouts = await offlineDB.getWorkouts();
setWorkouts(cachedWorkouts);
// Update queue count
const queueCount = await offlineDB.getSyncQueueCount();
setSyncStatus(prev => ({ ...prev, queueCount }));
} catch (error) {
console.error('Failed to load workouts:', error);
}
};
const addWorkout = async (workoutData) => {
const newWorkout = {
id: `offline_${Date.now()}`,
...workoutData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
try {
// Save to local database
await offlineDB.insertWorkout(newWorkout);
// Add to sync queue
await offlineDB.addToSyncQueue('workouts', newWorkout.id, 'create', newWorkout);
// Update local state
setWorkouts(prev => [newWorkout, ...prev]);
// Trigger sync if online
if (network
Status.isOnline) {
sync
Manager.current.startBackgroundSync();
}
} catch (error) {
console.error('Failed to add workout:', error);
Alert.alert('Error', 'Failed to save workout');
}
};
const manualSync
= async () => {
if (!network
Status.isOnline) {
Alert.alert('Offline', 'Cannot sync while offline');
return;
}
try {
await sync
Manager.current.startBackgroundSync();
} catch (error) {
Alert.alert('Sync Failed', error.message);
}
};
const renderWorkout = ({ item }) => (
<View style={styles.workoutCard}>
<Text style={styles.workoutName}>{item.name}</Text>
<Text style={styles.workoutCategory}>{item.category}</Text>
<View style={styles.workoutMeta}>
<Text>{item.duration}min • {item.difficulty}</Text>
{item.sync_status === 'pending' && (
<View style={styles.pendingSync}>
<Text style={styles.pendingSync
Text}>📤 Pending</Text>
</View>
)}
{item.createdOffline && (
<View style={styles.offlineBadge}>
<Text style={styles.offlineBadgeText}>📱 Offline</Text>
</View>
)}
</View>
</View>
);
return (
<View style={styles.container}>
{/* Status Bar */}
<View style={styles.statusContainer}>
<View style={styles.statusItem}>
<Text style={[styles.statusText, {
color: network
Status.isOnline ? '#2ecc71' : '#e74c3c'
}]}>
{network
Status.isOnline ? '🟢 Online' : '🔴 Offline'}
</Text>
<Text style={styles.statusSubtext}>{network
Status.connectionType}</Text>
</View>
<View style={styles.statusItem}>
<Text style={styles.statusText}>
{sync
Status.isSync
? '⏳ Sync
' : '✅ Ready'}
</Text>
{sync
Status.lastSync
&& (
<Text style={styles.statusSubtext}>Last: {sync
Status.lastSync
}</Text>
)}
</View>
<View style={styles.statusItem}>
<Text style={styles.statusText}>Queue: {sync
Status.queueCount}</Text>
{sync
Status.queueCount > 0 && (
<TouchableOpacity onPress={manualSync
} style={styles.sync
Button}>
<Text style={styles.sync
ButtonText}>Sync Now</Text>
</TouchableOpacity>
)}
</View>
</View>
{/* Add Workout Button */}
<TouchableOpacity
style={styles.addButton}
onPress={() => addWorkout({
name: `Workout ${workouts.length + 1}`,
category: 'strength',
duration: 30,
difficulty: 'medium'
})}
>
<Text style={styles.addButtonText}>+ Add Workout (Works Offline)</Text>
</TouchableOpacity>
{/* Workouts List */}
<FlatList
data={workouts}
renderItem={renderWorkout}
keyExtractor={(item) => item.id}
style={styles.workoutsList}
/>
{sync
Status.isSync
&& (
<View style={styles.sync
Overlay}>
<ActivityIndicator size="large" color="#3498db" />
<Text style={styles.sync
OverlayText}>Sync
...</Text>
</View>
)}
</View>
);
};
Your Mission:
Add sophisticated offline capabilities:
// Offline cache optimization
class OfflineCacheManager {
constructor(maxSize = 100 * 1024 * 1024) { // 100MB default
this.maxSize = maxSize;
this.currentSize = 0;
this.cache = new Map();
this.accessOrder = [];
this.persistentKeys = new Set(); // Keys that should never be evicted
}
async initialize() {
await this.loadCacheMetadata();
this.startCacheCleanup();
}
async set(key, data, options = {}) {
const {
ttl = 24 * 60 * 60 * 1000, // 24 hours default
persistent = false,
priority = 'normal' // 'low', 'normal', 'high'
} = options;
const serialized = JSON.stringify(data);
const size = new Blob([serialized]).size;
// Check if we need to make space
if (this.currentSize + size > this.maxSize) {
await this.evictLRU(size);
}
const cacheItem = {
data: serialized,
size,
expiresAt: Date.now() + ttl,
priority,
accessCount: 0,
lastAccessed: Date.now()
};
this.cache.set(key, cacheItem);
this.currentSize += size;
if (persistent) {
this.persistentKeys.add(key);
}
this.updateAccessOrder(key);
await this.persistToStorage(key, cacheItem);
}
async get(key) {
const item = this.cache.get(key);
if (!item) {
// Try loading from persistent storage
const stored = await this.loadFromStorage(key);
if (stored) {
this.cache.set(key, stored);
this.currentSize += stored.size;
} else {
return null;
}
}
// Check expiration
if (Date.now() > item.expiresAt) {
this.delete(key);
return null;
}
// Update access metadata
item.accessCount++;
item.lastAccessed = Date.now();
this.updateAccessOrder(key);
return JSON.parse(item.data);
}
async evictLRU(sizeNeeded) {
let freedSize = 0;
// Sort by priority and access patterns
const evictionCandidates = Array.from(this.cache.entries())
.filter(([key]) => !this.persistentKeys.has(key))
.sort(([,a], [,b]) => {
// Lower priority items first
if (a.priority !== b.priority) {
const priorityWeight = { low: 0, normal: 1, high: 2 };
return priorityWeight[a.priority] - priorityWeight[b.priority];
}
// Then by access patterns (LRU)
return a.lastAccessed - b.lastAccessed;
});
for (const [key, item] of evictionCandidates) {
if (freedSize >= sizeNeeded) break;
this.delete(key);
freedSize += item.size;
}
console.log(`Evicted ${freedSize} bytes from cache`);
}
delete(key) {
const item = this.cache.get(key);
if (item) {
this.cache.delete(key);
this.currentSize -= item.size;
this.persistentKeys.delete(key);
const index = this.accessOrder.indexOf(key);
if (index > -1) {
this.accessOrder.splice(index, 1);
}
this.removeFromStorage(key);
}
}
// Smart prefetching based on usage patterns
async prefetchRelatedData(currentKey) {
const related = this.findRelatedKeys(currentKey);
const prefetchPromises = related.map(async (key) => {
if (!this.cache.has(key)) {
// Prefetch with lower priority
const data = await this.fetchFromSource(key);
if (data) {
await this.set(key, data, { priority: 'low' });
}
}
});
await Promise.all(prefetchPromises);
}
findRelatedKeys(key) {
// Simple pattern-based relationship detection
const parts = key.split('_');
const related = [];
// Find keys with similar patterns
for (const cachedKey of this.cache.keys()) {
const cachedParts = cachedKey.split('_');
if (parts[0] === cachedParts[0] && key !== cachedKey) {
related.push(cachedKey);
}
}
return related.slice(0, 5); // Limit to 5 related items
}
}
// Background sync service
class BackgroundSyncService {
constructor(syncManager) {
this.syncManager = syncManager;
this.isRegistered = false;
this.syncStrategies = new Map();
}
async initialize() {
if ('serviceWorker' in navigator && 'sync' in window.ServiceWorkerRegistration.prototype) {
await this.registerBackgroundSync();
}
this.setupSyncStrategies();
this.schedulePeriodicSync();
}
setupSyncStrategies() {
// Immediate sync for critical data
this.syncStrategies.set('critical', {
maxDelay: 0,
retryInterval: 5000,
maxRetries: 10
});
// Batch sync for normal data
this.syncStrategies.set('normal', {
maxDelay: 30000, // 30 seconds
retryInterval: 60000, // 1 minute
maxRetries: 5
});
// Deferred sync for low priority data
this.syncStrategies.set('deferred', {
maxDelay: 300000, // 5 minutes
retryInterval: 300000, // 5 minutes
maxRetries: 3
});
}
async scheduleSync(data, priority = 'normal') {
const strategy = this.syncStrategies.get(priority);
if (networkManager.isOnline && strategy.maxDelay === 0) {
// Sync immediately
return this.syncManager.processSyncItem(data);
}
// Queue for background sync
await this.queueForBackgroundSync(data, strategy);
}
async queueForBackgroundSync(data, strategy) {
const syncTask = {
id: Date.now().toString(),
data,
strategy,
attempts: 0,
scheduledAt: Date.now(),
nextAttempt: Date.now() + strategy.maxDelay
};
// Store in background sync queue
await AsyncStorage.setItem(
`bg_sync_${syncTask.id}`,
JSON.stringify(syncTask)
);
// Register background sync event
if (this.isRegistered) {
await navigator.serviceWorker.ready.then(registration => {
return registration.sync.register(`background-sync-${syncTask.id}`);
});
}
}
schedulePeriodicSync() {
setInterval(async () => {
if (networkManager.isOnline) {
await this.processBackgroundSyncQueue();
}
}, 60000); // Check every minute
}
async processBackgroundSyncQueue() {
try {
const keys = await AsyncStorage.getAllKeys();
const syncKeys = keys.filter(key => key.startsWith('bg_sync_'));
for (const key of syncKeys) {
const taskData = await AsyncStorage.getItem(key);
const task = JSON.parse(taskData);
if (Date.now() >= task.nextAttempt) {
try {
await this.syncManager.processSyncItem(task.data);
await AsyncStorage.removeItem(key); // Success - remove from queue
} catch (error) {
await this.handleSyncFailure(key, task, error);
}
}
}
} catch (error) {
console.error('Background sync processing failed:', error);
}
}
async handleSyncFailure(key, task, error) {
task.attempts++;
if (task.attempts >= task.strategy.maxRetries) {
// Max retries exceeded - remove from queue
await AsyncStorage.removeItem(key);
console.error(`Background sync failed permanently for task ${task.id}:`, error);
return;
}
// Schedule retry
task.nextAttempt = Date.now() + task.strategy.retryInterval;
await AsyncStorage.setItem(key, JSON.stringify(task));
}
}
// Progressive data loading
class ProgressiveDataLoader {
constructor(cacheManager) {
this.cache = cacheManager;
this.loadingTasks = new Map();
}
async loadData(key, fetcher, options = {}) {
const {
useCache = true,
fallback = null,
progressive = false,
priority = 'normal'
} = options;
// Return cached data if available
if (useCache) {
const cached = await this.cache.get(key);
if (cached) {
// Prefetch related data in background
this.cache.prefetchRelatedData(key);
return cached;
}
}
// Return fallback immediately if progressive loading
if (progressive && fallback) {
// Start background fetch
this.loadInBackground(key, fetcher, options);
return fallback;
}
// Regular loading
return this.loadWithFallback(key, fetcher, fallback);
}
async loadInBackground(key, fetcher, options) {
if (this.loadingTasks.has(key)) {
return this.loadingTasks.get(key);
}
const loadingPromise = this.loadWithFallback(key, fetcher, null)
.finally(() => {
this.loadingTasks.delete(key);
});
this.loadingTasks.set(key, loadingPromise);
return loadingPromise;
}
async loadWithFallback(key, fetcher, fallback) {
try {
const data = await fetcher();
// Cache successful results
await this.cache.set(key, data);
return data;
} catch (error) {
console.warn(`Failed to load data for key ${key}:`, error);
// Try to return stale cached data
const stale = await this.cache.get(key);
if (stale) {
return stale;
}
if (fallback !== null) {
return fallback;
}
throw error;
}
}
}
// Offline-aware component
const OfflineAwareWorkoutDetail = ({ workoutId }) => {
const [workout, setWorkout] = useState(null);
const [loading, setLoading] = useState(true);
const [isStale, setIsStale] = useState(false);
const cacheManager = useRef(new OfflineCacheManager()).current;
const dataLoader = useRef(new ProgressiveDataLoader(cacheManager)).current;
useEffect(() => {
loadWorkoutData();
}, [workoutId]);
const loadWorkoutData = async () => {
try {
const data = await dataLoader.loadData(
`workout_${workoutId}`,
() => fetchWorkoutFromAPI(workoutId),
{
progressive: true,
fallback: {
name: 'Loading workout...',
placeholder: true
},
priority: 'normal'
}
);
setWorkout(data);
setIsStale(data.placeholder || false);
} catch (error) {
console.error('Failed to load workout:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" />
<Text>Loading workout...</Text>
</View>
);
}
return (
<View style={styles.workoutDetail}>
{isStale && (
<View style={styles.staleDataBanner}>
<Text style={styles.staleDataText}>
📶 Showing cached data - updating in background
</Text>
</View>
)}
<Text style={styles.workoutTitle}>{workout.name}</Text>
<Text style={styles.workoutDescription}>{workout.description}</Text>
{/* Rest of workout detail UI */}
</View>
);
};
Add offline usage analytics and monitoring:
// Offline analytics manager
class OfflineAnalyticsManager {
constructor() {
this.eventQueue = [];
this.sessionData = {
sessionId: this.generateSessionId(),
startTime: Date.now(),
offlineTime: 0,
onlineTime: 0,
eventsCount: 0
};
this.isTracking = false;
}
async initialize() {
await this.loadQueuedEvents();
this.startSession();
this.setupNetworkListener();
}
startSession() {
this.isTracking = true;
this.trackEvent('session_started', {
timestamp: Date.now(),
network_status: networkManager.isOnline ? 'online' : 'offline'
});
}
setupNetworkListener() {
networkManager.addConnectionListener({
onOnline: () => {
this.trackEvent('network_online');
this.flushEventQueue();
},
onOffline: () => {
this.trackEvent('network_offline');
},
onConnectionChange: (state) => {
this.updateSessionNetworkTime(state.isConnected);
}
});
}
trackEvent(eventName, properties = {}) {
if (!this.isTracking) return;
const event = {
id: Date.now() + '_' + Math.random().toString(36).substr(2, 9),
name: eventName,
properties: {
...properties,
session_id: this.sessionData.sessionId,
timestamp: Date.now(),
network_status: networkManager.isOnline ? 'online' : 'offline',
app_state: 'foreground' // Could be enhanced to track app state
},
queued_at: Date.now()
};
this.eventQueue.push(event);
this.sessionData.eventsCount++;
// Store in persistent storage
this.persistEvent(event);
// Try to send immediately if online
if (networkManager.isOnline) {
this.flushEventQueue();
}
}
async persistEvent(event) {
try {
const existingEvents = await AsyncStorage.getItem('analytics_queue') || '[]';
const events = JSON.parse(existingEvents);
events.push(event);
// Keep only last 1000 events to prevent storage overflow
if (events.length > 1000) {
events.splice(0, events.length - 1000);
}
await AsyncStorage.setItem('analytics_queue', JSON.stringify(events));
} catch (error) {
console.error('Failed to persist analytics event:', error);
}
}
async loadQueuedEvents() {
try {
const stored = await AsyncStorage.getItem('analytics_queue') || '[]';
this.eventQueue = JSON.parse(stored);
console.log(`Loaded ${this.eventQueue.length} queued analytics events`);
} catch (error) {
console.error('Failed to load queued events:', error);
this.eventQueue = [];
}
}
async flushEventQueue() {
if (this.eventQueue.length === 0 || !networkManager.isOnline) {
return;
}
const batchSize = 50;
let sentCount = 0;
try {
while (this.eventQueue.length > 0 && sentCount < batchSize) {
const batch = this.eventQueue.splice(0, Math.min(10, this.eventQueue.length));
await this.sendEventBatch(batch);
sentCount += batch.length;
}
// Update persistent storage
await AsyncStorage.setItem('analytics_queue', JSON.stringify(this.eventQueue));
console.log(`Sent ${sentCount} analytics events to server`);
} catch (error) {
console.error('Failed to flush analytics events:', error);
// Events remain in queue for retry
}
}
async sendEventBatch(events) {
const response = await fetch('/api/analytics/events', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ events })
});
if (!response.ok) {
throw new Error(`Analytics API failed: ${response.status}`);
}
return response.json();
}
updateSessionNetworkTime(isOnline) {
const now = Date.now();
const timeDiff = now - this.sessionData.lastNetworkChange || 0;
if (this.sessionData.wasOnline) {
this.sessionData.onlineTime += timeDiff;
} else {
this.sessionData.offlineTime += timeDiff;
}
this.sessionData.wasOnline = isOnline;
this.sessionData.lastNetworkChange = now;
}
generateSessionReport() {
this.updateSessionNetworkTime(networkManager.isOnline);
const totalTime = Date.now() - this.sessionData.startTime;
return {
session_id: this.sessionData.sessionId,
total_duration: totalTime,
online_time: this.sessionData.onlineTime,
offline_time: this.sessionData.offlineTime,
online_percentage: (this.sessionData.onlineTime / totalTime * 100).toFixed(1),
events_tracked: this.sessionData.eventsCount,
queued_events: this.eventQueue.length
};
}
endSession() {
const report = this.generateSessionReport();
this.trackEvent('session_ended', report);
this.isTracking = false;
return report;
}
generateSessionId() {
return Date.now().toString() + '_' + Math.random().toString(36).substr(2, 9);
}
}
// Offline usage monitor component
const OfflineUsageMonitor = () => {
const [sessionReport, setSessionReport] = useState(null);
const [networkHistory, setNetworkHistory] = useState([]);
const analyticsManager = useRef(new OfflineAnalyticsManager()).current;
useEffect(() => {
analyticsManager.initialize();
const interval = setInterval(() => {
const report = analyticsManager.generateSessionReport();
setSessionReport(report);
}, 10000); // Update every 10 seconds
// Track network changes
const networkListener = networkManager.addConnectionListener({
onConnectionChange: (state) => {
setNetworkHistory(prev => [...prev, {
timestamp: new Date().toLocaleTimeString(),
status: state.isConnected ? 'online' : 'offline',
type: state.type
}].slice(-10)); // Keep last 10 changes
}
});
return () => {
clearInterval(interval);
networkListener();
analyticsManager.endSession();
};
}, []);
const trackTestEvent = () => {
analyticsManager.trackEvent('test_button_clicked', {
button_type: 'usage_monitor_test',
user_action: 'manual_test'
});
};
if (!sessionReport) {
return <Text>Initializing analytics...</Text>;
}
return (
<View style={styles.monitorContainer}>
<Text style={styles.monitorTitle}>Offline Usage Monitor</Text>
{/* Session Statistics */}
<View style={styles.statsContainer}>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Session Duration</Text>
<Text style={styles.statValue}>
{Math.floor(sessionReport.total_duration / 1000 / 60)}m
</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Online Time</Text>
<Text style={styles.statValue}>
{sessionReport.online_percentage}%
</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Events Tracked</Text>
<Text style={styles.statValue}>{sessionReport.events_tracked}</Text>
</View>
<View style={styles.statItem}>
<Text style={styles.statLabel}>Queued Events</Text>
<Text style={styles.statValue}>{sessionReport.queued_events}</Text>
</View>
</View>
{/* Network History */}
<View style={styles.networkHistory}>
<Text style={styles.historyTitle}>Network Changes</Text>
{networkHistory.map((change, index) => (
<View key={index} style={styles.historyItem}>
<Text style={styles.historyTime}>{change.timestamp}</Text>
<Text style={[
styles.historyStatus,
{ color: change.status === 'online' ? '#2ecc71' : '#e74c3c' }
]}>
{change.status} ({change.type})
</Text>
</View>
))}
</View>
<TouchableOpacity style={styles.testButton} onPress={trackTestEvent}>
<Text style={styles.testButtonText}>Track Test Event</Text>
</TouchableOpacity>
</View>
);
};
// Global analytics instance
export const offlineAnalytics = new OfflineAnalyticsManager();
Completed Successfully If:
Time Investment: 60 minutes total Difficulty Level: Advanced Prerequisites: Database knowledge, sync concepts, network programming Tools Needed: SQLite, AsyncStorage, network monitoring, sync libraries