From d6722bff9796e1b3037720813dbf647f49eb72df Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Sun, 24 May 2026 15:17:45 +0200 Subject: [PATCH] =?UTF-8?q?docs:=20plan=20Phase=203=20mis=20=C3=A0=20jour?= =?UTF-8?q?=20=E2=80=94=20liste=20magique=20+=20modal=20=C3=A9dition=20lis?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-05-24-phase3-shopping.md | 374 +++++++++++++++++- 1 file changed, 368 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-05-24-phase3-shopping.md b/docs/superpowers/plans/2026-05-24-phase3-shopping.md index 40d913e..022017f 100644 --- a/docs/superpowers/plans/2026-05-24-phase3-shopping.md +++ b/docs/superpowers/plans/2026-05-24-phase3-shopping.md @@ -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 { + 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 +
+

Courses

+
+``` + +par : + +```tsx +
+

Courses

+ +
+``` + +- [ ] **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 + +``` + +- [ ] **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 && ( + setShowEditListModal(false)}> + {/* Ajout rapide d'article */} +

+ Ajouter un article +

+ setNewItemName(e.target.value)} + autoFocus + onKeyDown={e => e.key === 'Enter' && void handleAddItem().then(() => setShowEditListModal(false))} + /> +
+ setNewItemQty(e.target.value)} + /> + setNewItemUnit(e.target.value)} + /> +
+ + + {/* Séparateur */} +
+ + {/* Suppression liste */} + + +)} +``` + +- [ ] **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().