docs: add phase 3 design polish planning, update progress
This commit is contained in:
@@ -0,0 +1,553 @@
|
||||
# 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 🐝*
|
||||
Reference in New Issue
Block a user