Soumission & Progression

Optimistic UI · React Query Mutations · Recharts

Soumettre un exercice, visualiser sa progression, gamifier l'apprentissage

Objectifs de la leçon

1. Cycle de vie d'une soumission

Comprendre les états : brouillon → soumise → en review → notée

2. Optimistic UI

Mettre à jour l'interface AVANT la confirmation serveur

3. Recharts

Bar chart, line chart, radial chart — API déclarative et responsive

4. Page de progression

Streaks, badges, graphique d'activité — la gamification qui motive

Plan du cours

1. Cycle de vie d'une soumission

brouillon → soumise → en review → notée

2. Formulaire de soumission

Lien PR GitHub, commentaire, validation

3. Optimistic UI

onMutate + onError + onSettled avec React Query

4. Data Visualization Recharts

Bar, Line, RadialBar charts

5. Page progression & gamification

Streaks, badges, graphique d'activité hebdomadaire

Module 1

Cycle de vie d'une soumission

Une soumission n'est pas binaire — elle évolue dans le temps

Les 4 états d'une soumission

Chaque état a sa couleur, son icône et son message.

✏️

BROUILLON

En cours de rédaction, pas encore envoyée

draft

📤

SOUMISE

Envoyée, en attente de prise en charge

submitted

🔍

EN REVIEW

Un formateur la regarde en ce moment

reviewing

NOTÉE

Correction disponible + score attribué

graded

Type TypeScript d'une soumission

Modéliser le domaine avant de coder les composants.

type SubmissionStatus = 'draft' | 'submitted' | 'reviewing' | 'graded';

interface Submission {
  id: string;
  exerciseId: string;
  studentId: string;
  githubPrUrl: string;
  comment?: string;
  status: SubmissionStatus;
  score?: number;           // null tant que status !== 'graded'
  feedback?: string;        // commentaire du formateur
  submittedAt?: string;     // ISO date string
  gradedAt?: string;
}

Règle métier

score et feedback ne sont disponibles que quand status === 'graded'

Transitions autorisées

draft → submitted → reviewing → graded (jamais en arrière côté étudiant)

Badges de statut avec des couleurs

Un composant StatusBadge centralisé pour tous les états.

const STATUS_CONFIG = {
  draft:      { label: 'Brouillon', className: 'bg-slate-700 text-slate-300' },
  submitted:  { label: 'Soumise',   className: 'bg-blue-900/50 text-blue-300' },
  reviewing:  { label: 'En review', className: 'bg-yellow-900/50 text-yellow-300' },
  graded:     { label: 'Notée',     className: 'bg-green-900/50 text-green-300' },
} as const;

function StatusBadge({ status }: { status: SubmissionStatus }) {
  const { label, className } = STATUS_CONFIG[status];
  return (
    <span className={`px-3 py-1 rounded-full text-sm font-medium ${className}`}>
      {label}
    </span>
  );
}

Principe clé

Centraliser la configuration des états dans un objet évite les if/else éparpillés dans le code.

Module 2

Formulaire de soumission

Lien PR GitHub · Commentaire · Validation

Structure du formulaire

2 champs : lien PR GitHub (obligatoire) + commentaire (optionnel).

interface SubmissionFormData {
  githubPrUrl: string;
  comment: string;
}

function SubmissionForm({ exerciseId }: { exerciseId: string }) {
  const { register, handleSubmit, formState: { errors } } = useForm<SubmissionFormData>();
  const mutation = useSubmitExercise(exerciseId);

  const onSubmit = (data: SubmissionFormData) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <input
        {...register('githubPrUrl', {
          required: 'Le lien PR est obligatoire',
          pattern: {
            value: /^https:\/\/github\.com\/.+\/pull\/\d+$/,
            message: 'URL GitHub PR invalide',
          },
        })}
        placeholder="https://github.com/user/repo/pull/42"
        className="w-full bg-slate-800 border border-slate-600 rounded px-4 py-2 text-white"
      />
      {errors.githubPrUrl && (
        <p className="text-red-400 text-sm">{errors.githubPrUrl.message}</p>
      )}
    </form>
  );
}

Validation du lien GitHub PR

✗ URLs invalides

https://github.com/user/repo

https://github.com/user/repo/issues/5

http://github.com/user/repo/pull/1

https://gitlab.com/user/repo/pull/1

✓ URLs valides

https://github.com/user/repo/pull/42

https://github.com/bootcamp/exo/pull/1

https://github.com/org/proj/pull/999

// Regex de validation

/^https:\/\/github\.com\/.+\/pull\/\d+$/

Module 3

Optimistic UI

Mettre à jour l'interface AVANT la confirmation du serveur

Qu'est-ce que l'Optimistic UI ?

Sans Optimistic UI

1.Clic sur "Soumettre"
2.Attente spinner... ⏳
3.Toujours en attente... ⏳
4.Réponse serveur → UI mise à jour

L'utilisateur attend 300-800ms sans feedback visuel

Avec Optimistic UI

1.Clic sur "Soumettre"
2.UI mise à jour immédiatement ✨
3.Requête serveur en arrière-plan
4.Confirmation ou rollback

L'interface semble instantanée — 0ms de latence perçue

Les 3 callbacks de React Query

onMutate

Déclenché avant la requête. Met à jour le cache optimistement. Retourne un contexte pour le rollback.

onError

Si la requête échoue. Restaure le cache avec le contexte de rollback sauvegardé dans onMutate.

onSettled

Toujours déclenché (succès OU erreur). Invalide la query pour re-synchroniser avec le serveur.

Mnémotechnique

onMutate = "Je parie que ça va marcher" → onError = "J'avais tort, je corrige" → onSettled = "On vérifie quoi qu'il arrive"

Implémentation complète

function useSubmitExercise(exerciseId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: SubmissionFormData) =>
      api.post(`/exercises/${exerciseId}/submit`, data),

    onMutate: async (newData) => {
      // 1. Annule les refetch en cours (évite les collisions)
      await queryClient.cancelQueries({ queryKey: ['submissions', exerciseId] });

      // 2. Snapshot de l'état actuel (pour rollback)
      const previousSubmissions = queryClient.getQueryData(['submissions', exerciseId]);

      // 3. Mise à jour optimiste du cache
      queryClient.setQueryData(['submissions', exerciseId], (old: Submission[]) => [
        ...old,
        { id: 'temp-' + Date.now(), status: 'submitted', ...newData },
      ]);

      return { previousSubmissions }; // contexte de rollback
    },

    onError: (_err, _newData, context) => {
      // Rollback si erreur
      queryClient.setQueryData(['submissions', exerciseId], context?.previousSubmissions);
    },

    onSettled: () => {
      // Toujours re-synchroniser avec le serveur
      queryClient.invalidateQueries({ queryKey: ['submissions', exerciseId] });
    },
  });
}

Optimistic UI pour un toggle "like"

Commencer par le cas simple — toggle d'un like — avant de passer aux mutations complexes.

function useLikeToggle(submissionId: string) {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: () => api.post(`/submissions/${submissionId}/like`),

    onMutate: async () => {
      await queryClient.cancelQueries({ queryKey: ['submission', submissionId] });
      const prev = queryClient.getQueryData<Submission>(['submission', submissionId]);

      // Toggle optimiste : si liked → unlike, sinon → like
      queryClient.setQueryData(['submission', submissionId], (old: Submission) => ({
        ...old,
        isLiked: !old.isLiked,
        likesCount: old.isLiked ? old.likesCount - 1 : old.likesCount + 1,
      }));

      return { prev };
    },

    onError: (_err, _vars, context) => {
      queryClient.setQueryData(['submission', submissionId], context?.prev);
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['submission', submissionId] });
    },
  });
}

Module 4

Data Visualization avec Recharts

Bar · Line · RadialBar — API déclarative, composants React natifs

Pourquoi Recharts ?

Déclaratif

Des composants React comme <BarChart>, <Line> — pas de configuration JSON complexe

Responsive

<ResponsiveContainer> adapte le graphique à la taille du conteneur parent automatiquement

SVG natif

Rendu SVG performant, personnalisable avec des classes CSS ou des props inline

# Installation

npm install recharts

Bar Chart — soumissions par semaine

import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';

const weeklyData = [
  { week: 'S1', submissions: 3 },
  { week: 'S2', submissions: 5 },
  { week: 'S3', submissions: 2 },
  { week: 'S4', submissions: 7 },
  { week: 'S5', submissions: 4 },
];

function WeeklySubmissionsChart() {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={weeklyData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
        <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
        <XAxis dataKey="week" stroke="#9ca3af" />
        <YAxis stroke="#9ca3af" />
        <Tooltip
          contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #475569' }}
          labelStyle={{ color: '#f8fafc' }}
        />
        <Bar dataKey="submissions" fill="#818cf8" radius={[4, 4, 0, 0]} />
      </BarChart>
    </ResponsiveContainer>
  );
}

Line Chart — évolution du score dans le temps

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';

const scoreHistory = [
  { date: '01/03', score: 65 },
  { date: '08/03', score: 72 },
  { date: '15/03', score: 68 },
  { date: '22/03', score: 81 },
  { date: '29/03', score: 88 },
];

function ScoreEvolutionChart() {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <LineChart data={scoreHistory}>
        <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
        <XAxis dataKey="date" stroke="#9ca3af" />
        <YAxis domain={[0, 100]} stroke="#9ca3af" />
        <Tooltip
          contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #475569' }}
        />
        <Line
          type="monotone"
          dataKey="score"
          stroke="#34d399"
          strokeWidth={2}
          dot={{ fill: '#34d399', strokeWidth: 2 }}
          activeDot={{ r: 6 }}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

Radial Bar Chart — taux de complétion

import { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } from 'recharts';

function CompletionRadialChart({ percentage }: { percentage: number }) {
  const data = [{ value: percentage, fill: '#818cf8' }];

  return (
    <div className="relative">
      <ResponsiveContainer width="100%" height={200}>
        <RadialBarChart
          innerRadius="70%"
          outerRadius="100%"
          data={data}
          startAngle={90}
          endAngle={-270}
        >
          <PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
          <RadialBar dataKey="value" cornerRadius={10} background={{ fill: '#1e293b' }} />
        </RadialBarChart>
      </ResponsiveContainer>
      {/* Valeur centrale en overlay */}
      <div className="absolute inset-0 flex items-center justify-center">
        <span className="text-3xl font-bold text-white">{percentage}%</span>
      </div>
    </div>
  );
}

Anatomie d'un graphique Recharts

ResponsiveContainer

Wrapper obligatoire — adapte width/height automatiquement. Toujours utiliser avec width="100%"

BarChart / LineChart

Le conteneur du graphique. Prend data (tableau d'objets) et margin en props

XAxis / YAxis

dataKey sur XAxis spécifie quelle propriété des données mapper sur l'axe horizontal

Bar / Line

dataKey spécifie quelle valeur afficher, fill / stroke pour la couleur

Tooltip

contentStyle pour personnaliser le fond sombre — indispensable sur thème dark

Module 5

Page de progression & Gamification

Streaks · Badges · Graphique d'activité

Structure de la page progression

🔥

Streak actuel

Jours consécutifs avec au moins une soumission

🏆

Badges débloqués

Jalons atteints : 1ère soumission, 10 exercices, etc.

📊

Graphique activité

Heatmap ou bar chart des 7 derniers jours

Radial Chart

% complétion des exercices de la semaine

Score moyen

Évolution du score sur les 5 dernières semaines

Composant StreakBadge

interface StreakData {
  currentStreak: number;
  longestStreak: number;
  lastActivityDate: string;
}

function StreakBadge({ streak }: { streak: StreakData }) {
  const isActive = streak.currentStreak > 0;

  return (
    <div className={`p-4 rounded-xl border-2 ${
      isActive ? 'border-orange-500 bg-orange-900/20' : 'border-slate-700 bg-slate-800'
    }`}>
      <div className="flex items-center gap-3">
        <span className="text-4xl">{isActive ? '🔥' : '❄️'}</span>
        <div>
          <p className={`text-3xl font-bold ${isActive ? 'text-orange-400' : 'text-slate-500'}`}>
            {streak.currentStreak} jour{streak.currentStreak > 1 ? 's' : ''}
          </p>
          <p className="text-slate-400 text-sm">
            Record : {streak.longestStreak} jours
          </p>
        </div>
      </div>
    </div>
  );
}

Système de badges — définition

interface Badge {
  id: string;
  name: string;
  description: string;
  icon: string;
  condition: (stats: StudentStats) => boolean;
  unlockedAt?: string;
}

const BADGES: Badge[] = [
  {
    id: 'first-submission',
    name: 'Premier pas',
    description: 'Soumettre son premier exercice',
    icon: '🚀',
    condition: (stats) => stats.totalSubmissions >= 1,
  },
  {
    id: 'week-streak',
    name: 'Une semaine de feu',
    description: '7 jours consécutifs avec une soumission',
    icon: '🔥',
    condition: (stats) => stats.longestStreak >= 7,
  },
  {
    id: 'perfect-score',
    name: 'Perfectionniste',
    description: 'Obtenir 100/100 sur un exercice',
    icon: '',
    condition: (stats) => stats.highestScore >= 100,
  },
];

Affichage des badges avec état

function BadgeGrid({ badges, unlockedIds }: { badges: Badge[], unlockedIds: string[] }) {
  return (
    <div className="grid grid-cols-3 gap-4">
      {badges.map((badge) => {
        const isUnlocked = unlockedIds.includes(badge.id);
        return (
          <div
            key={badge.id}
            className={`p-4 rounded-lg text-center transition-all ${
              isUnlocked
                ? 'bg-yellow-900/30 border-2 border-yellow-500'
                : 'bg-slate-800 border-2 border-slate-700 opacity-40 grayscale'
            }`}
          >
            <span className="text-3xl">{badge.icon}</span>
            <p className={`font-semibold mt-2 text-sm ${isUnlocked ? 'text-yellow-300' : 'text-slate-500'}`}>
              {badge.name}
            </p>
            <p className="text-slate-500 text-xs mt-1">{badge.description}</p>
          </div>
        );
      })}
    </div>
  );
}

UX : Les badges non débloqués sont affichés en grisé avec grayscale + opacity-40 — l'utilisateur voit ce qu'il peut obtenir.

Streaks : calcul côté serveur

⚠️ Ne jamais calculer les streaks côté client

• Le client ne connaît pas toutes les soumissions passées (pagination)

• Les fuseaux horaires varient selon l'utilisateur

• Un utilisateur malveillant peut modifier l'heure du navigateur

• La logique de streak est complexe (minuit, jours fériés, etc.)

✓ L'API retourne directement les valeurs calculées

// Réponse API /api/me/stats

{"{ currentStreak: 5, longestStreak: 12, lastActivityDate: '2024-03-22' }"}

Pièges courants

Les erreurs à éviter absolument

Piège 1 — Oublier le rollback

✗ Sans rollback

useMutation({
  mutationFn: submitExercise,
  onMutate: async (data) => {
    // Mise à jour optimiste
    queryClient.setQueryData(['submissions'], 
      (old) => [...old, { ...data, status: 'submitted' }]
    );
    // ❌ Pas de rollback — si erreur :
    // l'UI affiche "soumise" alors que ça a échoué !
  },
})

✓ Avec rollback

useMutation({
  mutationFn: submitExercise,
  onMutate: async (data) => {
    const prev = queryClient.getQueryData(['submissions']);
    queryClient.setQueryData(['submissions'],
      (old) => [...old, { ...data, status: 'submitted' }]
    );
    return { prev }; // ✅ Snapshot sauvegardé
  },
  onError: (_err, _data, context) => {
    queryClient.setQueryData(
      ['submissions'], context?.prev
    ); // ✅ Restauration
  },
})

Piège 2 — Trop de graphiques sur une page

✗ Tout charger d'un coup

• 5+ graphiques Recharts sur la même page

• Tous rendus même si hors viewport

• Premier paint lent, TTI dégradé

• Pas d'import() dynamique

✓ Lazy loading des sections

// Charger le composant graphique seulement
// quand il est visible dans le viewport
const ActivityChart = lazy(
  () => import('./ActivityChart')
);

function ProgressPage() {
  return (
    <Suspense fallback={<ChartSkeleton />}>
      <ActivityChart />
    </Suspense>
  );
}

Piège 3 — Optimistic UI sur des cas complexes

⚠️ L'optimistic UI est délicate à bien faire

Commencer par des cas simples. Complexifier progressivement.

Cas simple

Toggle like / bookmark — 1 champ booléen

Cas intermédiaire

Ajout d'item dans une liste — ID temporaire

Cas complexe

Mutation avec ID serveur, relations imbriquées

Conseil : Si le rollback est trop complexe à implémenter, utiliser un spinner classique. L'optimistic UI doit améliorer l'UX, pas créer des bugs.

Points clés à retenir

🔄 Lifecycle

Une soumission a 4 états. Afficher clairement le statut avec des badges colorés dans STATUS_CONFIG

⚡ Optimistic UI

onMutate → UI immédiate + snapshot, onError → rollback, onSettled → invalidate

📊 Recharts

Toujours wrapper dans ResponsiveContainer. Personnaliser le Tooltip pour le thème dark.

🎮 Gamification

Streaks calculés côté serveur. Badges grisés = motivation. La progression visible = rétention.

Commencer par le toggle "like" pour maîtriser l'optimistic UI, puis monter en complexité.