554 lines
13 KiB
Markdown
554 lines
13 KiB
Markdown
# 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 (
|
||
<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
|
||
|
||
```jsx
|
||
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)
|
||
|
||
```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 🐝*
|