2026-02-01 09:31:38 +01:00

34 KiB

User Story: Dashboard & Consultation des Prédictions

ID : US-002 Epic : Epic 5 - Dashboard & Core Visualizations + Epic 3 - Prediction System Status : Draft Priorité : P0 - Critique (c'est le cœur de l'application)


📋 Description

En tant qu'utilisateur connecté, je veux consulter les prédictions de matchs sur un dashboard clair et intuitif avec des indicateurs de confiance pour pouvoir prendre des décisions éclairées.

🎯 Objectifs d'Utilisateur

  1. Voir une liste de matchs à venir avec leurs prédictions
  2. Comprendre le niveau de confiance de chaque prédiction (Confidence Meter)
  3. Voir mon classement personnel et les statistiques globales
  4. Accéder aux détails de chaque match (équipes, date, ligue)
  5. Récupérer l'historique de mes prédictions consultées

Critères de Succès

Scénario 1 : Consultation du Dashboard Principal

Given : Je suis un utilisateur connecté sur le dashboard principal http://localhost:3000/dashboard When : La page se charge Then :

  • Je vois 3 cartes de statistiques personnelles en haut :
    • "Votre Classement : #42"
    • "Précision : 65%"
    • "Prédictions : 15"
  • Je vois une carte "Prédictions Récentes" avec les 5 derniers matchs
  • Chaque match affiche : équipes, date/heure, ligue, Confidence Meter
  • Le Confidence Meter utilise le bon code couleur :
    • Vert (>70%) : Match très fiable
    • Jaune (50-70%) : Match moyennement fiable
    • Rouge (<50%) : Match peu fiable

Scénario 2 : Filtrage des Prédictions

Given : Je suis sur le dashboard avec plusieurs prédictions When : Je clique sur "Filtrer" Then :

  • Je peux filtrer par ligue (ex: Ligue 1, Premier League)
  • Je peux filtrer par date (ex: Aujourd'hui, Demain, Cette semaine)
  • Je peux filtrer par niveau de confiance (ex: >70% seulement)
  • Les résultats se mettent à jour en temps réel sans rechargement de page

Scénario 3 : Détails d'une Prédiction

Given : Je clique sur un match dans la liste When : La page de détails s'ouvre Then :

  • Je vois les informations complètes du match :
    • Équipes : PSG vs Marseille
    • Date et heure : 2026-01-18 20:00
    • Ligue : Ligue 1
    • Statut : Scheduled / In Progress / Completed
  • Je vois les détails de la prédiction :
    • Vainqueur prédit : PSG
    • Niveau de confiance : 70%
    • Score d'énergie collective : Haute (72/68)
  • Je vois l'évolution de l'énergie sur 24h (graphique)

Scénario 4 : Historique Personnel

Given : Je clique sur l'onglet "Historique" When : La page d'historique s'affiche Then :

  • Je vois la liste chronologique de toutes mes prédictions consultées
  • Chaque prédiction indique :
    • Date de consultation
    • Match consulté
    • Résultat (si disponible) : Correct / Incorrect
  • Je vois mon taux de précision personnel global
  • Je vois mon ROI (Return on Investment) si applicable

Scénario 5 : Classement Gamifié

Given : Je clique sur l'onglet "Classement" When : La page de classement s'affiche Then :

  • Je vois le Top 100 utilisateurs classés par précision
  • Mon rang personnel est mis en évidence si je suis dans le Top 100
  • Chaque utilisateur affiche :
    • Rang
    • Nom d'utilisateur (ou anonyme)
    • Taux de précision (ex: 85%)
    • Nombre de prédictions (ex: 100)

📱 Composants Frontend Requis

Page Dashboard Principal (src/app/dashboard/page.tsx)

"use client";

import { useEffect, useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { usePredictions } from '@/hooks/usePredictions';
import { useLeaderboard } from '@/hooks/useLeaderboard';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

export default function DashboardPage() {
  const { user } = useAuth();
  const { predictions, isLoading, error } = usePredictions();
  const { personalRank, topUsers } = useLeaderboard();
  const [filter, setFilter] = useState({ league: 'all', confidence: 'all' });

  // Chargement initial
  useEffect(() => {
    if (!user) return;
    
    // Charger les données du dashboard
    // usePredictions et useLeaderboard feront les appels API
  }, [user]);

  if (isLoading) {
    return <DashboardSkeleton />;
  }

  if (error) {
    return <ErrorState message={error} />;
  }

  // Filtrer les prédictions
  const filteredPredictions = predictions.filter(p => {
    if (filter.league !== 'all' && p.match.league !== filter.league) return false;
    if (filter.confidence !== 'all') {
      const confidenceValue = parseFloat(p.confidence);
      if (filter.confidence === 'high' && confidenceValue < 70) return false;
      if (filter.confidence === 'medium' && (confidenceValue < 50 || confidenceValue >= 70)) return false;
      if (filter.confidence === 'low' && confidenceValue >= 50) return false;
    }
    }
    return true;
  });

  return (
    <div className="container mx-auto px-4 py-8">
      {/* En-tête utilisateur */}
      <div className="mb-8">
        <h1 className="text-3xl font-bold">
          Bonjour, {user?.name || 'Sportif'} ! 👋
        </h1>
        <p className="text-muted-foreground">
          Bienvenue sur votre dashboard Chartbastan
        </p>
      </div>

      {/* Statistiques personnelles */}
      {personalRank && (
        <PersonalStatsCard personalRank={personalRank} />
      )}

      {/* Filtres */}
      <FilterBar filter={filter} setFilter={setFilter} />

      {/* Liste des prédictions */}
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {filteredPredictions.map(prediction => (
          <PredictionCard key={prediction.id} prediction={prediction} />
        ))}
      </div>

      {/* Top Classement (aperçu) */}
      <TopLeaderboardPreview users={topUsers.slice(0, 5)} />
    </div>
  );
}

// Composant de squelette de chargement
function DashboardSkeleton() {
  return (
    <div className="space-y-6">
      {/* Statistiques personnelles */}
      <div className="grid gap-6 md:grid-cols-3">
        {[1, 2, 3].map(i => (
          <Skeleton key={i} className="h-32 w-full" />
        ))}
      </div>
      
      {/* Filtres */}
      <Skeleton className="h-16 w-full" />
      
      {/* Cartes de prédictions */}
      <div className="grid gap-6 md:grid-cols-3">
        {[1, 2, 3, 4, 5, 6].map(i => (
          <Skeleton key={i} className="h-48 w-full" />
        ))}
      </div>
    </div>
  );
}

// Composant d'état d'erreur
function ErrorState({ message }: { message: string }) {
  return (
    <div className="p-8">
      <div className="bg-destructive/10 border border-destructive text-destructive p-4 rounded-lg">
        <p className="font-medium">{message}</p>
        <p className="text-sm mt-2">
          Veuillez rafraîchir la page ou réessayer ultérieurement.
        </p>
      </div>
    </div>
  );
}

Composant Carte de Statistiques Personnelles

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

interface PersonalStatsCardProps {
  personalRank: {
    rank: number;
    accuracy: number;
    predictions_count: number;
  };
}

export function PersonalStatsCard({ personalRank }: PersonalStatsCardProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle>📊 Vos Statistiques</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="grid gap-6 md:grid-cols-3">
          {/* Classement */}
          <div className="text-center p-4 rounded-lg bg-primary/10">
            <p className="text-sm text-muted-foreground">Votre Classement</p>
            <p className="text-4xl font-bold">#{personalRank.rank}</p>
          </div>

          {/* Précision */}
          <div className="text-center p-4 rounded-lg bg-muted">
            <p className="text-sm text-muted-foreground">Précision</p>
            <p className="text-4xl font-bold">{personalRank.accuracy}%</p>
          </div>

          {/* Prédictions */}
          <div className="text-center p-4 rounded-lg bg-secondary">
            <p className="text-sm text-muted-foreground">Prédictions</p>
            <p className="text-4xl font-bold">{personalRank.predictions_count}</p>
          </div>
        </div>
      </CardContent>
    </Card>
  );
}

Composant Barre de Filtres

import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';

interface FilterBarProps {
  filter: { league: string; confidence: string };
  setFilter: (filter: { league: string; confidence: string }) => void;
}

export function FilterBar({ filter, setFilter }: FilterBarProps) {
  return (
    <Card>
      <CardContent className="flex gap-4 items-center">
        <div className="flex-1">
          <label className="text-sm font-medium">Ligue</label>
          <Select value={filter.league} onValueChange={(value) => setFilter({ ...filter, league: value })}>
            <SelectTrigger className="w-full">
              <SelectValue placeholder="Toutes les ligues" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">Toutes les ligues</SelectItem>
              <SelectItem value="ligue1">Ligue 1</SelectItem>
              <SelectItem value="ligue1">Premier League</SelectItem>
              <SelectItem value="laliga">La Liga</SelectItem>
              <SelectItem value="bundesliga">Bundesliga</SelectItem>
            </SelectContent>
          </Select>
        </div>

        <div className="flex-1">
          <label className="text-sm font-medium">Confiance</label>
          <Select value={filter.confidence} onValueChange={(value) => setFilter({ ...filter, confidence: value })}>
            <SelectTrigger className="w-full">
              <SelectValue placeholder="Tous les niveaux" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">Tous les niveaux</SelectItem>
              <SelectItem value="high">Élevée (>70%)</SelectItem>
              <SelectItem value="medium">Moyenne (50-70%)</SelectItem>
              <SelectItem value="low">Faible (<50%)</SelectItem>
            </SelectContent>
          </Select>
        </div>

        <Button variant="outline" onClick={() => setFilter({ league: 'all', confidence: 'all' })}>
          Réinitialiser
        </Button>
      </CardContent>
    </Card>
  );
}

Composant Carte de Prédiction

import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { useNavigate } from 'react-router-dom';
import Link from 'next/link';

interface PredictionCardProps {
  prediction: {
    id: number;
    match_id: number;
    match: {
      id: number;
      home_team: string;
      away_team: string;
      date: string;
      league: string;
      status: string;
    };
    energy_score: string;
    confidence: string;
    predicted_winner: string;
    created_at: string;
  };
}

export function PredictionCard({ prediction }: PredictionCardProps) {
  const navigate = useNavigate();
  
  // Extraire la valeur numérique de confiance
  const confidenceValue = parseFloat(prediction.confidence);
  
  // Déterminer le niveau de confiance pour le code couleur
  const getConfidenceLevel = () => {
    if (confidenceValue >= 70) return 'high';
    if (confidenceValue >= 50) return 'medium';
    return 'low';
  };

  const getConfidenceColor = () => {
    if (confidenceValue >= 70) return 'bg-green-500 text-white';
    if (confidenceValue >= 50) return 'bg-yellow-500 text-black';
    return 'bg-red-500 text-white';
  };

  const getConfidenceEmoji = () => {
    if (confidenceValue >= 70) return '🟢';
    if (confidenceValue >= 50) return '🟡';
    return '🔴';
  };

  const formatDate = (dateString: string) => {
    const date = new Date(dateString);
    return date.toLocaleDateString('fr-FR', {
      weekday: 'short',
      day: 'numeric',
      month: 'short',
      hour: '2-digit',
      minute: '2-digit'
    });
  };

  return (
    <Card className="hover:shadow-lg transition-shadow">
      <CardHeader>
        <div className="flex items-start justify-between">
          <div>
            <CardTitle className="text-lg">
              {prediction.match.home_team} vs {prediction.match.away_team}
            </CardTitle>
            <CardDescription className="mt-1">
              <div className="flex items-center gap-2 text-sm">
                <span>{prediction.match.league}</span>
                <span></span>
                <span>{formatDate(prediction.match.date)}</span>
                <Badge variant="secondary">{prediction.match.status}</Badge>
              </div>
            </CardDescription>
          </div>
          <div className={`text-3xl font-bold px-3 py-1 rounded-full ${getConfidenceColor()}`}>
            {getConfidenceEmoji()}
          </div>
        </div>
      </CardHeader>
      
      <CardContent>
        <div className="space-y-4">
          {/* Vainqueur prédit */}
          <div className="flex items-center justify-between">
            <div>
              <p className="text-sm text-muted-foreground">Vainqueur prédit</p>
              <p className="text-2xl font-bold">{prediction.predicted_winner}</p>
            </div>
            <ConfidenceMeter confidence={prediction.confidence} />
          </div>
          
          {/* Score d'énergie */}
          <div>
            <p className="text-sm text-muted-foreground">Score d'énergie collective</p>
            <div className="flex items-center gap-2 mt-2">
              <Badge variant="outline">{prediction.match.home_team}</Badge>
              <div className="flex-1 bg-secondary h-2 rounded overflow-hidden">
                <div 
                  className="h-full bg-primary transition-all duration-500"
                  style={{ width: `${confidenceValue}%` }}
                ></div>
              </div>
              <Badge variant="outline">{prediction.match.away_team}</Badge>
              <div className="flex-1 bg-secondary h-2 rounded overflow-hidden">
                <div 
                  className="h-full bg-secondary" 
                  style={{ width: `${100 - confidenceValue}%` }}
                ></div>
              </div>
            </div>
          </div>
        </div>
      </CardContent>
      
      <CardFooter>
        <div className="flex justify-between items-center">
          <Button variant="outline" size="sm" asChild>
            <Link href={`/predictions/${prediction.id}`}>
              Voir détails
            </Link>
          </Button>
          <span className="text-xs text-muted-foreground">
            {new Date(prediction.created_at).toLocaleTimeString('fr-FR', {
              hour: '2-digit',
              minute: '2-digit'
            })}
          </span>
        </div>
      </CardFooter>
    </Card>
  );
}

Composant Confidence Meter

import { Card, CardContent } from '@/components/ui/card';

interface ConfidenceMeterProps {
  confidence: string; // Ex: "70.5%"
}

export function ConfidenceMeter({ confidence }: ConfidenceMeterProps) {
  const value = parseFloat(confidence);
  
  const getLevel = () => {
    if (value >= 70) return { level: 'Élevée', color: 'text-green-600', bg: 'bg-green-100' };
    if (value >= 50) return { level: 'Moyenne', color: 'text-yellow-600', bg: 'bg-yellow-100' };
    return { level: 'Faible', color: 'text-red-600', bg: 'bg-red-100' };
  };

  const level = getLevel();

  return (
    <div className="flex items-center gap-2">
      <span className="text-sm text-muted-foreground">Confiance :</span>
      <div className="relative w-32 h-2 bg-gray-200 rounded-full overflow-hidden">
        <div 
          className={`absolute left-0 top-0 h-full transition-all duration-500 ${level.bg}`}
          style={{ width: `${value}%` }}
        ></div>
      </div>
      <span className={`text-lg font-bold ${level.color}`}>
        {confidence}
      </span>
      <Badge variant="secondary" className="ml-2">
        {level.level}
      </Badge>
    </div>
  );
}

Hook usePredictions

// src/hooks/usePredictions.ts
import { useState, useEffect } from 'react';
import { apiRequest } from '@/lib/api';

export interface Prediction {
  id: number;
  match_id: number;
  match: {
    id: number;
    home_team: string;
    away_team: string;
    date: string;
    league: string;
    status: string;
  };
  energy_score: string;
  confidence: string;
  predicted_winner: string;
  created_at: string;
}

export interface PredictionsResponse {
  data: Prediction[];
  meta: {
    total: number;
    limit: number;
    offset: number;
    timestamp: string;
    version: string;
  };
}

export function usePredictions() {
  const [state, setState] = useState<{
    predictions: Prediction[];
    isLoading: boolean;
    error: string | null;
    meta: PredictionsResponse['meta'] | null;
  }>({
    predictions: [],
    isLoading: false,
    error: null,
    meta: null
  });

  const fetchPredictions = async () => {
    setState(prev => ({ ...prev, isLoading: true, error: null }));

    try {
      const response = await apiRequest('/api/v1/predictions?limit=20&offset=0');
      
      if (!response.ok) {
        throw new Error('Erreur lors du chargement des prédictions');
      }

      const data: PredictionsResponse = await response.json();

      setState({
        predictions: data.data,
        isLoading: false,
        error: null,
        meta: data.meta
      });

      return data.data;
    } catch (error) {
      setState(prev => ({
        ...prev,
        isLoading: false,
        error: error instanceof Error ? error.message : 'Erreur inconnue'
      }));
      return [];
    }
  };

  // Charger au montage
  useEffect(() => {
    fetchPredictions();
  }, []);

  return {
    ...state,
    fetchPredictions, // Exposer pour rafraîchissement manuel
    refresh: fetchPredictions
  };
}

🔌 API Backend Requis

Endpoints à Créer dans backend/app/api/v1/predictions.py

"""
Enhancement des endpoints de prédictions avec statistiques personnelles et optimisations.
"""

from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session

from app.database import get_db
from app.services.prediction_service import PredictionService
from app.services.leaderboard_service import LeaderboardService
from app.schemas.prediction import PredictionListResponse, PredictionResponse

router = APIRouter(prefix="/api/v1/predictions", tags=["predictions"])

# Service instances
prediction_service = PredictionService
leaderboard_service = LeaderboardService

@router.get("", response_model=PredictionListResponse)
def get_predictions(
    limit: int = Query(20, ge=1, le=100, description="Maximum 20 prédictions par défaut"),
    offset: int = Query(0, ge=0, description="Pagination offset"),
    league_filter: Optional[str] = Query(None, description="Filtre par ligue"),
    confidence_filter: Optional[str] = Query(None, description="Filtre par niveau de confiance"),
    db: Session = Depends(get_db)
):
    """
    Récupérer les prédictions avec filtres avancés.
    
    Améliorations:
    - Filtre par ligue
    - Filtre par niveau de confiance (high/medium/low)
    - Optimisation des requêtes avec indexes
    - Tri par date de match (matchs à venir d'abord)
    """
    # Récupérer les prédictions
    predictions, total = prediction_service.get_predictions_with_pagination(
        limit=limit,
        offset=offset,
        league=league_filter
    )
    
    # Filtrer par niveau de confiance côté backend pour optimiser
    if confidence_filter:
        filtered_predictions = []
        for prediction in predictions:
            conf_value = float(prediction.confidence.rstrip('%'))
            
            if confidence_filter == 'high' and conf_value < 70:
                continue
            elif confidence_filter == 'medium' and (conf_value < 50 or conf_value >= 70):
                continue
            elif confidence_filter == 'low' and conf_value >= 50:
                continue
                
            filtered_predictions.append(prediction)
        
        predictions = filtered_predictions
        total = len(filtered_predictions)
    
    # Construire la réponse
    prediction_responses = []
    for prediction in predictions:
        match = prediction.match
        prediction_responses.append({
            "id": prediction.id,
            "match_id": prediction.match_id,
            "match": {
                "id": match.id,
                "home_team": match.home_team,
                "away_team": match.away_team,
                "date": match.date.isoformat() if match.date else None,
                "league": match.league,
                "status": match.status,
                "actual_winner": match.actual_winner
            },
            "energy_score": prediction.energy_score,
            "confidence": prediction.confidence,
            "predicted_winner": prediction.predicted_winner,
            "created_at": prediction.created_at.isoformat() if prediction.created_at else None
        })
    
    return {
        "data": prediction_responses,
        "meta": {
            "total": total,
            "limit": limit,
            "offset": offset,
            "timestamp": datetime.utcnow().isoformat() + "Z",
            "version": "v1"
        }
    }

@router.get("/{prediction_id}", response_model=PredictionResponse)
def get_prediction_details(
    prediction_id: int,
    db: Session = Depends(get_db)
):
    """
    Récupérer les détails complets d'une prédiction avec historique.
    
    Améliorations:
    - Inclut l'historique des prédictions pour ce match
    - Inclut les scores d'énergie détaillés
    """
    prediction = prediction_service.get_prediction_with_details(prediction_id)
    
    if not prediction:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Prédiction {prediction_id} non trouvée"
        )
    
    return prediction

@router.post("/regenerate/{prediction_id}", status_code=status.HTTP_200_OK)
def regenerate_prediction(
    prediction_id: int,
    db: Session = Depends(get_db)
):
    """
    Régénérer une prédiction pour un match existant.
    
    Cette fonctionnalité permet de recalculer une prédiction si de nouvelles
    données d'énergie sont disponibles.
    """
    prediction = prediction_service.get_prediction_by_id(prediction_id)
    
    if not prediction:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Prédiction {prediction_id} non trouvée"
        )
    
    # Récupérer les scores d'énergie actuels pour ce match
    # TODO: Récupérer depuis energy_scores table
    # Pour l'instant, on simule
    home_energy = 65.0  # Exemple
    away_energy = 45.0  # Exemple
    
    # Créer une nouvelle prédiction
    new_prediction = prediction_service.create_prediction_for_match(
        match_id=prediction.match_id,
        home_energy=home_energy,
        away_energy=away_energy
    )
    
    # Archiver l'ancienne prédiction
    # TODO: Marquer comme archivée
    
    return {
        "message": "Prédiction régénérée avec succès",
        "prediction": new_prediction.to_dict(),
        "previous_prediction": prediction.to_dict()
    }

@router.delete("/{prediction_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_prediction(
    prediction_id: int,
    db: Session = Depends(get_db)
):
    """
    Supprimer une prédiction (utilisateur peut vouloir supprimer ses consultations).
    """
    success = prediction_service.delete_prediction(prediction_id)
    
    if not success:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Prédiction {prediction_id} non trouvée"
        )
    
    return None

@router.get("/dashboard/summary", status_code=status.HTTP_200_OK)
def get_dashboard_summary(
    user_id: int = Query(..., description="ID de l'utilisateur"),
    db: Session = Depends(get_db)
):
    """
    Récupérer un résumé pour le dashboard de l'utilisateur.
    
    Inclut:
    - Statistiques personnelles
    - Prédictions récentes
    - Classement personnel
    """
    # Statistiques personnelles
    personal_rank = leaderboard_service.get_personal_rank(user_id)
    
    # Prédictions récentes (5 dernières)
    recent_predictions, _ = prediction_service.get_predictions_with_pagination(limit=5, offset=0)
    
    return {
        "personal_rank": personal_rank,
        "recent_predictions": recent_predictions[:5],
        "total_predictions": personal_rank.predictions_count if personal_rank else 0,
        "accuracy": personal_rank.accuracy if personal_rank else 0
    }

📊 Tests Acceptation

Tests Frontend

// tests/dashboard.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { usePredictions } from '@/hooks/usePredictions';

describe('Dashboard Page', () => {
  it('devrait charger les prédictions au montage', async () => {
    const { result, waitForNextUpdate } = renderHook(() => usePredictions());
    
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: async () => ({
          data: [
            {
              id: 1,
              match_id: 1,
              match: {
                id: 1,
                home_team: "PSG",
                away_team: "Marseille",
                date: "2026-01-18T20:00:00Z",
                league: "Ligue 1",
                status: "scheduled"
              },
              energy_score: "high",
              confidence: "70.5%",
              predicted_winner: "PSG",
              created_at: "2026-01-17T12:00:00Z"
            }
          ],
          meta: {
            total: 1,
            limit: 20,
            offset: 0,
            timestamp: "2026-01-17T14:30:00Z",
            version: "v1"
          }
        })
      })
    );

    await waitForNextUpdate();

    expect(result.current.predictions).toHaveLength(1);
    expect(result.current.isLoading).toBe(false);
    expect(result.current.error).toBeNull();
  });

  it('devrait afficher une erreur si le chargement échoue', async () => {
    const { result, waitForNextUpdate } = renderHook(() => usePredictions());
    
    global.fetch = jest.fn(() =>
      Promise.reject(new Error('Erreur API'))
    );

    await act(async () => {
      await result.current.fetchPredictions();
    });

    await waitForNextUpdate();

    expect(result.current.error).toBe('Erreur API');
    expect(result.current.predictions).toEqual([]);
  });

  it('devrait rafraîchir les prédictions manuellement', async () => {
    const { result, waitForNextUpdate } = renderHook(() => usePredictions());
    
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: async () => ({
          data: [],
          meta: { total: 0, limit: 20, offset: 0, timestamp: '2026-01-17T14:30:00Z', version: 'v1' }
        })
      })
    });

    await act(async () => {
      await result.current.fetchPredictions();
    });

    await waitForNextUpdate();

    expect(global.fetch).toHaveBeenCalledTimes(2);
  });
});

Tests d'Intégration Backend

# tests/test_dashboard_api.py
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

from app.database import Base, get_db
from app.models.prediction import Prediction
from app.models.match import Match

# Setup base de données de test
TEST_DATABASE_URL = "sqlite:///./test_chartbastan.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)

def override_get_db():
    try:
        db = TestingSessionLocal()
        yield db
    finally:
        db.close()

client = TestClient(app)

def test_get_predictions_success():
    """Test récupération réussie des prédictions."""
    # Setup: Créer des données de test
    db = next(override_get_db())
    
    # Créer un match
    match = Match(
        home_team="PSG",
        away_team="Marseille",
        date=datetime.now(),
        league="Ligue 1",
        status="scheduled"
    )
    db.add(match)
    db.commit()
    
    # Créer une prédiction
    prediction = Prediction(
        match_id=match.id,
        energy_score="high",
        confidence="70.5%",
        predicted_winner="PSG",
        created_at=datetime.now()
    )
    db.add(prediction)
    db.commit()
    
    # Test
    response = client.get("/api/v1/predictions?limit=20")
    
    assert response.status_code == 200
    data = response.json()
    assert "data" in data
    assert len(data["data"]) == 1
    assert data["data"][0]["confidence"] == "70.5%"
    assert data["data"][0]["predicted_winner"] == "PSG"

def test_get_predictions_with_filters():
    """Test récupération avec filtres."""
    # Créer plusieurs prédictions
    db = next(override_get_db())
    
    matches = [
        Match(home_team="PSG", away_team="Lyon", date=datetime.now(), league="Ligue 1", status="scheduled"),
        Match(home_team="Real Madrid", away_team="Barcelona", date=datetime.now(), league="La Liga", status="scheduled")
    ]
    db.add_all(matches)
    db.commit()
    
    predictions = [
        Prediction(match_id=matches[0].id, energy_score="high", confidence="75.0%", predicted_winner="PSG", created_at=datetime.now()),
        Prediction(match_id=matches[0].id, energy_score="medium", confidence="55.0%", predicted_winner="PSG", created_at=datetime.now()),
        Prediction(match_id=matches[1].id, energy_score="high", confidence="78.0%", predicted_winner="Real Madrid", created_at=datetime.now()),
    ]
    db.add_all(predictions)
    db.commit()
    
    # Test filtre ligue
    response = client.get("/api/v1/predictions?limit=20&league_filter=Ligue 1")
    
    assert response.status_code == 200
    data = response.json()
    assert len(data["data"]) == 2  # Seulement les 2 prédictions Ligue 1
    
    # Test filtre confiance high
    response = client.get("/api/v1/predictions?limit=20&confidence_filter=high")
    
    assert response.status_code == 200
    data = response.json()
    assert len(data["data"]) == 3  # 3 prédictions avec confiance >70%

def test_regenerate_prediction():
    """Test la régénération d'une prédiction."""
    db = next(override_get_db())
    
    # Créer match et prédiction
    match = Match(home_team="PSG", away_team="Lyon", date=datetime.now(), league="Ligue 1", status="scheduled")
    db.add(match)
    db.commit()
    
    prediction = Prediction(match_id=match.id, energy_score="high", confidence="70.5%", predicted_winner="PSG", created_at=datetime.now())
    db.add(prediction)
    db.commit()
    
    # Régénérer
    response = client.post(f"/api/v1/predictions/regenerate/{prediction.id}")
    
    assert response.status_code == 200
    data = response.json()
    assert "message" in data
    assert "prediction" in data
    assert data["prediction"]["confidence"] != prediction.confidence  # Nouvelle prédiction

def test_delete_prediction():
    """Test la suppression d'une prédiction."""
    db = next(override_get_db())
    
    match = Match(home_team="PSG", away_team="Lyon", date=datetime.now(), league="Ligue 1", status="scheduled")
    db.add(match)
    db.commit()
    
    prediction = Prediction(match_id=match.id, energy_score="high", confidence="70.5%", predicted_winner="PSG", created_at=datetime.now())
    db.add(prediction)
    db.commit()
    
    # Supprimer
    response = client.delete(f"/api/v1/predictions/{prediction.id}")
    
    assert response.status_code == 204
    
    # Vérifier que la prédiction n'existe plus
    db = next(override_get_db())
    deleted_prediction = db.query(Prediction).filter(Prediction.id == prediction.id).first()
    assert deleted_prediction is None

def test_get_prediction_details():
    """Test la récupération des détails d'une prédiction."""
    db = next(override_get_db())
    
    match = Match(home_team="PSG", away_team="Lyon", date=datetime.now(), league="Ligue 1", status="scheduled")
    db.add(match)
    db.commit()
    
    prediction = Prediction(match_id=match.id, energy_score="high", confidence="70.5%", predicted_winner="PSG", created_at=datetime.now())
    db.add(prediction)
    db.commit()
    
    # Récupérer les détails
    response = client.get(f"/api/v1/predictions/{prediction.id}")
    
    assert response.status_code == 200
    data = response.json()
    assert data["id"] == prediction.id
    assert data["predicted_winner"] == "PSG"
    assert "history" in data
    assert len(data["history"]) >= 1

📝 Définitions de Succès

  • Un utilisateur peut voir une liste de prédictions sur le dashboard
  • Le Confidence Meter utilise le bon code couleur (vert >70%, jaune 50-70%, rouge <50%)
  • Les prédictions sont triées par date de match (plus proches d'abord)
  • Les prédictions peuvent être filtrées par ligue
  • Les prédictions peuvent être filtrées par niveau de confiance
  • Les utilisateurs peuvent voir leurs statistiques personnelles (classement, précision, nombre)
  • Les utilisateurs peuvent voir le Top 100 du classement
  • Les utilisateurs peuvent accéder aux détails d'une prédiction
  • Le dashboard est responsive (mobile et desktop)

🔗 Dépendances

Frontend :

  • @/components/ui/card, skeleton, badge, button, select
  • @/hooks/usePredictions, useLeaderboard
  • react-router-dom (ou Next.js Link)
  • lucide-react (animations)

Backend :

  • app/services/prediction_service.py (existant)
  • app/services/leaderboard_service.py (existant)
  • app/models/prediction.py (existant)
  • app/models/match.py (existant)

🎯 KPIs de Mesure

  • Temps de chargement dashboard : < 2s
  • Temps de réponse API prédictions : < 500ms
  • Nombre de prédictions par page : 20 (pagination)
  • Taux de conversion dashboard → détails : À suivre
  • Utilisateurs actifs : Mesure avec GA (Phase 2+)

Note : Cette User Story est au cœur de l'application Chartbastan. Une fois implémentée, les utilisateurs pourront consulter les prédictions sportives basées sur l'énergie collective avec une interface claire et intuitive. Les filtres avancés (ligue, confiance) permettront aux utilisateurs de trouver rapidement les prédictions les plus pertinentes.