# Offline-First Implementation — Research för Gravl ## Varför Offline-First? > "Mobile networks are unreliable. Users face data limits, weak signals, airplane mode, subway tunnels." **Gym-specifikt:** - Gym har ofta dålig/ingen WiFi - Källare, betong, metall = dålig signal - Användare vill inte vänta på laddning mellan sets - Data får INTE förloras (loggade reps är värdefulla) --- ## Offline-First Principer ### Core Principles (från OneUptime) 1. **Local-first:** Data sparas lokalt FÖRST, synkas SEN 2. **Optimistic Updates:** UI uppdateras direkt, backend i bakgrund 3. **Graceful Degradation:** Features som kräver nätverk degraderas snyggt 4. **Conflict Resolution:** Tydlig strategi för datakonflikt 5. **Transparent Sync:** Användaren förstår sync-status ### Mental Model ``` ┌─────────────────────────────────────────────────────────┐ │ USER ACTION │ │ (logga set) │ └─────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ LOCAL DATABASE │ │ (SQLite/IndexedDB) │ │ │ │ ✅ Omedelbar respons │ │ ✅ Fungerar offline │ │ ✅ Data säker lokalt │ └─────────────────────┬───────────────────────────────────┘ │ │ (när nätverk finns) ▼ ┌─────────────────────────────────────────────────────────┐ │ SYNC ENGINE │ │ │ │ • Queue pending changes │ │ • Retry on failure │ │ • Resolve conflicts │ └─────────────────────┬───────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────┐ │ REMOTE SERVER │ │ (PostgreSQL API) │ └─────────────────────────────────────────────────────────┘ ``` --- ## Tekniska Alternativ ### 1. React Native + SQLite **Bibliotek:** `react-native-sqlite-storage` eller `expo-sqlite` **Fördelar:** - Native performance - Full SQL-support - Beprövad teknologi **Nackdelar:** - Kräver native build - Ingen inbyggd sync ```javascript import * as SQLite from 'expo-sqlite'; const db = SQLite.openDatabase('gravl.db'); // Skapa tabell db.transaction(tx => { tx.executeSql( `CREATE TABLE IF NOT EXISTS workout_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, exercise_id INTEGER, weight REAL, reps TEXT, synced INTEGER DEFAULT 0, local_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP )` ); }); // Logga set (offline-first) const logSet = async (exerciseId, weight, reps) => { const localId = uuid.v4(); // Spara lokalt FÖRST db.transaction(tx => { tx.executeSql( 'INSERT INTO workout_logs (exercise_id, weight, reps, local_id) VALUES (?, ?, ?, ?)', [exerciseId, weight, JSON.stringify(reps), localId] ); }); // Försök synka i bakgrund syncToServer(localId); }; ``` ### 2. React Native + RxDB **RxDB:** Reactive Database med inbyggd sync **Fördelar:** - Reaktiv (observables) - Inbyggd sync (CouchDB-protokoll) - Conflict resolution - TypeScript-stöd **Nackdelar:** - Mer komplex setup - Större bundle ```javascript import { createRxDatabase, addRxPlugin } from 'rxdb'; import { getRxStorageDexie } from 'rxdb/plugins/storage-dexie'; import { RxDBReplicationCouchDBPlugin } from 'rxdb/plugins/replication-couchdb'; addRxPlugin(RxDBReplicationCouchDBPlugin); const db = await createRxDatabase({ name: 'gravldb', storage: getRxStorageDexie() }); // Schema await db.addCollections({ workouts: { schema: { version: 0, primaryKey: 'id', properties: { id: { type: 'string' }, exercise_id: { type: 'number' }, weight: { type: 'number' }, reps: { type: 'array' }, timestamp: { type: 'string' } } } } }); // Replication const replicationState = db.workouts.syncCouchDB({ remote: 'https://api.gravl.app/sync', push: { batchSize: 10 }, pull: { batchSize: 10 } }); ``` ### 3. PWA + IndexedDB + Service Worker **För web-first approach** **Fördelar:** - Ingen app store - Fungerar på alla plattformar - Service Worker caching **Nackdelar:** - Begränsad native-access - iOS PWA-begränsningar ```javascript // Service Worker (sw.js) const CACHE_NAME = 'gravl-v1'; const OFFLINE_URLS = [ '/', '/app.js', '/styles.css', '/exercises.json' ]; self.addEventListener('install', event => { event.waitUntil( caches.open(CACHE_NAME).then(cache => { return cache.addAll(OFFLINE_URLS); }) ); }); self.addEventListener('fetch', event => { event.respondWith( caches.match(event.request).then(cached => { // Returnera cached först, hämta nytt i bakgrund const networkFetch = fetch(event.request).then(response => { caches.open(CACHE_NAME).then(cache => { cache.put(event.request, response.clone()); }); return response; }); return cached || networkFetch; }) ); }); ``` ```javascript // IndexedDB wrapper (Dexie) import Dexie from 'dexie'; const db = new Dexie('GravlDB'); db.version(1).stores({ workouts: '++id, date, synced', exercises: 'id, name, bodyPart', pendingSync: '++id, type, data, timestamp' }); // Offline-first save async function saveWorkout(workout) { // Spara lokalt const id = await db.workouts.add({ ...workout, synced: false, localId: crypto.randomUUID() }); // Queue för sync await db.pendingSync.add({ type: 'workout', data: workout, timestamp: Date.now() }); // Trigger background sync if ('serviceWorker' in navigator && 'sync' in registration) { registration.sync.register('sync-workouts'); } return id; } ``` ### 4. SQLite Sync (CRDT) **Nytt:** SQLite Cloud's SQLite Sync extension **Fördelar:** - Äkta local-first - CRDT för konfliktfri sync - Standard SQLite API ```javascript // SQLite Sync (konceptuell) import { SQLiteSync } from 'sqlite-sync'; const db = new SQLiteSync('gravl.db', { remote: 'https://sync.gravl.app', tables: ['workouts', 'exercises'] }); // Automatisk sync! await db.exec(` INSERT INTO workouts (exercise_id, weight, reps) VALUES (1, 80, '[8, 8, 8]') `); // Synkas automatiskt när online ``` --- ## Sync Strategies ### 1. Optimistic UI ```javascript // Användaren ser ändringen DIREKT const logSet = async (data) => { // 1. Uppdatera UI omedelbart setWorkoutLogs(prev => [...prev, data]); // 2. Spara lokalt await localDB.save(data); // 3. Synka i bakgrund (utan att blockera UI) syncInBackground(data).catch(err => { // Visa synkfel-indikator, men behåll data showSyncError(); }); }; ``` ### 2. Conflict Resolution **Strategier:** | Strategi | Beskrivning | Bäst för | |----------|-------------|----------| | **Last Write Wins** | Senaste timestamp vinner | Enkel data | | **Client Wins** | Lokal data prioriteras | User-kontroll | | **Server Wins** | Server-data prioriteras | Data integrity | | **Merge** | Kombinera ändringar | Komplex data | | **CRDT** | Konfliktfri automatisk | Multi-device | **Gravl-rekommendation:** Last Write Wins med server-timestamp ```javascript const resolveConflict = (local, remote) => { // Om samma workout redigerats på två enheter if (local.updated_at > remote.updated_at) { return local; // Nyare vinner } else { return remote; } }; ``` ### 3. Background Sync ```javascript // Service Worker background sync self.addEventListener('sync', event => { if (event.tag === 'sync-workouts') { event.waitUntil(syncPendingWorkouts()); } }); async function syncPendingWorkouts() { const pending = await db.pendingSync .where('type') .equals('workout') .toArray(); for (const item of pending) { try { await fetch('/api/workouts', { method: 'POST', body: JSON.stringify(item.data) }); // Ta bort från queue await db.pendingSync.delete(item.id); // Markera som synkad await db.workouts .where('localId') .equals(item.data.localId) .modify({ synced: true }); } catch (err) { // Retry later console.log('Sync failed, will retry'); } } } ``` --- ## Sync Status UI ### Indikera sync-status ```jsx // Sync-indikator komponent const SyncStatus = () => { const { pendingCount, lastSync, isOnline } = useSyncStatus(); if (!isOnline) { return ( 📴 Offline — Data sparas lokalt ); } if (pendingCount > 0) { return ( ⏳ Synkar {pendingCount} ändringar... ); } return ( ✅ Synkad {formatTime(lastSync)} ); }; ``` ### Per-item sync status ```jsx const WorkoutLogItem = ({ log }) => { return ( {log.exercise} — {log.weight}kg × {log.reps} {!log.synced && ( Ej synkad )} ); }; ``` --- ## Gravl Implementation Plan ### Phase 1: Local Storage ``` 1. Implementera SQLite/IndexedDB 2. Spara ALL data lokalt först 3. UI visar alltid lokal data 4. Ingen sync ännu (100% offline) ``` ### Phase 2: Basic Sync ``` 1. Lägg till sync queue 2. POST nya workouts till server 3. Markera som synkade 4. Retry on failure ``` ### Phase 3: Bi-directional Sync ``` 1. Pull server-ändringar 2. Merge med lokal data 3. Conflict resolution 4. Multi-device support ``` ### Phase 4: Real-time (optional) ``` 1. WebSocket för live updates 2. Optimistic UI 3. Collaborative features ``` --- ## Database Schema (Offline-optimerad) ```sql -- Local SQLite schema CREATE TABLE workouts ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_id TEXT UNIQUE NOT NULL, -- UUID, genereras lokalt server_id INTEGER, -- NULL tills synkad -- Data program_day_id INTEGER, started_at TEXT, completed_at TEXT, notes TEXT, -- Sync metadata synced INTEGER DEFAULT 0, sync_action TEXT DEFAULT 'create', -- 'create', 'update', 'delete' local_updated_at TEXT, server_updated_at TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE workout_sets ( id INTEGER PRIMARY KEY AUTOINCREMENT, local_id TEXT UNIQUE NOT NULL, server_id INTEGER, workout_local_id TEXT REFERENCES workouts(local_id), exercise_id INTEGER, set_number INTEGER, weight REAL, reps INTEGER, rpe REAL, synced INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, table_name TEXT, local_id TEXT, action TEXT, -- 'create', 'update', 'delete' payload TEXT, -- JSON attempts INTEGER DEFAULT 0, last_attempt TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- Index för snabb sync-lookup CREATE INDEX idx_workouts_synced ON workouts(synced); CREATE INDEX idx_sync_queue_attempts ON sync_queue(attempts); ``` --- ## Rekommendation för Gravl ### Tech Stack ``` Frontend: React (web) eller React Native (app) Local DB: Dexie (IndexedDB wrapper) för web expo-sqlite för native Sync: Custom sync engine med retry logic Backend: Befintlig Express/PostgreSQL ``` ### Varför inte RxDB/CouchDB? - Overhead för ett simpelt use case - Gravl har enkel data (workouts, sets) - Custom sync ger mer kontroll ### Nyckelprinciper 1. **Lokal data är sanning** — Servern är backup 2. **Aldrig blockera UI** — Sync sker i bakgrund 3. **Aldrig förlora data** — Queue allt 4. **Tydlig status** — Användaren vet vad som händer --- ## Källor - Medium: Offline-First React Native (2026) - OneUptime: React Native Data Sync - dev.family: RxDB Architecture - Google Developers: PWA Going Offline - Monterail: PWA Dynamic Data - SQLite.ai: SQLite Sync - SQLite Cloud: OffSync --- *Sammanställt 2026-02-15 av Bumblebee 🐝*