vision refactor

This commit is contained in:
alma 2026-01-15 22:15:59 +01:00
parent c9acec83dc
commit 0aaaf468ff
2 changed files with 541 additions and 19 deletions

View File

@ -0,0 +1,500 @@
# Analyse du Flow du Widget "Devoirs"
## 📋 Vue d'ensemble
Le widget "Devoirs" affiche les tâches Leantime assignées à l'utilisateur connecté. Il récupère les données depuis l'API Leantime via un endpoint Next.js qui utilise un système de cache Redis.
---
## 🔄 Flow Complet
### 1. **Initialisation du Widget** (`components/flow.tsx`)
**Fichier:** `components/flow.tsx`
**Composant:** `Duties()`
#### État initial
```typescript
const [tasks, setTasks] = useState<TaskWithDate[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
```
#### Cycle de vie
- **Mount:** `useEffect(() => { fetchTasks(); }, [])` - Appelle `fetchTasks()` au montage
- **Refresh manuel:** Bouton de rafraîchissement dans le header du widget
---
### 2. **Appel API Frontend** (`components/flow.tsx`)
**Fonction:** `fetchTasks()`
```typescript
const fetchTasks = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/leantime/tasks?refresh=true');
// ...
}
}
```
**Points importants:**
- ✅ Utilise `?refresh=true` pour **bypasser le cache Redis**
- ✅ Appelé au montage du composant
- ✅ Appelé manuellement via le bouton de rafraîchissement
**URL:** `GET /api/leantime/tasks?refresh=true`
---
### 3. **Route API Backend** (`app/api/leantime/tasks/route.ts`)
**Fichier:** `app/api/leantime/tasks/route.ts`
**Méthode:** `GET`
#### 3.1. Authentification
```typescript
const session = await getServerSession(authOptions);
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
```
#### 3.2. Gestion du Cache Redis
**Clé de cache:** `widget:tasks:${userId}`
**TTL:** 10 minutes (600 secondes)
```typescript
// Check for force refresh parameter
const url = new URL(request.url);
const forceRefresh = url.searchParams.get('refresh') === 'true';
// Try to get data from cache if not forcing refresh
if (!forceRefresh) {
const cachedTasks = await getCachedTasksData(session.user.id);
if (cachedTasks) {
return NextResponse.json(cachedTasks); // ⚡ Retour immédiat si cache hit
}
}
```
**Comportement:**
- Si `?refresh=true`**Ignore le cache**, va chercher les données fraîches
- Si pas de `refresh` → **Vérifie le cache Redis d'abord**
- Si cache hit → Retourne immédiatement les données en cache
- Si cache miss → Continue avec la récupération depuis Leantime
---
### 4. **Récupération de l'ID Utilisateur Leantime**
**Fonction:** `getLeantimeUserId(email: string)`
#### 4.1. Appel API Leantime - Users
```typescript
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.users.getAll',
id: 1
}),
});
```
**Méthode RPC:** `leantime.rpc.users.getAll`
**Objectif:** Récupérer tous les utilisateurs Leantime pour trouver celui correspondant à l'email de session
#### 4.2. Recherche de l'utilisateur
```typescript
const users = data.result;
const user = users.find((u: any) => u.username === email);
return user ? user.id : null;
```
**Logique:**
- Parcourt tous les utilisateurs Leantime
- Trouve celui dont `username` correspond à l'email de session
- Retourne l'ID Leantime de l'utilisateur
**Erreurs possibles:**
- ❌ `LEANTIME_TOKEN` non défini → Retourne `null`
- ❌ API Leantime non accessible → Retourne `null`
- ❌ Utilisateur non trouvé → Retourne `null`
- ❌ Réponse invalide → Retourne `null`
---
### 5. **Récupération des Tâches Leantime**
**Méthode RPC:** `leantime.rpc.tickets.getAll`
```typescript
const response = await fetch(`${process.env.LEANTIME_API_URL}/api/jsonrpc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.LEANTIME_TOKEN!
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'leantime.rpc.tickets.getAll',
params: {
userId: userId,
status: "all"
},
id: 1
}),
});
```
**Paramètres:**
- `userId`: ID Leantime de l'utilisateur (récupéré à l'étape 4)
- `status: "all"`: Récupère toutes les tâches, peu importe leur statut
**Réponse:** Tableau de toutes les tâches Leantime de l'utilisateur
---
### 6. **Filtrage et Transformation des Tâches**
#### 6.1. Filtrage côté Backend (`app/api/leantime/tasks/route.ts`)
```typescript
const tasks = data.result
.filter((task: any) => {
// 1. Exclure les tâches avec status "Done" (5)
if (task.status === 5) {
return false;
}
// 2. Filtrer uniquement les tâches où l'utilisateur est l'éditeur
const taskEditorId = String(task.editorId).trim();
const currentUserId = String(userId).trim();
const isUserEditor = taskEditorId === currentUserId;
return isUserEditor;
})
.map((task: any) => ({
id: task.id.toString(),
headline: task.headline,
projectName: task.projectName,
projectId: task.projectId,
status: task.status,
dateToFinish: task.dateToFinish || null,
milestone: task.type || null,
details: task.description || null,
createdOn: task.dateCreated,
editedOn: task.editedOn || null,
editorId: task.editorId,
editorFirstname: task.editorFirstname,
editorLastname: task.editorLastname,
type: task.type || null,
dependingTicketId: task.dependingTicketId || null
}));
```
**Critères de filtrage:**
1. ✅ **Exclut les tâches "Done"** (status = 5)
2. ✅ **Uniquement les tâches où l'utilisateur est l'éditeur** (`editorId === userId`)
**Transformation:**
- Convertit les IDs en string
- Normalise les dates (`null` si absentes)
- Ajoute les champs `type` et `dependingTicketId` pour identifier les sous-tâches
#### 6.2. Mise en cache Redis
```typescript
await cacheTasksData(session.user.id, tasks);
```
**Fonction:** `cacheTasksData(userId, data)` dans `lib/redis.ts`
```typescript
const key = KEYS.TASKS(userId); // `widget:tasks:${userId}`
await redis.set(key, JSON.stringify(data), 'EX', TTL.TASKS); // TTL = 600 secondes (10 min)
```
---
### 7. **Traitement Frontend** (`components/flow.tsx`)
#### 7.1. Filtrage supplémentaire côté Frontend
```typescript
const sortedTasks = data
.filter((task: Task) => {
// Double filtrage: exclure les tâches Done (déjà fait côté backend, mais sécurité)
const isNotDone = task.status !== 5;
return isNotDone;
})
.sort((a: Task, b: Task) => {
// 1. Trier par dateToFinish (plus anciennes en premier)
const dateA = getValidDate(a);
const dateB = getValidDate(b);
if (dateA && dateB) {
const timeA = new Date(dateA).getTime();
const timeB = new Date(dateB).getTime();
if (timeA !== timeB) {
return timeA - timeB;
}
}
// 2. Si une seule tâche a une date, la mettre en premier
if (dateA) return -1;
if (dateB) return 1;
// 3. Si dates égales ou absentes, prioriser status 4 (Waiting for Approval)
if (a.status === 4 && b.status !== 4) return -1;
if (b.status === 4 && a.status !== 4) return 1;
return 0;
});
```
**Logique de tri:**
1. **Par date d'échéance** (ascendant - plus anciennes en premier)
2. **Tâches avec date** avant celles sans date
3. **Status 4** (Waiting for Approval) en priorité si dates égales
#### 7.2. Limitation du nombre de résultats
```typescript
setTasks(sortedTasks.slice(0, 7)); // Affiche maximum 7 tâches
```
---
### 8. **Affichage dans le Widget**
#### 8.1. Structure du Widget
```tsx
<Card>
<CardHeader>
<CardTitle>Devoirs</CardTitle>
<Button onClick={() => fetchTasks()}>Refresh</Button>
</CardHeader>
<CardContent>
{loading ? <Spinner /> :
error ? <Error /> :
tasks.length === 0 ? <EmptyState /> :
<TaskList />}
</CardContent>
</Card>
```
#### 8.2. Composant TaskDate
Affiche la date d'échéance avec:
- **Format:** Mois (court) / Jour / Année
- **Couleurs:**
- 🔴 Rouge si date passée (`isPastDue`)
- 🔵 Bleu si date future
- **Fallback:** "NO DATE" si pas de date ou date invalide
#### 8.3. Lien vers Leantime
Chaque tâche est cliquable et redirige vers:
```
https://agilite.slm-lab.net/tickets/showTicket/${task.id}
```
---
## 📊 Diagramme de Flow
```
┌─────────────────────────────────────────────────────────────┐
│ 1. Widget Mount (components/flow.tsx) │
│ useEffect(() => fetchTasks()) │
└────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 2. Frontend API Call │
│ GET /api/leantime/tasks?refresh=true │
└────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 3. Backend Route (app/api/leantime/tasks/route.ts) │
│ - Vérifie session │
│ - Check cache Redis (si !refresh) │
└────────────────────┬──────────────────────────────────────┘
┌─────────────┴─────────────┐
│ │
▼ ▼
Cache Hit? Cache Miss?
│ │
│ ▼
Return Cache ┌──────────────────────────┐
│ 4. getLeantimeUserId() │
│ - API: users.getAll │
│ - Find by email │
└──────────┬───────────────┘
┌──────────────────────────┐
│ 5. Fetch Tasks │
│ - API: tickets.getAll │
│ - Filter: !status=5 │
│ - Filter: editorId │
└──────────┬───────────────┘
┌──────────────────────────┐
│ 6. Cache Results │
│ Redis: widget:tasks │
│ TTL: 10 minutes │
└──────────┬───────────────┘
┌──────────────────────────┐
│ 7. Return JSON │
└──────────┬───────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 8. Frontend Processing (components/flow.tsx) │
│ - Filter: !status=5 (double check) │
│ - Sort: by dateToFinish, then status │
│ - Slice: first 7 tasks │
└────────────────────┬──────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 9. Render Widget │
│ - TaskDate component │
│ - Links to Leantime │
└─────────────────────────────────────────────────────────────┘
```
---
## 🔑 Points Clés
### Cache Redis
- **Clé:** `widget:tasks:${userId}`
- **TTL:** 10 minutes (600 secondes)
- **Bypass:** `?refresh=true` dans l'URL
### Filtrage
1. **Backend:** Exclut status=5 et filtre par `editorId`
2. **Frontend:** Double vérification status=5 et tri personnalisé
### Tri
1. Date d'échéance (ascendant)
2. Tâches avec date en premier
3. Status 4 (Waiting for Approval) prioritaire
### Limitation
- Maximum **7 tâches** affichées
### Statuts des Tâches
- **1:** New (Bleu)
- **2:** Blocked (Rouge)
- **3:** In Progress (Jaune)
- **4:** Waiting for Approval (Violet)
- **5:** Done (Gris) - **Exclu de l'affichage**
---
## ⚠️ Problèmes Identifiés
### 1. Double Filtrage
- Le backend filtre déjà les tâches Done (status=5)
- Le frontend refilt également → **Redondant**
### 2. Bypass Cache Systématique
- Le widget utilise toujours `?refresh=true`
- **Ne profite jamais du cache Redis** → Augmente la charge serveur
### 3. Pas de Refresh Automatique
- Aucun polling automatique
- Seulement au mount et refresh manuel
- **Pas d'intégration avec `useUnifiedRefresh`**
### 4. Gestion d'Erreurs
- Si `getLeantimeUserId()` retourne `null` → Erreur 404
- Pas de fallback si Leantime est indisponible
---
## 🚀 Recommandations
### 1. Utiliser le Cache par Défaut
```typescript
// Au lieu de toujours utiliser ?refresh=true
const response = await fetch('/api/leantime/tasks'); // Sans refresh
```
### 2. Intégrer useUnifiedRefresh
```typescript
const { refresh, isActive } = useUnifiedRefresh({
resource: 'tasks',
interval: 300000, // 5 minutes
enabled: status === 'authenticated',
onRefresh: fetchTasks,
priority: 'low',
});
```
### 3. Retirer le Double Filtrage
- Supprimer le filtrage status=5 côté frontend (déjà fait côté backend)
### 4. Améliorer la Gestion d'Erreurs
- Afficher un message clair si l'utilisateur n'est pas trouvé dans Leantime
- Fallback si Leantime est indisponible
---
## 📝 Fichiers Concernés
1. **Frontend:**
- `components/flow.tsx` - Widget principal
2. **Backend:**
- `app/api/leantime/tasks/route.ts` - Route API
3. **Cache:**
- `lib/redis.ts` - Fonctions `cacheTasksData()` et `getCachedTasksData()`
4. **Configuration:**
- Variables d'environnement:
- `LEANTIME_API_URL`
- `LEANTIME_TOKEN`
---
## 🔍 Logs et Debug
### Logs Backend
- `[LEANTIME_TASKS]` - Préfixe pour tous les logs
- Logs de debug pour chaque étape du flow
- Logs d'erreur en cas d'échec
### Logs Frontend
- `console.log('Raw API response:', data)` - Réponse brute
- `console.log('Sorted and filtered tasks:', ...)` - Tâches triées
- Logs de filtrage pour chaque tâche
---
## 📊 Métriques
- **Cache TTL:** 10 minutes
- **Limite d'affichage:** 7 tâches
- **Statuts affichés:** 1, 2, 3, 4 (exclut 5)
- **Tri:** Date d'échéance → Status 4 prioritaire

View File

@ -1,10 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { RefreshCw, Share2, Folder } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useUnifiedRefresh } from "@/hooks/use-unified-refresh";
import { REFRESH_INTERVALS } from "@/lib/constants/refresh-intervals";
interface Task {
id: number;
@ -39,6 +42,7 @@ interface TaskWithDate extends Task {
}
export function Duties() {
const { data: session, status } = useSession();
const [tasks, setTasks] = useState<TaskWithDate[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@ -88,11 +92,17 @@ export function Duties() {
return null;
};
const fetchTasks = async () => {
setLoading(true);
const fetchTasks = async (forceRefresh = false) => {
// Only show loading spinner on initial load, not on auto-refresh
if (!tasks.length) {
setLoading(true);
}
setRefreshing(true);
setError(null);
try {
const response = await fetch('/api/leantime/tasks?refresh=true');
// Use cache by default, only bypass with ?refresh=true for manual refresh
const url = forceRefresh ? '/api/leantime/tasks?refresh=true' : '/api/leantime/tasks';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch tasks');
}
@ -106,18 +116,9 @@ export function Duties() {
return;
}
// Filter out tasks with status Done (5) and sort by dateToFinish
// Backend already filters out status=5 (Done) and filters by editorId
// Only sort by dateToFinish here
const sortedTasks = data
.filter((task: Task) => {
// Filter out any task (main or subtask) that has status Done (5)
const isNotDone = task.status !== 5;
if (!isNotDone) {
console.log(`Filtering out Done task ${task.id} (type: ${task.type || 'main'}, status: ${task.status})`);
} else {
console.log(`Keeping task ${task.id}: status=${task.status} (${getStatusLabel(task.status)}), type=${task.type || 'main'}`);
}
return isNotDone;
})
.sort((a: Task, b: Task) => {
// First sort by dateToFinish (oldest first)
const dateA = getValidDate(a);
@ -144,7 +145,7 @@ export function Duties() {
return 0;
});
console.log('Sorted and filtered tasks:', sortedTasks.map(t => ({
console.log('Sorted tasks:', sortedTasks.map(t => ({
id: t.id,
date: t.dateToFinish,
status: t.status,
@ -156,12 +157,32 @@ export function Duties() {
setError(error instanceof Error ? error.message : 'Failed to fetch tasks');
} finally {
setLoading(false);
setRefreshing(false);
}
};
// Initial fetch on mount
useEffect(() => {
fetchTasks();
}, []);
if (status === 'authenticated') {
fetchTasks(false); // Use cache on initial load
}
}, [status]);
// Integrate unified refresh for automatic polling
const { refresh } = useUnifiedRefresh({
resource: 'duties',
interval: REFRESH_INTERVALS.DUTIES, // 2 minutes
enabled: status === 'authenticated',
onRefresh: async () => {
await fetchTasks(false); // Use cache for auto-refresh
},
priority: 'medium',
});
// Manual refresh handler (bypasses cache)
const handleManualRefresh = async () => {
await fetchTasks(true); // Force refresh, bypass cache
};
// Update the TaskDate component to handle dates better
const TaskDate = ({ task }: { task: TaskWithDate }) => {
@ -225,10 +246,11 @@ export function Duties() {
<Button
variant="ghost"
size="icon"
onClick={() => fetchTasks()}
onClick={handleManualRefresh}
disabled={refreshing}
className="h-7 w-7 p-0 hover:bg-gray-100/50 rounded-full"
>
<RefreshCw className="h-3.5 w-3.5 text-gray-600" />
<RefreshCw className={`h-3.5 w-3.5 text-gray-600 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
</CardHeader>
<CardContent className="p-3">