# 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=&workspace_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
{{ fd_icon('alert-triangle', 14) }} Mode hors ligne — vos modifications seront synchronisées {{ pendingSyncCount }} modification(s) en attente
``` ### 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 ``` --- ## 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