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