1102 lines
34 KiB
Markdown
1102 lines
34 KiB
Markdown
# 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`)
|
|
|
|
```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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```tsx
|
|
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
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```python
|
|
"""
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```python
|
|
# 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.
|