13 KiB
13 KiB
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)
- Local-first: Data sparas lokalt FÖRST, synkas SEN
- Optimistic Updates: UI uppdateras direkt, backend i bakgrund
- Graceful Degradation: Features som kräver nätverk degraderas snyggt
- Conflict Resolution: Tydlig strategi för datakonflikt
- 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
- Lokal data är sanning — Servern är backup
- Aldrig blockera UI — Sync sker i bakgrund
- Aldrig förlora data — Queue allt
- 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 🐝