docs: plan Phase 3 mis à jour — liste magique + modal édition liste

This commit is contained in:
2026-05-24 15:17:45 +02:00
parent 96e9375337
commit d6722bff97
@@ -2,9 +2,11 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Implémenter le module liste de courses — CRUD listes et articles, cochage en magasin, mode plein-écran avec Wake Lock, et composant Modal réutilisable pour tous les formulaires de l'app.
**Goal:** Implémenter le module liste de courses — CRUD listes et articles, cochage en magasin, mode plein-écran avec Wake Lock, bouton "liste magique" (génération automatique par score fréquence), et composant Modal réutilisable pour tous les formulaires de l'app.
**Architecture:** Backend FastAPI avec 10 endpoints sous `/api/shopping/`, frontend React avec 3 vues (liste des listes, détail d'une liste, mode magasin plein-écran). Un composant `Modal` générique sert de base à tous les formulaires create/edit — jamais de panneau inline qui se déplie dans la page.
**Architecture:** Backend FastAPI avec 12 endpoints sous `/api/shopping/`, frontend React avec 3 vues (liste des listes, détail d'une liste, mode magasin plein-écran). Un composant `Modal` générique sert de base à tous les formulaires. Terminologie : "articles" (pas "aliments" — la liste peut contenir n'importe quoi).
**Liste magique V1 :** score = `(today - last_purchased_at) / avg_interval_days` calculé depuis l'historique des `list_items` cochés. Bouton désactivé si une liste `draft`/`active` existe déjà.
**Tech Stack:** FastAPI 0.115, SQLAlchemy 2.0 async, Pydantic v2, React 18 + TypeScript, CSS variables Gruvbox, Wake Lock API (navigator.wakeLock).
@@ -1697,11 +1699,369 @@ Vérifier sur `http://localhost:3001/todos` :
- La touche Escape ferme le modal
- Cliquer en dehors du modal le ferme
- [ ] **Step 6 : Commit final**
- [ ] **Step 6 : Commit**
```bash
rtk git add frontend/src/pages/TodosPage.tsx
rtk git commit -m "refactor(todos): formulaires création et édition migrés vers Modal"
```
---
## Task 11 : Endpoint "Liste magique" (génération automatique)
**Files:**
- Modify: `backend/app/api/shopping.py`
- Modify: `frontend/src/api/shopping.ts`
L'algorithme V1 : score = `(aujourd'hui - last_purchased) / avg_interval_days` calculé depuis l'historique des `list_items` cochés. Les articles avec score ≥ 0.7 sont proposés, triés par score décroissant.
- [ ] **Step 1 : Ajouter l'endpoint `POST /api/shopping/lists/generate`**
Ajouter dans `backend/app/api/shopping.py`, avant le dernier endpoint, les imports suivants en haut du fichier :
```python
from sqlalchemy import func, text
from datetime import date, timedelta
```
Puis ajouter l'endpoint (à placer AVANT `@router.post("/lists/{list_id}/finish")`) :
```python
@router.post("/lists/generate", response_model=ShoppingListDetailResponse, status_code=201)
async def generate_magic_list(session: AsyncSession = Depends(get_session)):
"""Génère une liste à partir du score de fréquence (retard / intervalle moyen)."""
# Calcul du score pour chaque article déjà acheté (is_checked=True)
# On utilise created_at de la liste parente comme date d'achat
query = text("""
WITH achats AS (
SELECT
COALESCE(li.custom_name, p.name) AS nom,
li.product_id,
li.custom_name,
p.category,
p.default_unit,
sl.created_at::date AS date_achat
FROM shopping.list_items li
JOIN shopping.lists sl ON sl.id = li.list_id
LEFT JOIN shopping.products p ON p.id = li.product_id
WHERE li.is_checked = true
),
stats AS (
SELECT
nom,
product_id,
custom_name,
category,
default_unit,
MAX(date_achat) AS last_purchased,
COUNT(*) AS nb_achats,
AVG(
date_achat - LAG(date_achat) OVER (PARTITION BY nom ORDER BY date_achat)
) AS avg_interval_days
FROM achats
GROUP BY nom, product_id, custom_name, category, default_unit
)
SELECT
nom,
product_id,
custom_name,
category,
default_unit,
last_purchased,
nb_achats,
COALESCE(avg_interval_days, 30) AS avg_interval_days,
(CURRENT_DATE - last_purchased)::float
/ NULLIF(COALESCE(avg_interval_days, 30), 0) AS score
FROM stats
WHERE (CURRENT_DATE - last_purchased)::float
/ NULLIF(COALESCE(avg_interval_days, 30), 0) >= 0.7
ORDER BY score DESC
LIMIT 50
""")
result = await session.execute(query)
rows = result.mappings().all()
# Créer la liste générée
new_list = ShoppingList(name="Liste magique", status="draft")
session.add(new_list)
await session.flush()
for row in rows:
session.add(ListItem(
list_id=new_list.id,
product_id=row["product_id"],
custom_name=row["custom_name"],
unit=row["default_unit"],
))
await session.commit()
await session.refresh(new_list, ["items"])
stmt = (
select(ShoppingList)
.where(ShoppingList.id == new_list.id)
.options(selectinload(ShoppingList.items).selectinload(ListItem.product))
)
result2 = await session.execute(stmt)
new_list = result2.scalar_one()
sorted_items = sorted(new_list.items, key=lambda i: (i.sort_order or 999, str(i.id)))
return ShoppingListDetailResponse(
**_list_to_response(new_list).model_dump(),
items=[_item_to_response(i) for i in sorted_items],
)
```
- [ ] **Step 2 : Ajouter la fonction dans le client TypeScript**
Dans `frontend/src/api/shopping.ts`, ajouter après `finishShopping` :
```typescript
export async function generateMagicList(): Promise<ShoppingListDetail> {
return handleResponse(await fetch(`${BASE}/lists/generate`, { method: 'POST' }))
}
```
- [ ] **Step 3 : Tester manuellement**
```bash
curl -s -X POST http://localhost:8000/api/shopping/lists/generate | python3 -m json.tool | head -20
```
Si aucun historique : liste vide créée (normal au premier lancement — le seed ne contient pas d'historique d'achats cochés).
- [ ] **Step 4 : Commit**
```bash
rtk git add backend/app/api/shopping.py frontend/src/api/shopping.ts
rtk git commit -m "feat(shopping): endpoint génération liste magique (score fréquence V1)"
```
---
## Task 12 : UI — Bouton liste magique + Modal édition liste
**Files:**
- Modify: `frontend/src/pages/ShoppingPage.tsx`
Deux ajouts dans la ShoppingPage :
1. **Vue "listes"** : bouton ✨ "Liste magique" dans le header, désactivé si une liste `draft` ou `active` existe
2. **Vue "détail"** : bouton ✏️ dans le header ouvre un modal avec ajout rapide d'article + bouton rouge "Supprimer la liste"
- [ ] **Step 1 : Ajouter l'import et le state**
En haut de `ShoppingPage`, ajouter l'import :
```typescript
import { generateMagicList } from '../api/shopping'
```
Dans le composant, ajouter les states :
```typescript
const [showEditListModal, setShowEditListModal] = useState(false)
const [generating, setGenerating] = useState(false)
```
- [ ] **Step 2 : Handler génération liste magique**
Ajouter après `handleFinish` :
```typescript
async function handleGenerateMagicList() {
setGenerating(true)
setError(null)
try {
const newList = await generateMagicList()
void loadLists()
// Ouvrir directement le détail de la liste générée
setActiveList(newList)
setView('detail')
} catch {
setError('Erreur lors de la génération')
} finally {
setGenerating(false)
}
}
```
- [ ] **Step 3 : Handler suppression liste courante**
Ajouter après `handleGenerateMagicList` :
```typescript
async function handleDeleteActiveList() {
if (!activeList) return
try {
await deleteList(activeList.id)
setView('lists')
setActiveList(null)
void loadLists()
} catch {
setError('Erreur lors de la suppression')
}
}
```
- [ ] **Step 4 : Bouton liste magique dans la vue "listes"**
Dans le header de la vue "listes" (bloc `return` final), remplacer :
```tsx
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
</div>
```
par :
```tsx
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
<button
onClick={() => void handleGenerateMagicList()}
disabled={generating || lists.some(l => l.status === 'draft' || l.status === 'active')}
title="Générer une liste automatiquement"
style={{
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
color: lists.some(l => l.status === 'draft' || l.status === 'active')
? 'var(--ink-4)'
: 'var(--accent)',
cursor: lists.some(l => l.status === 'draft' || l.status === 'active')
? 'not-allowed'
: 'pointer',
padding: '8px 12px',
fontFamily: 'var(--font-ui)',
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 6,
minHeight: 44,
}}
>
<i className="fa-solid fa-wand-magic-sparkles" />
{generating ? 'Génération…' : 'Liste magique'}
</button>
</div>
```
- [ ] **Step 5 : Bouton édition dans le header de la vue "détail"**
Dans le header de la vue "détail" (bloc `if (view === 'detail' && activeList)`), ajouter le bouton ✏️ entre le titre et le bouton "Mode magasin" :
```tsx
<button
onClick={() => setShowEditListModal(true)}
title="Modifier / gérer la liste"
style={{
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
color: 'var(--ink-2)',
cursor: 'pointer',
padding: '8px 12px',
fontSize: 16,
minHeight: 44,
}}
>
<i className="fa-solid fa-pen" />
</button>
```
- [ ] **Step 6 : Modal édition liste (ajout article + suppression)**
Dans la vue "détail", ajouter après le modal d'ajout d'article existant :
```tsx
{showEditListModal && (
<Modal title="Gérer la liste" onClose={() => setShowEditListModal(false)}>
{/* Ajout rapide d'article */}
<p style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)', margin: 0, textTransform: 'uppercase', letterSpacing: 1 }}>
Ajouter un article
</p>
<input
style={inputStyle}
placeholder="Nom de l'article *"
value={newItemName}
onChange={e => setNewItemName(e.target.value)}
autoFocus
onKeyDown={e => e.key === 'Enter' && void handleAddItem().then(() => setShowEditListModal(false))}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<input
style={inputStyle}
placeholder="Quantité"
value={newItemQty}
onChange={e => setNewItemQty(e.target.value)}
/>
<input
style={inputStyle}
placeholder="Unité (kg, L…)"
value={newItemUnit}
onChange={e => setNewItemUnit(e.target.value)}
/>
</div>
<button
onClick={() => void handleAddItem().then(() => { if (!error) setShowEditListModal(false) })}
style={{
padding: '10px 20px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: '#1d2021', cursor: 'pointer',
fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48, width: '100%',
}}
>
<i className="fa-solid fa-plus" style={{ marginRight: 8 }} />
Ajouter
</button>
{/* Séparateur */}
<div style={{ borderTop: '1px solid var(--bg-4)', margin: '4px 0' }} />
{/* Suppression liste */}
<button
onClick={() => {
setShowEditListModal(false)
void handleDeleteActiveList()
}}
style={{
padding: '10px 20px', borderRadius: 8,
border: '1px solid var(--err)',
background: 'transparent', color: 'var(--err)',
cursor: 'pointer', fontFamily: 'var(--font-ui)',
fontWeight: 600, minHeight: 48, width: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
}}
>
<i className="fa-solid fa-trash" />
Supprimer la liste en cours
</button>
</Modal>
)}
```
- [ ] **Step 7 : Rebuild et vérifier**
```bash
docker compose build frontend && docker compose up -d frontend
```
Vérifier :
- Vue "Courses" : bouton "Liste magique" visible dans le header
- Si aucune liste en cours → bouton actif (orange), clic crée une liste et ouvre le détail
- Si liste `draft`/`active` existe → bouton grisé, non cliquable
- Vue "détail" : icône ✏️ dans le header ouvre un modal avec champ d'ajout + bouton rouge suppression
- Bouton rouge "Supprimer la liste en cours" → supprime et retourne à la liste des listes
- [ ] **Step 8 : Commit final**
```bash
rtk git add frontend/src/pages/ShoppingPage.tsx
rtk git commit -m "feat(shopping): bouton liste magique + modal édition liste avec suppression"
rtk git tag v0.3.0-phase3
```
@@ -1717,8 +2077,10 @@ rtk git tag v0.3.0-phase3
- ✅ Modal pour tous les formulaires (plus de panneau inline)
- ✅ Seed produits et magasins déjà en place
- ✅ Migration TodoForm → Modal
- ❌ Hors scope Phase 3 : prix OCR, auto-fill fréquence, catalogue CRUD laptop, historique prix
- ✅ Bouton "liste magique" (score fréquence V1, désactivé si liste en cours)
- ✅ Modal édition liste (ajout article + suppression liste)
- ❌ Hors scope Phase 3 : prix OCR, corrélations, saisonnalité, catalogue CRUD laptop
**Type consistency :** `ShoppingListDetail` étend `ShoppingList` avec `items: ShoppingItem[]`. `_list_to_response()` et `_item_to_response()` sont les seuls points de conversion ORM→schema. `display_name` calculé dans `_item_to_response` uniquement.
**Type consistency :** `ShoppingListDetail` étend `ShoppingList` avec `items: ShoppingItem[]`. `generateMagicList()` retourne `ShoppingListDetail`. `_list_to_response()` et `_item_to_response()` sont les seuls points de conversion ORM→schema.
**Placeholder scan :** aucun TBD, aucun "handle edge cases" — toutes les erreurs catchées avec setError().
**Placeholder scan :** aucun TBD — toutes les erreurs catchées avec setError().