Skip to Content
React Native8. SQLite8.3 Stats & records

Chapitre 8.3 — Stats & records : le SQL qui fait les écrans

Où on en est : les sessions s’enregistrent. On écrit maintenant les requêtes qui font vivre l’onglet Historique et l’écran résultat — records, moyennes, progression — puis on branche le tout avec TanStack Query (oui, lui aussi sert en local).

⏱️ TL;DR — Des agrégats SQL classiques (MAX, AVG, GROUP BY) font tout le travail : record par thème, taux de réussite, sessions par jour. Côté React, on réutilise TanStack Query comme couche d’accès (queryKey ['stats', …], invalidation après chaque partie) — un seul modèle mental pour les données distantes ET locales. L’écran résultat sait dire « 🏆 Nouveau record ! ».

🎯 Objectifs

  • Écrire les requêtes d’agrégats de Drill (records, stats par thème, progression).
  • Utiliser Query pour les données SQLite (et invalider après écriture).
  • Détecter le nouveau record au bon moment (avant d’écrire la session !).
  • Brancher l’Historique définitif : liste + en-tête de stats.

Les requêtes de Drill

// lib/db/stats.ts import type { SQLiteDatabase } from 'expo-sqlite' export type ThemeStats = { theme: string sessionCount: number bestScore: number avgScore: number accuracy: number // 0-1, toutes sessions confondues lastPlayedAt: number | null } export function getThemeStats(db: SQLiteDatabase, theme: string) { return db.getFirstAsync<ThemeStats>( `SELECT theme, COUNT(*) AS sessionCount, MAX(score) AS bestScore, ROUND(AVG(score)) AS avgScore, CAST(SUM(correct_count) AS REAL) / NULLIF(SUM(total), 0) AS accuracy, MAX(played_at) AS lastPlayedAt FROM sessions WHERE theme = ? GROUP BY theme`, theme ) } export function getGlobalStats(db: SQLiteDatabase) { return db.getAllAsync<ThemeStats>( `SELECT theme, COUNT(*) AS sessionCount, MAX(score) AS bestScore, ROUND(AVG(score)) AS avgScore, CAST(SUM(correct_count) AS REAL) / NULLIF(SUM(total), 0) AS accuracy, MAX(played_at) AS lastPlayedAt FROM sessions GROUP BY theme ORDER BY lastPlayedAt DESC` ) } // Les questions les plus ratées — le radar de révision (et la graine de Drill v2) export function getWorstQuestions(db: SQLiteDatabase, limit = 10) { return db.getAllAsync<{ questionId: string; attempts: number; failRate: number }>( `SELECT question_id AS questionId, COUNT(*) AS attempts, 1.0 - AVG(correct) AS failRate FROM answers GROUP BY question_id HAVING attempts >= 3 -- pas de verdict sur 1 essai ORDER BY failRate DESC, attempts DESC LIMIT ?`, limit ) }

Trois détails de SQL qui distinguent le code sérieux :

  • NULLIF(SUM(total), 0) : pas de division par zéro (table vide).
  • CAST(… AS REAL) : sans lui, la division INTEGER/INTEGER tronque à 0.
  • HAVING attempts >= 3 : un agrégat ne devient une information qu’avec assez de données.
  • Et AVG(correct) sur du 0/1 = le taux de réussite direct — l’astuce booléens-entiers.

TanStack Query pour le local — un seul modèle mental

Surprise de design : Query n’est pas réservé au réseau. queryFn est juste une fonction async — SQLite en est une. Bénéfices : cache entre écrans, états gérés, et surtout l’invalidation :

// hooks/stats.ts import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' import { useSQLiteContext } from 'expo-sqlite' import { getGlobalStats, getThemeStats } from '../lib/db/stats' import { insertSession } from '../lib/db/sessions' export function useGlobalStats() { const db = useSQLiteContext() return useQuery({ queryKey: ['stats', 'global'], queryFn: () => getGlobalStats(db), staleTime: Infinity, // ← n'expire JAMAIS seul : seule une partie le change }) } export function useSaveSession() { const db = useSQLiteContext() const qc = useQueryClient() return useMutation({ mutationFn: ({ summary, answers }: { summary: SessionInput; answers: AnswerInput[] }) => insertSession(db, summary, answers), onSuccess: () => { qc.invalidateQueries({ queryKey: ['stats'] }) // TOUTES les stats se rafraîchissent qc.invalidateQueries({ queryKey: ['history'] }) }, }) }

staleTime: Infinity + invalidation explicite : les stats locales ne changent QUE quand une partie s’enregistre — le cache est donc éternellement frais entre-temps, et l’invalidation après mutation rafraîchit tous les écrans abonnés d’un coup. (C’est LE pattern mutation/invalidation de Query, appliqué à une base locale.)

Le nouveau record — l’ordre des opérations

L’écran résultat veut afficher « 🏆 Nouveau record ! ». Subtilité : il faut comparer AVANT d’insérer (sinon la session courante est son propre record) :

// Dans le flux de fin de partie (écran résultat, au montage) const previousBest = await getThemeStats(db, theme).then(s => s?.bestScore ?? 0) const isNewRecord = summary.score > previousBest await saveSession.mutateAsync({ summary, answers }) // → afficher isNewRecord (et le confetti attendra la Partie 10)

Séquence : lire l’ancien record → comparer → écrire. Trois lignes, mais l’inverser est LE bug du domaine (tout le monde le fait une fois).

L’Historique définitif assemble tout : ListHeaderComponent = les cartes de stats globales (useGlobalStats), la liste = useQuery(['history'], () => listSessions(db)), le useFocusEffect du chapitre 6.1 devient inutile — l’invalidation a remplacé le refetch au focus : plus précis, plus économe.

⚠️ Piège — Résistez à « je calcule les stats en JS depuis listSessions() ». Ça marche à 50 sessions… et l’écran ramera à 2 000 (tout charger pour tout réduire). SQLite est un moteur d’agrégation : GROUP BY sur 100 000 lignes indexées est instantané. La règle : la réduction se fait dans la base, le JS reçoit des résultats, pas des données brutes.

💡 Pour un dev Next.js — Query pour SQLite peut surprendre — mais reconnaissez le pattern : c’est exactement invalidation-après-mutation que vous pratiquez avec les server actions + revalidate. Un seul modèle mental (queryKey/invalidate) pour GitHub ET la base locale, c’est moins de concepts que « un système de cache réseau + un state manager local ».

✏️ Exercices

1. Écrivez getDailyActivity(db, days) : sessions par jour sur les N derniers jours (pour la future grille d’activité façon GitHub). Astuce : strftime sur played_at/1000.

✅ Solution

export function getDailyActivity(db: SQLiteDatabase, days = 30) { return db.getAllAsync<{ day: string; count: number }>( `SELECT strftime('%Y-%m-%d', played_at / 1000, 'unixepoch', 'localtime') AS day, COUNT(*) AS count FROM sessions WHERE played_at >= ? GROUP BY day ORDER BY day`, Date.now() - days * 86_400_000 ) }

unixepoch + division par 1000 (ms → s), localtime pour que « aujourd’hui » soit le vôtre. Consommé par un useQuery(['stats','daily']) — invalidé par le même onSuccess.

2. L’écran détail d’un thème (ch. 5.3) doit afficher son record réel et le taux de réussite. Branchez useThemeStats(slug) — gérez le cas « jamais joué ».

✅ Solution

export function useThemeStats(theme: string) { const db = useSQLiteContext() return useQuery({ queryKey: ['stats', 'theme', theme], queryFn: () => getThemeStats(db, theme), staleTime: Infinity, }) }

getFirstAsync renvoie null si aucune ligne (GROUP BY sans données) → l’écran affiche « Jamais joué — sois le premier ! » au lieu de 0 partout. Le null est une information.

3. Un utilisateur joue en pleine nuit : sa session de 00h30 compte pour quel jour dans getDailyActivity, et pourquoi est-ce le bon comportement ? Quel bug le paramètre ‘localtime’ évite-t-il ?

✅ Solution

Pour le jour civil LOCAL en cours (00h30 = déjà « demain » localement). Sans ‘localtime’, strftime travaille en UTC : à Paris (UTC+1/+2), une session de 00h30 serait comptée sur le jour précédent — une grille d’activité qui « rate » des jours et casse les streaks perçus. Les dates affichées à un humain se calculent dans SON fuseau.

🧠 Quiz

1. Pourquoi CAST(… AS REAL) dans le calcul d’accuracy ?

Réponse

SUM(correct_count)/SUM(total) entre INTEGER fait une division entière (résultat 0). Le CAST force le flottant. Et NULLIF évite la division par zéro sur table vide.

2. Pourquoi staleTime: Infinity sur les stats locales ?

Réponse

Elles ne changent QUE par nos propres écritures : l’invalidation explicite (onSuccess de la mutation) est le seul rafraîchissement nécessaire — zéro re-requête spontanée.

3. Quel est l’ordre correct pour détecter un nouveau record ?

Réponse

Lire l’ancien best → comparer avec le score courant → puis insérer la session. Inverser fait de chaque session son propre record.

4. Où se calculent les agrégats, et pourquoi pas en JS ?

Réponse

Dans SQLite (GROUP BY/MAX/AVG sur colonnes indexées) : instantané à toute échelle. En JS il faudrait charger toutes les lignes pour les réduire — linéairement plus lent et plus gourmand.

5. Que remplace l’invalidation par rapport au chapitre 6.1 ?

Réponse

Le refetch au focus (useFocusEffect) : au lieu de recharger « au cas où » à chaque visite, les écrans se rafraîchissent précisément quand une mutation invalide leurs clés.


👉 Chapitre suivant : 8.4 — Drizzle ORM — le confort typé, et quand il vaut son coût.