- manifest + icones, service worker (precache, network-first, Background Sync)
- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur
- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)
- migrations offline_sync_queue + sync_version (triggers)
- UI offline (banner, badge sync, toasts, icone dirty) + doc /help
- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
538 lines
21 KiB
Markdown
538 lines
21 KiB
Markdown
# V6.0.0 — PWA : Progressive Web App, Offline Support
|
|
|
|
> **Statut** : Conception détaillée — v6.0.0
|
|
> **Date** : 2026-09-15
|
|
> **Route** : `feat/v6-pwa` → `develop` → `main`
|
|
> **Dépendances** : v5.14.0 Synced blocks (requis pour la synchronisation hors ligne)
|
|
|
|
---
|
|
|
|
## 1. Vision & objectifs
|
|
|
|
Transformer FlowDeck en une **Progressive Web App** fonctionnellement identique en ligne et hors ligne. L'utilisateur doit pouvoir :
|
|
|
|
- **Naviguer** entre les pages, workspaces, databases sans connexion
|
|
- **Créer, éditer, supprimer** du contenu en mode offline
|
|
- **Synchroniser** automatiquement les modifications dès le retour du réseau
|
|
- **Installer** l'application sur le bureau/l'écran d'accueil (manifest + service worker)
|
|
- **Bouncer** gracefulment entre modes en ligne/hors ligne avec indication claire
|
|
|
|
### Objectifs de qualité
|
|
|
|
| Critère | Cible |
|
|
|---------|-------|
|
|
| Temps de chargement hors ligne | < 1 s (cache local) |
|
|
| Taille du cache initial | < 15 MB (shell de l'app + assets critiques) |
|
|
| Synchronisation différée | Queue de mutations → replay automatique |
|
|
| Conflits | Détection par version de page → résolution manuelle ou last-write-wins |
|
|
| Disponibilité UI | Indicateur visuel online/offline persistant |
|
|
|
|
---
|
|
|
|
## 2. Architecture PWA
|
|
|
|
### 2.1 Composants
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────────┐
|
|
│ CLIENT (Browser) │
|
|
│ │
|
|
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
|
|
│ │ App Shell │ │ Service │ │ IndexedDB │ │
|
|
│ │ (Jinja2 + │ │ Worker │ │ (données offline) │ │
|
|
│ │ Alpine.js) │ │ (cache + │ │ │ │
|
|
│ │ │ │ sync) │ │ │ │
|
|
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
|
|
│ │ │ │ │
|
|
│ └────────────┬────┴───────────────────────┘ │
|
|
│ │ │
|
|
│ ┌────────────▼──────────────────────────────┐ │
|
|
│ │ Sync Queue (Background Sync) │ │
|
|
│ │ ┌─────────────────────────────┐ │ │
|
|
│ │ │ Mutations pending → replay │ │ │
|
|
│ │ │ sur reconnexion │ │ │
|
|
│ │ └─────────────────────────────┘ │ │
|
|
│ └───────────────────────────────────────────┘ │
|
|
└──────────────────────────┬──────────────────────────────────────┘
|
|
│ HTTPS / Service Worker
|
|
┌──────────────────────────▼──────────────────────────────────────┐
|
|
│ FASTAPI (Server) │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
│ │ Routes existantes (incluses dans le cache) │ │
|
|
│ │ ├─ /, /workspaces, /library, /settings │ │
|
|
│ │ ├─ /board/{owner}/{repo}, /db/{id}/pages │ │
|
|
│ │ ├─ /api/pages/*, /db/*/pages/api │ │
|
|
│ │ └─ /p/{slug} (pages publiques) │ │
|
|
│ └─────────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
|
│ │ Nouveau : Offline Sync Endpoint │ │
|
|
│ │ POST /api/v2/sync/batch — batch de mutations │ │
|
|
│ │ GET /api/v2/sync/delta ?since=timestamp │ │
|
|
│ └─────────────────────────────────────────────────────────┘ │
|
|
└─────────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
### 2.2 Flux de synchronisation
|
|
|
|
```
|
|
[En ligne] [Hors ligne]
|
|
│ │
|
|
│ ── Requête HTTP normale ──► │
|
|
│ │ L'utilisateur édite
|
|
│ ◄── Réponse JSON ── │ → mutation mise en queue
|
|
│ │ → IndexedDB mis à jour
|
|
│ │ → UI optimiste
|
|
│ │
|
|
│ Reconnexion détectée │
|
|
│ ── GET /api/v2/sync/delta ──► │
|
|
│ ◄── Changements serveur ────────┤
|
|
│ ── POST /api/v2/sync/batch ──► │
|
|
│ ◄── 200 OK (toutes mut. ack'd)─┤
|
|
│ → UI refresh + cache invalidé │
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Service Worker
|
|
|
|
### 3.1 Fichier : `static/sw.js`
|
|
|
|
```javascript
|
|
const CACHE_NAME = 'flowdeck-v6';
|
|
const PRECACHE_URLS = [
|
|
'/', '/workspaces', '/library', '/settings',
|
|
'/static/css/app.css', '/static/css/design-tokens.css',
|
|
'/static/css/components.css',
|
|
'/static/js/app.js', '/static/js/alpine.js',
|
|
'/static/js/sortable.js', '/static/js/prism.js',
|
|
'/static/js/katex.min.js', '/static/css/katex.min.css',
|
|
];
|
|
|
|
// ── Install : precache du shell ──
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
|
|
);
|
|
});
|
|
|
|
// ── Activate : purge des anciens caches ──
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
caches.keys().then((keys) =>
|
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
|
)
|
|
);
|
|
self.clients.claim();
|
|
});
|
|
|
|
// ── Fetch : stratégie cache-first pour assets, network-first pour API ──
|
|
self.addEventListener('fetch', (event) => {
|
|
const url = new URL(event.request.url);
|
|
|
|
if (url.pathname.startsWith('/api/')) {
|
|
// API : network-first avec fallback offline
|
|
event.respondWith(networkFirst(event.request));
|
|
} else if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2|ico)$/)) {
|
|
// Assets statiques : cache-first
|
|
event.respondWith(cacheFirst(event.request));
|
|
} else {
|
|
// Pages HTML : network-first avec fallback offline
|
|
event.respondWith(networkFirst(event.request));
|
|
}
|
|
});
|
|
```
|
|
|
|
### 3.2 Stratégies de cache
|
|
|
|
| Type | Stratégie | Fallback offline |
|
|
|------|-----------|-----------------|
|
|
| Shell (HTML/CSS/JS) | Cache-first | Cache stocké lors de l'install |
|
|
| API GET (pages, vues) | Network-first, cache en fallback | Dernière version IndexedDB |
|
|
| API POST/PUT/DELETE | Network-only | Offline queue → Background Sync |
|
|
| Images uploadées | Cache-first après upload | Stockées dans IndexedDB |
|
|
|
|
---
|
|
|
|
## 4. IndexedDB — Structure de données offline
|
|
|
|
### 4.1 Base de données : `flowdeck-offline`
|
|
|
|
```sql
|
|
-- Tables stockées dans IndexedDB (via idb-keyval ou Dexie.js)
|
|
|
|
-- Pages locales (modifiées hors ligne)
|
|
STORE pages_offline {
|
|
id: number (primary key)
|
|
workspace_id: number
|
|
title: string
|
|
content: string (blocs JSON)
|
|
content_format: string
|
|
parent_id: number | null
|
|
deleted_at: string | null
|
|
_modified_at: number (timestamp local)
|
|
_dirty: boolean (true = pending sync)
|
|
}
|
|
|
|
-- Collections vues hors ligne
|
|
STORE collections_offline {
|
|
id: number (primary key)
|
|
workspace_id: number
|
|
name: string
|
|
schema_json: string
|
|
_modified_at: number
|
|
_dirty: boolean
|
|
}
|
|
|
|
-- Queue de synchronisation
|
|
STORE sync_queue {
|
|
id: number (auto-increment)
|
|
type: string ('page_create' | 'page_update' | 'page_delete' | 'collection_create' | ...)
|
|
payload: string (JSON de la mutation)
|
|
timestamp: number
|
|
retries: number
|
|
status: string ('pending' | 'syncing' | 'synced' | 'failed')
|
|
}
|
|
|
|
-- Métadonnées de sync
|
|
STORE sync_meta {
|
|
key: string ('last_server_sync' | 'last_page_version' | ...)
|
|
value: string (timestamp ou version)
|
|
}
|
|
```
|
|
|
|
### 4.2 Modèle de conflit
|
|
|
|
```
|
|
Cas 1 — Pas de conflit (page non modifiée côté serveur)
|
|
→ Apply la mutation locale directement
|
|
|
|
Cas 2 — Conflit edit-edit
|
|
→ Détection via version de page (column `updated_at` serveur vs `_modified_at` local)
|
|
→ Résolution :
|
|
a) Last-write-wins (par défaut, configurable dans Settings)
|
|
b) Manuel : l'utilisateur choisit via un diff UI
|
|
|
|
Cas 3 — Conflit edit-delete
|
|
→ La page a été supprimée côté serveur pendant le mode offline
|
|
→ Créer une nouvelle page orphan → notification à l'utilisateur
|
|
|
|
Cas 4 — Conflit create-create (même titre)
|
|
→ Le serveur a créé une page avec le même titre pendant l'offset
|
|
→ Renommer la copie locale : "Titre (copie offline)"
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Endpoints serveur pour le sync offline
|
|
|
|
### 5.1 Nouveaux endpoints `app/routers/sync.py`
|
|
|
|
```
|
|
GET /api/v2/sync/delta?since=<timestamp>&workspace_id=<id>
|
|
→ Retourne les changements serveur depuis le dernier sync
|
|
→ Format: { changes: [{type, page_id, content, version}], new_pages: [...] }
|
|
|
|
POST /api/v2/sync/batch
|
|
→ Batch de mutations offline → serveur
|
|
→ Input: { mutations: [{type, payload, client_timestamp}], device_id: string }
|
|
→ Output: { results: [{mutation_id, status, server_version}], conflicts: [...] }
|
|
|
|
GET /api/v2/sync/status
|
|
→ État de synchronisation pour le workspace courant
|
|
→ { pending_count, last_sync, is_syncing }
|
|
```
|
|
|
|
### 5.2 Service serveur `app/services/sync_engine.py`
|
|
|
|
```python
|
|
class SyncEngine:
|
|
"""Moteur de synchronisation offline↔online."""
|
|
|
|
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
|
|
"""Retourne les changements serveur depuis `since`."""
|
|
# Requêter pages, collections, comments modifiées depuis `since`
|
|
# Inclure les versions pour détection de conflits
|
|
|
|
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
|
|
"""Applique un batch de mutations en une transaction."""
|
|
results = []
|
|
conflicts = []
|
|
for mut in mutations:
|
|
try:
|
|
# Validation de la version attendue
|
|
# Application de la mutation
|
|
# Génération du conflit si version mismatch
|
|
results.append({"mutation_id": mut["id"], "status": "synced", "server_version": ...})
|
|
except ConflictError as e:
|
|
conflicts.append({"mutation_id": mut["id"], "conflict": e.details})
|
|
return {"results": results, "conflicts": conflicts}
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Interface utilisateur — Indicateurs offline
|
|
|
|
### 6.1 Offline banner (existant à améliorer)
|
|
|
|
Le banner offline existant dans `local_workspace.html` (ligne 3016-3017) est étendu :
|
|
|
|
```css
|
|
/* Amélioration : ajouter dans app.css */
|
|
.offline-banner {
|
|
position: fixed; top: 0; left: 0; right: 0;
|
|
background: var(--danger); color: #fff;
|
|
text-align: center; padding: 8px 16px;
|
|
font-size: 13px; z-index: 500; font-weight: 500;
|
|
/* Ajout v6 */
|
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
|
animation: slideDown 0.3s ease;
|
|
}
|
|
```
|
|
|
|
```html
|
|
<!-- Extension du banner -->
|
|
<div class="offline-banner" x-show="!online" x-transition>
|
|
<span>{{ fd_icon('alert-triangle', 14) }} Mode hors ligne — vos modifications seront synchronisées</span>
|
|
<span x-show="pendingSyncCount > 0" class="badge">
|
|
{{ pendingSyncCount }} modification(s) en attente
|
|
</span>
|
|
</div>
|
|
```
|
|
|
|
### 6.2 Indicateur de synchronisation
|
|
|
|
- **Badge syncing** dans la topbar quand le batch est en cours
|
|
- **Toast** à la fin d'une synchronisation réussie ou avec erreurs
|
|
- **Cône de synchronisation** dans le sidebar pour les pages modifiées hors ligne (icône `⟳` sur les pages `_dirty = true`)
|
|
|
|
---
|
|
|
|
## 7. Manifest Web & Installation
|
|
|
|
### 7.1 `static/manifest.json`
|
|
|
|
```json
|
|
{
|
|
"name": "FlowDeck",
|
|
"short_name": "FlowDeck",
|
|
"description": "Clone Notion intégré à Gitea/GitHub",
|
|
"start_url": "/",
|
|
"display": "standalone",
|
|
"background_color": "#191919",
|
|
"theme_color": "#191919",
|
|
"orientation": "any",
|
|
"icons": [
|
|
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
|
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
|
|
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
|
],
|
|
"categories": ["productivity", "business"],
|
|
"lang": "fr",
|
|
"dir": "ltr",
|
|
"prefer_related_applications": false
|
|
}
|
|
```
|
|
|
|
### 7.2 Service Worker registration (dans `base.html`)
|
|
|
|
```html
|
|
<script>
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/sw.js').then(reg => {
|
|
console.log('SW registered:', reg.scope);
|
|
// Background Sync support
|
|
if ('sync' in reg) {
|
|
reg.sync.register('sync-flowdeck');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
</script>
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Tables de base de données — Modifications nécessaires
|
|
|
|
### 8.1 Nouveau table `offline_sync_queue`
|
|
|
|
```sql
|
|
CREATE TABLE offline_sync_queue (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
device_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
-- 'page_create', 'page_update', 'page_delete', 'page_move',
|
|
-- 'collection_create', 'collection_update', 'collection_delete'
|
|
payload TEXT NOT NULL, -- JSON de la mutation
|
|
client_timestamp REAL NOT NULL, -- epoch ms
|
|
server_version INTEGER DEFAULT 0,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
-- 'pending', 'syncing', 'synced', 'failed'
|
|
retries INTEGER NOT NULL DEFAULT 0,
|
|
error TEXT,
|
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
);
|
|
|
|
CREATE INDEX idx_syncqueue_user ON offline_sync_queue(user_id, status);
|
|
CREATE INDEX idx_syncqueue_device ON offline_sync_queue(device_id, status);
|
|
```
|
|
|
|
### 8.2 Colonnes ajoutées sur `pages` et `collections`
|
|
|
|
```sql
|
|
-- Détection de conflit
|
|
ALTER TABLE pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
|
|
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
|
|
ALTER TABLE collections ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
|
|
```
|
|
|
|
### 8.3 Migration
|
|
|
|
```python
|
|
# app/migrations.py — ajouter à la séquence
|
|
MIGRATIONS = [
|
|
# ... migrations existantes v1-v5 ...
|
|
("v6-offline-sync-queue", """
|
|
CREATE TABLE offline_sync_queue (...);
|
|
CREATE INDEX ...;
|
|
"""),
|
|
("v6-sync-version-columns", """
|
|
ALTER TABLE pages ADD COLUMN sync_version INTEGER DEFAULT 1;
|
|
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER DEFAULT 1;
|
|
ALTER TABLE collections ADD COLUMN sync_version INTEGER DEFAULT 1;
|
|
"""),
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 9. Frontend — Modules Alpine.js
|
|
|
|
### 9.1 Module `app/offline.js`
|
|
|
|
```javascript
|
|
// Module hors ligne — gestion de la connexion et de la queue
|
|
export const offlineModule = {
|
|
data() {
|
|
return {
|
|
online: navigator.onLine,
|
|
pendingSyncCount: 0,
|
|
lastSyncAt: null,
|
|
isSyncing: false,
|
|
};
|
|
},
|
|
async created() {
|
|
window.addEventListener('online', () => { this.online = true; this.processQueue(); });
|
|
window.addEventListener('offline', () => { this.online = false; });
|
|
this.pendingSyncCount = await this.getPendingCount();
|
|
// Polling status toutes les 30s
|
|
setInterval(() => this.syncStatus(), 30000);
|
|
},
|
|
methods: {
|
|
async getPendingCount() {
|
|
const r = await fetch('/api/v2/sync/status');
|
|
const d = await r.json();
|
|
this.pendingSyncCount = d.pending_count;
|
|
this.lastSyncAt = d.last_sync;
|
|
},
|
|
async processQueue() {
|
|
if (this.isSyncing || !this.online) return;
|
|
this.isSyncing = true;
|
|
const queue = await this.getQueue();
|
|
if (queue.length > 0) {
|
|
await fetch('/api/v2/sync/batch', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ mutations: queue, device_id: DEVICE_ID }),
|
|
});
|
|
}
|
|
this.isSyncing = false;
|
|
await this.getPendingCount();
|
|
},
|
|
async getQueue() { /* Lire IndexedDB sync_queue */ },
|
|
async syncStatus() { /* GET /api/v2/sync/status */ },
|
|
},
|
|
};
|
|
```
|
|
|
|
### 9.2 Integration dans l'éditeur
|
|
|
|
Lorsqu'un utilisateur est hors ligne et modifie une page :
|
|
1. La mutation est immédiatement appliquée dans IndexedDB (optimistic UI)
|
|
2. L'éditeur sauvegarde normalement dans le cache local
|
|
3. Une entrée est ajoutée à `sync_queue` via `POST /api/v2/sync/batch` quand le réseau revient
|
|
4. Le contenu de la page est marqué `_dirty = true` → icône `⟳` dans le sidebar
|
|
|
|
---
|
|
|
|
## 10. Tests
|
|
|
|
| Test | Description | Outil |
|
|
|------|-------------|-------|
|
|
| Service worker install | Cache precache valide | Playwright |
|
|
| Offline page load | Navigation sans réseau → cache servi | Playwright |
|
|
| Offline mutation | Créer une page offline → IndexedDB + queue | Playwright + SQLite |
|
|
| Online sync | Reconnexion → batch replay → serveur à jour | Playwright + API |
|
|
| Conflict resolution | Edit-edit conflict → résolution last-write-wins | Unit test + Playwright |
|
|
| Manifest install | `navigator.standalone` ou `beforeinstallprompt` | Playwright |
|
|
| Background sync | `SyncManager.register` appelé | Playwright |
|
|
| Cache invalidation | Nouvelle version → ancien cache supprimé | Unit test |
|
|
|
|
```bash
|
|
# Commandes
|
|
pytest tests/test_pwa_offline.py # Tests sync offline
|
|
pytest tests/test_service_worker.py # Tests SW
|
|
npx playwright tests/pwa-offline.spec.ts # E2E offline flows
|
|
```
|
|
|
|
---
|
|
|
|
## 11. Checklist d'implémentation
|
|
|
|
1. **`static/manifest.json`** + icônes (512x512 maskable)
|
|
2. **`static/sw.js`** — Service Worker avec precache + API strategy
|
|
3. **`app/routers/sync.py`** — endpoints `/api/v2/sync/*`
|
|
4. **`app/services/sync_engine.py`** — moteur de synchronisation
|
|
5. **Migrations** — table `offline_sync_queue` + colonnes `sync_version`
|
|
6. **`app/offline.js`** — module Alpine.js offline
|
|
7. **Amélioration banner offline** — pending count + sync status
|
|
8. **IndexedDB côté client** — wrapper pour pages, collections, queue
|
|
9. **Tests** — offline scenarios + sync conflict + background sync
|
|
10. **Dockerfile** — servir le SW + manifest via le server
|
|
11. **Documentation utilisateur** — section `/help` sur le mode offline
|
|
|
|
---
|
|
|
|
## 12. Performance & contraintes
|
|
|
|
| Contrainte | Détail |
|
|
|-----------|--------|
|
|
| Taille maximale cache shell | 15 MB |
|
|
| Timeout sync batch | 30 s |
|
|
| Max mutations par batch | 100 |
|
|
| Retention queue offline | 30 jours (auto-purge) |
|
|
| Device ID | UUID stocké dans localStorage |
|
|
| Fallback sans SW | Mode "lite" → pas de PWA, mais app fonctionne normalement |
|
|
|
|
---
|
|
|
|
## 13. Références
|
|
|
|
- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
|
|
- [Google: Offline Storage Best Practices](https://web.dev/offline-storage-best-practices/)
|
|
- [Workbox — Google's SW library](https://developers.google.com/web/tools/workbox)
|
|
- [Background Sync API](https://web.dev/background-sync/)
|
|
- [IndexedDB MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
|
|
- `app/routers/sync.py` — (à créer)
|
|
- `app/services/sync_engine.py` — (à créer)
|
|
- `docs/API_GUIDE_V6.md` — référence API v2 (endpoint `/api/v2/sync/*`)
|
|
- `ROADMAP.md` — v6.0.0 PWA item
|