Files
flowdeck/docs/V6_PWA_Progressive_Web_App.md
T
bruno b5207216f1 feat: v6.0.0 PWA offline support
- 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
2026-09-18 13:05:40 -04:00

21 KiB

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

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

-- 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

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 :

/* 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;
}
<!-- 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

{
  "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)

<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

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

-- 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

# 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

// 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
# 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