Files
gravl/.planning/research/12-offline-first.md
T

13 KiB
Raw Blame History

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
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
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
// 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;
    })
  );
});
// 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
// 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

// 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

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

// 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

// Sync-indikator komponent
const SyncStatus = () => {
  const { pendingCount, lastSync, isOnline } = useSyncStatus();
  
  if (!isOnline) {
    return (
      <StatusBar color="orange">
        📴 Offline  Data sparas lokalt
      </StatusBar>
    );
  }
  
  if (pendingCount > 0) {
    return (
      <StatusBar color="yellow">
         Synkar {pendingCount} ändringar...
      </StatusBar>
    );
  }
  
  return (
    <StatusBar color="green">
       Synkad {formatTime(lastSync)}
    </StatusBar>
  );
};

Per-item sync status

const WorkoutLogItem = ({ log }) => {
  return (
    <View>
      <Text>{log.exercise}  {log.weight}kg × {log.reps}</Text>
      {!log.synced && (
        <Badge color="orange">Ej synkad</Badge>
      )}
    </View>
  );
};

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)

-- 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 🐝