Skip to content

9️⃣.4️⃣ Queue offline avec Capacitor Preferences

Dans une approche offline-first, on fait toujours deux choses quand l'utilisateur agit :

  1. On applique l'action immédiatement en local (SQLite) → l'UI reste fluide.
  2. 🕒 On mémorise l'intention pour pouvoir la rejouer plus tard sur Supabase → c'est la queue offline

👉 Ce chapitre met en place uniquement la queue (pas encore de synchronisation).

9️⃣.4️⃣.1️⃣ Pourquoi une queue offline ?

SQLite contient l'état local (les cartes visibles dans l'app). Mais quand l'utilisateur est hors-ligne, Supabase ne reçoit rien.

Exemples :

  • je crée une carte hors-ligne → Supabase ne sait pas qu'elle existe.
  • je modifie une carte hors-ligne → Supabase ne sait pas la dernière version.
  • je supprime une carte hors-ligne → Supabase ne supprime rien.

👉 On a donc besoin d’un mécanisme qui garde en mémoire :

“qu’est-ce que l’utilisateur a fait, dans quel ordre, et quoi rejouer quand le réseau revient”.

9️⃣.4️⃣.2️⃣ Pourquoi Capacitor Preferences (et pas SQLite) ?

Rappel de la théorie :

  • SQLite = données métier structurées (ici : cards)
  • Preferences = stockage clé/valeur simple (petites données JSON)

La queue offline est parfaite pour Preferences car :

  • c'est un tableau JSON
  • pas énorme en taille
  • facile à lire/écrire
  • persistant

9️⃣.4️⃣.3️⃣ Installer Capacitor Preferences

Dans le terminal :

bash
npm install @capacitor/preferences

9️⃣.4️⃣.4️⃣ Définir le type OfflineAction

Créez le fichier src/types/OfflineAction.ts :

src/types/OfflineAction.ts
ts
import type { CardLocal } from '@/types/Card'

/**
 * Une action offline représente une “intention utilisateur”
 * à rejouer plus tard sur Supabase.
 *
 * - id : identifiant unique de l’action (pour la retirer après sync)
 * - type : CREATE / UPDATE / DELETE
 * - payload : données nécessaires pour rejouer l’action
 * - createdAt : timestamp de l’action (utile pour debug + ordre)
 */
export type OfflineAction =
  | {
      id: string
      type: 'CREATE'
      payload: CardLocal
      createdAt: string
    }
  | {
      id: string
      type: 'UPDATE'
      payload: CardLocal
      createdAt: string
    }
  | {
      id: string
      type: 'DELETE'
      payload: { id: string } // on a juste besoin de l’id à supprimer
      createdAt: string
    }

9️⃣.4️⃣.5️⃣ Créer le service offlineQueueService

Créez le fichier src/services/offlineQueueService.ts.

Ce service :

  • lit la queue depuis Preferences,
  • ajoute des actions,
  • supprime des actions.
  • vide la queue.
src/services/offlineQueueService.ts
ts
import { Preferences } from '@capacitor/preferences'
import type { OfflineAction } from '@/types/OfflineAction'

/**
 * Clé unique dans Preferences.
 * Toute la queue est stockée en JSON sous cette clé.
 */
const QUEUE_KEY = 'offline_queue_cards'

/**
 * Lit la queue depuis Preferences.
 * Si rien n’existe encore, on renvoie un tableau vide.
 */
export async function getQueue(): Promise<OfflineAction[]> {
  const { value } = await Preferences.get({ key: QUEUE_KEY })

  // value est une string JSON ou null
  if (!value) return []

  try {
    return JSON.parse(value) as OfflineAction[]
  } catch {
    // si JSON cassé (rare), on repart sur une queue vide
    return []
  }
}

/**
 * Sauvegarde une queue complète dans Preferences.
 * (fonction interne pour centraliser l’écriture JSON)
 */
async function saveQueue(queue: OfflineAction[]): Promise<void> {
  await Preferences.set({
    key: QUEUE_KEY,
    value: JSON.stringify(queue)
  })
}

/**
 * Ajoute une action à la queue (en fin de liste).
 * -> l’ordre est important (on rejouera dans le même ordre plus tard)
 */
export async function enqueue(action: OfflineAction): Promise<void> {
  const queue = await getQueue()
  queue.push(action)
  await saveQueue(queue)
}

/**
 * Retire une action de la queue (par id).
 * -> utilisé après une sync réussie (chapitre 9.5)
 */
export async function removeFromQueue(actionId: string): Promise<void> {
  const queue = await getQueue()
  const newQueue = queue.filter((a) => a.id !== actionId)
  await saveQueue(newQueue)
}

/**
 * Vide complètement la queue.
 * -> utile pour debug / reset
 */
export async function clearQueue(): Promise<void> {
  await Preferences.remove({ key: QUEUE_KEY })
}

9️⃣.4️⃣.6️⃣ Ajouter des helpers pour créer une action rapidement

Pour simplifier l’ajout d’actions dans la queue, on crée des fonctions utilitaires dans src/services/offlineQueueService.ts. Ajoutez en bas du fichier :

src/services/offlineQueueService.ts
ts
import type { CardLocal } from '@/types/Card'

/**
 * Génère un id unique pour une action.
 * crypto.randomUUID() marche sur la plupart des navigateurs modernes.
 * (sinon on verra un fallback plus tard si besoin)
 */
function newActionId(): string {
  return crypto.randomUUID()
}

/**
 * Fabrique une action CREATE
 */
export function makeCreateAction(card: CardLocal) {
  return {
    id: newActionId(),
    type: 'CREATE' as const,
    payload: card,
    createdAt: new Date().toISOString()
  }
}

/**
 * Fabrique une action UPDATE
 */
export function makeUpdateAction(card: CardLocal) {
  return {
    id: newActionId(),
    type: 'UPDATE' as const,
    payload: card,
    createdAt: new Date().toISOString()
  }
}

/**
 * Fabrique une action DELETE
 */
export function makeDeleteAction(id: string) {
  return {
    id: newActionId(),
    type: 'DELETE' as const,
    payload: { id },
    createdAt: new Date().toISOString()
  }
}

Ces helpers évitent de recopier la même structure d’action partout dans le code. Par exemple makeCreateAction(card) crée une action CREATE complète avec un id et un timestamp automatiquement, au lieu de devoir le faire manuellement à chaque fois.

9️⃣.4️⃣.7️⃣ Brancher la queue sur le CRUD local

Ici, on relie ce qu'on a fait au chapitre 9.3 :

  • quand on crée/modifie/supprime en local
  • on ajoute aussi l'action dans la queue.

⚠️ Important

On le fait seulement si l'action n'est pas déjà "cloud". (les actions "cloud → local" via `upsertManyLocalCards() ne doivent pas créer de queue)

1. Création local → queue

Dans src/services/cardsLocalService.ts, importez :

src/services/cardsLocalService.ts
ts
import { enqueue, makeCreateAction, makeUpdateAction, makeDeleteAction } from '@/services/offlineQueueService'

Puis dans createLocalCard() ajoutez après l'insertion SQLite :

src/services/cardsLocalService.ts
ts
// ✅ On ajoute une action CREATE dans la queue
await enqueue(makeCreateAction({ ...card, synced: 0 }))

Exemple de version complète de createLocalCard() :

src/services/cardsLocalService.ts
ts
export async function createLocalCard(card: CardCloud): Promise<void> {
  const db = getDB()

  await db.run(/* ... insert ... */)

  // On pousse l’action dans la queue offline
  // -> elle sera rejouée sur Supabase quand le réseau reviendra
  await enqueue(makeCreateAction({ ...card, synced: 0 }))
}
2. Mise à jour local → queue

Dans updateLocalCard(), ajoutez après la mise à jour SQLite :

src/services/cardsLocalService.ts
ts
// On pousse l’action UPDATE dans la queue
await enqueue(makeUpdateAction({ ...card, synced: 0, updated_at: now }))

Exemple de version complète de updateLocalCard() :

src/services/cardsLocalService.ts
ts
export async function updateLocalCard(card: CardLocal): Promise<void> {
  const db = getDB()
  const now = new Date().toISOString()

  await db.run(/* ... update ... */)

  // Ajout en queue : on rejouera cet UPDATE sur Supabase plus tard
  await enqueue(makeUpdateAction({ ...card, synced: 0, updated_at: now }))
}
3. Suppression local → queue

Dans deleteLocalCard(), ajoutez après la suppression SQLite :

src/services/cardsLocalService.ts
ts
// On pousse l’action DELETE dans la queue
await enqueue(makeDeleteAction(id))

Exemple de version complète de deleteLocalCard() :

src/services/cardsLocalService.ts
ts
export async function deleteLocalCard(id: string): Promise<void> {
  const db = getDB()
  await db.run('DELETE FROM cards WHERE id = ?;', [id])

  // Ajout en queue : on rejouera ce DELETE sur Supabase plus tard
  await enqueue(makeDeleteAction(id))
}
Code complet de cardsLocalService.ts
src/services/cardsLocalService.ts
ts
import { getDB } from '@/services/sqliteService'
import type { CardCloud, CardLocal } from '@/types/Card'
import { enqueue, makeCreateAction, makeUpdateAction, makeDeleteAction } from '@/services/offlineQueueService'

/**
 * Récupère toutes les cartes depuis SQLite
 * -> l'UI peut s'afficher même sans réseau
 */
export async function getAllLocalCards(): Promise<CardLocal[]> {
    const db = getDB()

    // Tri par "updated_at" (plus récent en premier)
    const res = await db.query('SELECT * FROM cards ORDER BY updated_at DESC;')

    // SQLite renvoie parfois des nombres/booleans sous forme "0/1"
    // On normalise pour avoir un objet CardLocal propre
    return (res.values ?? []).map((row: any) => ({
        ...row,
        elixir_cost: Number(row.elixir_cost),
        hitpoints: Number(row.hitpoints),
        damage: Number(row.damage),
        arena: Number(row.arena),
        is_favorite: Boolean(row.is_favorite),
        synced: Number(row.synced)
    })) as CardLocal[]
}

/**
 * Crée une carte dans SQLite
 * - synced = 0 car pas encore synchronisée
 * - created_at / updated_at = now (pour le local)
 */
export async function createLocalCard(card: CardLocal): Promise<void> {
    const db = getDB()

    await db.run(
        `
            INSERT INTO cards (
                id, name, rarity, elixir_cost, role,
                hitpoints, damage, arena, is_favorite,
                created_at, updated_at, synced
            )
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
        `,
        [
            card.id,
            card.name,
            card.rarity,
            card.elixir_cost,
            card.role,
            card.hitpoints,
            card.damage,
            card.arena,
            card.is_favorite ? 1 : 0,
            card.created_at,
            card.updated_at,
            0 // ✅ offline-first : modification locale en attente
        ]
    )
    // ✅ On ajoute une action CREATE dans la queue
    await enqueue(makeCreateAction({ ...card, synced: 0 }))
}

/**
 * Met à jour une carte dans SQLite (offline-first)
 * - updated_at = now
 * - synced = 0 (à renvoyer au cloud)
 */
export async function updateLocalCard(card: CardLocal): Promise<void> {
    const db = getDB()
    const now = new Date().toISOString()

    await db.run(
        `
            UPDATE cards
            SET
                name = ?,
                rarity = ?,
                elixir_cost = ?,
                role = ?,
                hitpoints = ?,
                damage = ?,
                arena = ?,
                is_favorite = ?,
                updated_at = ?,
                synced = ?
            WHERE id = ?;
        `,
        [
            card.name,
            card.rarity,
            card.elixir_cost,
            card.role,
            card.hitpoints,
            card.damage,
            card.arena,
            card.is_favorite ? 1 : 0,
            now,
            0,
            card.id
        ]
    )
    // On pousse l’action UPDATE dans la queue
    await enqueue(makeUpdateAction({ ...card, synced: 0, updated_at: now }))
}

/**
 * Supprime une carte de SQLite
 * -> si offline, on stockera aussi l’action dans la queue (chapitre 9.5)
 */
export async function deleteLocalCard(id: string): Promise<void> {
    const db = getDB()
    await db.run('DELETE FROM cards WHERE id = ?;', [id])
    // On pousse l’action DELETE dans la queue
    await enqueue(makeDeleteAction(id))
}

/**
 * Insère ou met à jour plusieurs cartes dans SQLite
 * -> utilisé après un fetch Supabase
 * -> synced = 1 car les données viennent du cloud
 */
export async function upsertManyLocalCards(cards: CardCloud[]): Promise<void> {
    const db = getDB()

    for (const c of cards) {
        await db.run(
            `
                INSERT INTO cards (
                    id, name, rarity, elixir_cost, role,
                    hitpoints, damage, arena, is_favorite,
                    created_at, updated_at, synced
                )
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                    ON CONFLICT(id) DO UPDATE SET
                    name = excluded.name,
                                           rarity = excluded.rarity,
                                           elixir_cost = excluded.elixir_cost,
                                           role = excluded.role,
                                           hitpoints = excluded.hitpoints,
                                           damage = excluded.damage,
                                           arena = excluded.arena,
                                           is_favorite = excluded.is_favorite,
                                           created_at = excluded.created_at,
                                           updated_at = excluded.updated_at,
                                           synced = excluded.synced;
            `,
            [
                c.id,
                c.name,
                c.rarity,
                c.elixir_cost,
                c.role,
                c.hitpoints,
                c.damage,
                c.arena,
                c.is_favorite ? 1 : 0,
                c.created_at,
                c.updated_at,
                1
            ]
        )
    }
}

🔜 La suite...

Synchronisation automatique :

  • dès que le réseau revient, on lit la queue, on rejoue les actions sur Supabase, et on les retire de la queue.