docs: plan Phase 3 mis à jour — liste magique + modal édition liste
This commit is contained in:
@@ -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().
|
||||
|
||||
Reference in New Issue
Block a user