# Phase 3 — Liste de courses : Plan d'implémentation > **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, 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 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). --- ## Structure des fichiers **Créer :** - `backend/app/schemas/shopping.py` — schémas Pydantic (listes, articles, produits, magasins) - `backend/app/api/shopping.py` — 10 endpoints REST - `backend/tests/test_shopping.py` — 9 tests d'intégration - `frontend/src/api/shopping.ts` — client fetch typé - `frontend/src/components/Modal.tsx` — modal générique réutilisable - `frontend/src/components/shopping/ItemRow.tsx` — ligne article avec check + swipe suppression - `frontend/src/hooks/useWakeLock.ts` — hook Wake Lock API avec fallback **Modifier :** - `backend/app/models/shopping.py` — ajouter relation `product` sur `ListItem` - `backend/app/main.py` — enregistrer le router shopping - `frontend/src/pages/ShoppingPage.tsx` — remplacer le placeholder par la vraie page --- ## Task 1 : Relation `product` dans le modèle ListItem **Files:** - Modify: `backend/app/models/shopping.py` - [ ] **Step 1 : Ajouter la relation `product` sur `ListItem`** Ouvrir `backend/app/models/shopping.py`. Ajouter l'import `Optional` et la relation dans `ListItem` : ```python # Ajouter en haut du fichier, avec les imports existants : from typing import Optional from sqlalchemy.orm import Mapped, mapped_column, relationship # déjà présent # Dans la classe ListItem, ajouter après `shopping_list` : product: Mapped[Optional["Product"]] = relationship("Product", lazy="select") ``` Le fichier complet de `ListItem` après modification : ```python class ListItem(Base): __tablename__ = "list_items" __table_args__ = {"schema": "shopping"} id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) list_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False) product_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="SET NULL")) custom_name: Mapped[str | None] = mapped_column(String(150)) quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3)) unit: Mapped[str | None] = mapped_column(String(20)) is_checked: Mapped[bool] = mapped_column(Boolean, server_default=text("false")) price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2)) carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false")) sort_order: Mapped[int | None] = mapped_column(Integer) shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items") product: Mapped[Optional["Product"]] = relationship("Product", lazy="select") ``` - [ ] **Step 2 : Vérifier que l'import Python fonctionne** ```bash docker compose exec backend python -c "from app.models.shopping import ListItem; print('OK')" ``` Expected: `OK` - [ ] **Step 3 : Commit** ```bash rtk git add backend/app/models/shopping.py rtk git commit -m "feat(shopping): relation product sur ListItem" ``` --- ## Task 2 : Schémas Pydantic **Files:** - Create: `backend/app/schemas/shopping.py` - [ ] **Step 1 : Créer le fichier de schémas** ```python # backend/app/schemas/shopping.py import uuid from datetime import datetime, date from decimal import Decimal from typing import Literal from pydantic import BaseModel, ConfigDict, Field, model_validator class StoreResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID name: str location: str | None class ProductResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID name: str brand: str | None category: str | None default_unit: str | None frequency_score: int class ListItemCreate(BaseModel): product_id: uuid.UUID | None = None custom_name: str | None = None quantity: Decimal | None = None unit: str | None = None @model_validator(mode='after') def must_have_name(self) -> 'ListItemCreate': if not self.product_id and not self.custom_name: raise ValueError('product_id ou custom_name requis') return self class ListItemUpdate(BaseModel): is_checked: bool | None = None quantity: Decimal | None = None unit: str | None = None price_recorded: Decimal | None = None class ListItemResponse(BaseModel): id: uuid.UUID product_id: uuid.UUID | None custom_name: str | None display_name: str quantity: Decimal | None unit: str | None is_checked: bool price_recorded: Decimal | None carried_over: bool sort_order: int | None class ShoppingListCreate(BaseModel): name: str | None = None store_id: uuid.UUID | None = None week_date: date | None = None class ShoppingListUpdate(BaseModel): name: str | None = None store_id: uuid.UUID | None = None status: Literal['draft', 'active', 'done'] | None = None class ShoppingListResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID name: str | None store_id: uuid.UUID | None week_date: date | None status: str created_at: datetime item_count: int checked_count: int class ShoppingListDetailResponse(BaseModel): id: uuid.UUID name: str | None store_id: uuid.UUID | None week_date: date | None status: str created_at: datetime item_count: int checked_count: int items: list[ListItemResponse] ``` - [ ] **Step 2 : Vérifier l'import** ```bash docker compose exec backend python -c "from app.schemas.shopping import ShoppingListCreate; print('OK')" ``` Expected: `OK` - [ ] **Step 3 : Commit** ```bash rtk git add backend/app/schemas/shopping.py rtk git commit -m "feat(shopping): schémas Pydantic listes et articles" ``` --- ## Task 3 : Endpoints backend **Files:** - Create: `backend/app/api/shopping.py` - Modify: `backend/app/main.py` - [ ] **Step 1 : Créer le fichier d'endpoints** ```python # backend/app/api/shopping.py import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.database import get_session from app.models.shopping import ShoppingList, ListItem, Product, Store from app.schemas.shopping import ( ShoppingListCreate, ShoppingListUpdate, ShoppingListResponse, ShoppingListDetailResponse, ListItemCreate, ListItemUpdate, ListItemResponse, ProductResponse, StoreResponse, ) router = APIRouter() def _item_to_response(item: ListItem) -> ListItemResponse: display_name = item.custom_name or (item.product.name if item.product else "Article inconnu") return ListItemResponse( id=item.id, product_id=item.product_id, custom_name=item.custom_name, display_name=display_name, quantity=item.quantity, unit=item.unit, is_checked=item.is_checked, price_recorded=item.price_recorded, carried_over=item.carried_over, sort_order=item.sort_order, ) def _list_to_response(lst: ShoppingList) -> ShoppingListResponse: items = lst.items if lst.items is not None else [] return ShoppingListResponse( id=lst.id, name=lst.name, store_id=lst.store_id, week_date=lst.week_date, status=lst.status, created_at=lst.created_at, item_count=len(items), checked_count=sum(1 for i in items if i.is_checked), ) # ── Stores ──────────────────────────────────────────────────────────────────── @router.get("/stores", response_model=list[StoreResponse]) async def list_stores(session: AsyncSession = Depends(get_session)): result = await session.execute(select(Store).order_by(Store.name)) return result.scalars().all() # ── Products ────────────────────────────────────────────────────────────────── @router.get("/products", response_model=list[ProductResponse]) async def search_products( q: str | None = Query(default=None), limit: int = 30, session: AsyncSession = Depends(get_session), ): stmt = select(Product).order_by(Product.frequency_score.desc(), Product.name) if q: stmt = stmt.where(Product.name.ilike(f"%{q}%")) stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() # ── Lists ───────────────────────────────────────────────────────────────────── @router.get("/lists", response_model=list[ShoppingListResponse]) async def list_shopping_lists(session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .options(selectinload(ShoppingList.items)) .order_by(ShoppingList.created_at.desc()) ) result = await session.execute(stmt) lists = result.scalars().all() return [_list_to_response(lst) for lst in lists] @router.post("/lists", response_model=ShoppingListDetailResponse, status_code=201) async def create_shopping_list( payload: ShoppingListCreate, session: AsyncSession = Depends(get_session), ): lst = ShoppingList(**payload.model_dump()) session.add(lst) await session.commit() await session.refresh(lst, ["items"]) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[], ) @router.get("/lists/{list_id}", response_model=ShoppingListDetailResponse) async def get_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @router.patch("/lists/{list_id}", response_model=ShoppingListDetailResponse) async def update_shopping_list( list_id: uuid.UUID, payload: ShoppingListUpdate, session: AsyncSession = Depends(get_session), ): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(lst, field, value) await session.commit() sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) @router.delete("/lists/{list_id}", status_code=204) async def delete_shopping_list(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): lst = await session.get(ShoppingList, list_id) if not lst: raise HTTPException(404, "Liste introuvable") await session.delete(lst) await session.commit() return Response(status_code=204) # ── Items ───────────────────────────────────────────────────────────────────── @router.post("/lists/{list_id}/items", response_model=ListItemResponse, status_code=201) async def add_item( list_id: uuid.UUID, payload: ListItemCreate, session: AsyncSession = Depends(get_session), ): lst = await session.get(ShoppingList, list_id) if not lst: raise HTTPException(404, "Liste introuvable") item = ListItem(list_id=list_id, **payload.model_dump()) session.add(item) await session.commit() # Recharger avec la relation product pour le display_name stmt = ( select(ListItem) .where(ListItem.id == item.id) .options(selectinload(ListItem.product)) ) result = await session.execute(stmt) item = result.scalar_one() return _item_to_response(item) @router.patch("/lists/{list_id}/items/{item_id}", response_model=ListItemResponse) async def update_item( list_id: uuid.UUID, item_id: uuid.UUID, payload: ListItemUpdate, session: AsyncSession = Depends(get_session), ): stmt = ( select(ListItem) .where(ListItem.id == item_id, ListItem.list_id == list_id) .options(selectinload(ListItem.product)) ) result = await session.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Article introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(item, field, value) await session.commit() await session.refresh(item) return _item_to_response(item) @router.delete("/lists/{list_id}/items/{item_id}", status_code=204) async def delete_item( list_id: uuid.UUID, item_id: uuid.UUID, session: AsyncSession = Depends(get_session), ): stmt = select(ListItem).where(ListItem.id == item_id, ListItem.list_id == list_id) result = await session.execute(stmt) item = result.scalar_one_or_none() if not item: raise HTTPException(404, "Article introuvable") await session.delete(item) await session.commit() return Response(status_code=204) @router.post("/lists/{list_id}/finish", response_model=ShoppingListDetailResponse) async def finish_shopping(list_id: uuid.UUID, session: AsyncSession = Depends(get_session)): stmt = ( select(ShoppingList) .where(ShoppingList.id == list_id) .options(selectinload(ShoppingList.items).selectinload(ListItem.product)) ) result = await session.execute(stmt) lst = result.scalar_one_or_none() if not lst: raise HTTPException(404, "Liste introuvable") lst.status = "done" unchecked = [i for i in lst.items if not i.is_checked] if unchecked: new_list = ShoppingList(store_id=lst.store_id, status="draft") session.add(new_list) await session.flush() for item in unchecked: session.add(ListItem( list_id=new_list.id, product_id=item.product_id, custom_name=item.custom_name, quantity=item.quantity, unit=item.unit, sort_order=item.sort_order, carried_over=True, )) await session.commit() sorted_items = sorted(lst.items, key=lambda i: (i.sort_order or 999, str(i.id))) return ShoppingListDetailResponse( **_list_to_response(lst).model_dump(), items=[_item_to_response(i) for i in sorted_items], ) ``` - [ ] **Step 2 : Enregistrer le router dans `main.py`** Ouvrir `backend/app/main.py`. Ajouter après les imports existants : ```python from app.api.shopping import router as shopping_router ``` Et après `app.include_router(todos_router, prefix="/api/todos")` : ```python app.include_router(shopping_router, prefix="/api/shopping") ``` - [ ] **Step 3 : Vérifier que les routes sont enregistrées** ```bash docker compose build backend && docker compose up -d backend docker compose exec backend python -c " from app.main import app routes = [r.path for r in app.routes if 'shopping' in r.path] print('\n'.join(routes)) " ``` Expected output (10 routes) : ``` /api/shopping/stores /api/shopping/products /api/shopping/lists /api/shopping/lists /api/shopping/lists/{list_id} /api/shopping/lists/{list_id} /api/shopping/lists/{list_id} /api/shopping/lists/{list_id}/items /api/shopping/lists/{list_id}/items/{item_id} /api/shopping/lists/{list_id}/items/{item_id} /api/shopping/lists/{list_id}/finish ``` - [ ] **Step 4 : Commit** ```bash rtk git add backend/app/api/shopping.py backend/app/main.py rtk git commit -m "feat(shopping): 10 endpoints CRUD listes et articles" ``` --- ## Task 4 : Tests d'intégration backend **Files:** - Create: `backend/tests/test_shopping.py` - [ ] **Step 1 : Écrire les tests** ```python # backend/tests/test_shopping.py import pytest from sqlalchemy import delete from app.models.shopping import ShoppingList, ListItem @pytest.fixture(autouse=True) async def cleanup(db_session): yield # Supprimer les listes de test (cascade supprime les items) await db_session.execute( delete(ShoppingList).where(ShoppingList.name.like("TEST_%")) ) # Supprimer les listes sans nom créées par les tests result = await db_session.execute( delete(ShoppingList).where(ShoppingList.name.is_(None), ShoppingList.status == "draft") ) await db_session.commit() async def test_creer_liste(client): resp = await client.post("/api/shopping/lists", json={"name": "TEST_semaine 1"}) assert resp.status_code == 201 data = resp.json() assert data["name"] == "TEST_semaine 1" assert data["status"] == "draft" assert data["item_count"] == 0 assert data["items"] == [] async def test_lister_listes(client): await client.post("/api/shopping/lists", json={"name": "TEST_liste A"}) await client.post("/api/shopping/lists", json={"name": "TEST_liste B"}) resp = await client.get("/api/shopping/lists") assert resp.status_code == 200 noms = [l["name"] for l in resp.json()] assert "TEST_liste A" in noms assert "TEST_liste B" in noms async def test_ajouter_article_custom(client): liste = (await client.post("/api/shopping/lists", json={"name": "TEST_ajout"})).json() list_id = liste["id"] resp = await client.post(f"/api/shopping/lists/{list_id}/items", json={ "custom_name": "Farine T55", "quantity": "1.5", "unit": "kg", }) assert resp.status_code == 201 data = resp.json() assert data["display_name"] == "Farine T55" assert data["is_checked"] is False assert data["carried_over"] is False async def test_ajouter_article_sans_nom_erreur(client): liste = (await client.post("/api/shopping/lists", json={"name": "TEST_sans nom"})).json() resp = await client.post(f"/api/shopping/lists/{liste['id']}/items", json={}) assert resp.status_code == 422 async def test_cocher_article(client): liste = (await client.post("/api/shopping/lists", json={"name": "TEST_cocher"})).json() list_id = liste["id"] item = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Lait"})).json() item_id = item["id"] resp = await client.patch(f"/api/shopping/lists/{list_id}/items/{item_id}", json={"is_checked": True}) assert resp.status_code == 200 assert resp.json()["is_checked"] is True async def test_supprimer_article(client): liste = (await client.post("/api/shopping/lists", json={"name": "TEST_suppr"})).json() list_id = liste["id"] item = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Beurre"})).json() item_id = item["id"] resp = await client.delete(f"/api/shopping/lists/{list_id}/items/{item_id}") assert resp.status_code == 204 detail = await client.get(f"/api/shopping/lists/{list_id}") assert all(i["id"] != item_id for i in detail.json()["items"]) async def test_terminer_courses_reporte_non_coches(client): liste = (await client.post("/api/shopping/lists", json={"name": "TEST_finish"})).json() list_id = liste["id"] await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Coché"}) item_non_coche = (await client.post(f"/api/shopping/lists/{list_id}/items", json={"custom_name": "Non coché"})).json() # Cocher le premier article items = (await client.get(f"/api/shopping/lists/{list_id}")).json()["items"] coche_id = next(i["id"] for i in items if i["display_name"] == "Coché") await client.patch(f"/api/shopping/lists/{list_id}/items/{coche_id}", json={"is_checked": True}) resp = await client.post(f"/api/shopping/lists/{list_id}/finish") assert resp.status_code == 200 assert resp.json()["status"] == "done" # Vérifier que la nouvelle liste draft a été créée avec l'article non coché all_lists = (await client.get("/api/shopping/lists")).json() new_drafts = [l for l in all_lists if l["status"] == "draft"] assert len(new_drafts) >= 1 async def test_detail_liste_404(client): resp = await client.get("/api/shopping/lists/00000000-0000-0000-0000-000000000000") assert resp.status_code == 404 async def test_lister_stores(client): resp = await client.get("/api/shopping/stores") assert resp.status_code == 200 assert isinstance(resp.json(), list) async def test_rechercher_produits(client): resp = await client.get("/api/shopping/products?q=lait&limit=5") assert resp.status_code == 200 data = resp.json() assert isinstance(data, list) assert len(data) <= 5 ``` - [ ] **Step 2 : Lancer les tests** ```bash docker compose exec backend python -m pytest backend/tests/test_shopping.py -v ``` Expected: `9 passed` - [ ] **Step 3 : Commit** ```bash rtk git add backend/tests/test_shopping.py rtk git commit -m "test(shopping): 9 tests d'intégration CRUD listes et articles" ``` --- ## Task 5 : Client API TypeScript **Files:** - Create: `frontend/src/api/shopping.ts` - [ ] **Step 1 : Créer le client fetch typé** ```typescript // frontend/src/api/shopping.ts export interface Store { id: string name: string location: string | null } export interface Product { id: string name: string brand: string | null category: string | null default_unit: string | null frequency_score: number } export interface ShoppingItem { id: string product_id: string | null custom_name: string | null display_name: string quantity: string | null unit: string | null is_checked: boolean price_recorded: string | null carried_over: boolean sort_order: number | null } export interface ShoppingList { id: string name: string | null store_id: string | null week_date: string | null status: 'draft' | 'active' | 'done' created_at: string item_count: number checked_count: number } export interface ShoppingListDetail extends ShoppingList { items: ShoppingItem[] } export interface ShoppingListCreate { name?: string store_id?: string week_date?: string } export interface ShoppingListUpdate { name?: string store_id?: string status?: 'draft' | 'active' | 'done' } export interface ShoppingItemCreate { product_id?: string custom_name?: string quantity?: string unit?: string } export interface ShoppingItemUpdate { is_checked?: boolean quantity?: string unit?: string price_recorded?: string } const BASE = '/api/shopping' async function handleResponse(res: Response): Promise { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) if (res.status === 204) return undefined as T return res.json() as Promise } export async function fetchStores(): Promise { return handleResponse(await fetch(`${BASE}/stores`)) } export async function searchProducts(q?: string): Promise { const qs = q ? `?q=${encodeURIComponent(q)}&limit=30` : '?limit=30' return handleResponse(await fetch(`${BASE}/products${qs}`)) } export async function fetchLists(): Promise { return handleResponse(await fetch(`${BASE}/lists`)) } export async function createList(data: ShoppingListCreate): Promise { return handleResponse(await fetch(`${BASE}/lists`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), })) } export async function fetchListDetail(id: string): Promise { return handleResponse(await fetch(`${BASE}/lists/${id}`)) } export async function updateList(id: string, data: ShoppingListUpdate): Promise { return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), })) } export async function deleteList(id: string): Promise { return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' })) } export async function addItem(listId: string, data: ShoppingItemCreate): Promise { return handleResponse(await fetch(`${BASE}/lists/${listId}/items`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), })) } export async function updateItem(listId: string, itemId: string, data: ShoppingItemUpdate): Promise { return handleResponse(await fetch(`${BASE}/lists/${listId}/items/${itemId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), })) } export async function deleteItem(listId: string, itemId: string): Promise { return handleResponse(await fetch(`${BASE}/lists/${listId}/items/${itemId}`, { method: 'DELETE' })) } export async function finishShopping(listId: string): Promise { return handleResponse(await fetch(`${BASE}/lists/${listId}/finish`, { method: 'POST' })) } ``` - [ ] **Step 2 : Vérifier que TypeScript compile** ```bash docker compose exec frontend npx tsc --noEmit 2>&1 | head -20 ``` Expected: aucune erreur (ou le conteneur frontend n'a pas tsc — vérifier dans l'image de dev) - [ ] **Step 3 : Commit** ```bash rtk git add frontend/src/api/shopping.ts rtk git commit -m "feat(shopping): client API TypeScript typé" ``` --- ## Task 6 : Composant Modal réutilisable **Files:** - Create: `frontend/src/components/Modal.tsx` - [ ] **Step 1 : Créer le composant Modal** ```tsx // frontend/src/components/Modal.tsx import { useEffect } from 'react' interface ModalProps { title: string onClose: () => void children: React.ReactNode width?: number } export default function Modal({ title, onClose, children, width = 480 }: ModalProps) { // Fermer sur Escape useEffect(() => { function onKey(e: KeyboardEvent) { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [onClose]) return (
e.stopPropagation()} className="glass" style={{ width: '100%', maxWidth: width, borderRadius: 12, padding: 20, display: 'flex', flexDirection: 'column', gap: 16, maxHeight: '90dvh', overflowY: 'auto', }} >

{title}

{children}
) } ``` - [ ] **Step 2 : Commit** ```bash rtk git add frontend/src/components/Modal.tsx rtk git commit -m "feat(ui): composant Modal réutilisable (overlay + Escape)" ``` --- ## Task 7 : Hook Wake Lock **Files:** - Create: `frontend/src/hooks/useWakeLock.ts` - [ ] **Step 1 : Créer le hook** ```typescript // frontend/src/hooks/useWakeLock.ts import { useEffect, useRef } from 'react' export function useWakeLock(active: boolean) { const lockRef = useRef(null) useEffect(() => { if (!active) { lockRef.current?.release().catch(() => {}) lockRef.current = null return } if (!('wakeLock' in navigator)) return navigator.wakeLock.request('screen').then(lock => { lockRef.current = lock }).catch(() => { // Wake Lock non disponible (mode économie d'énergie, navigateur non supporté) }) return () => { lockRef.current?.release().catch(() => {}) lockRef.current = null } }, [active]) } ``` - [ ] **Step 2 : Commit** ```bash rtk git add frontend/src/hooks/useWakeLock.ts rtk git commit -m "feat(shopping): hook useWakeLock avec fallback gracieux" ``` --- ## Task 8 : Composant ItemRow **Files:** - Create: `frontend/src/components/shopping/ItemRow.tsx` - [ ] **Step 1 : Créer le composant** ```tsx // frontend/src/components/shopping/ItemRow.tsx import { useRef, useState } from 'react' import type { ShoppingItem } from '../../api/shopping' interface ItemRowProps { item: ShoppingItem onCheck: () => void onDelete: () => void storeMode?: boolean // true = grands boutons, pas de swipe } const SWIPE_THRESHOLD = 80 export default function ItemRow({ item, onCheck, onDelete, storeMode = false }: ItemRowProps) { const [offsetX, setOffsetX] = useState(0) const [isDragging, setIsDragging] = useState(false) const startX = useRef(null) function onTouchStart(e: React.TouchEvent) { if (storeMode) return startX.current = e.touches[0].clientX setIsDragging(true) } function onTouchMove(e: React.TouchEvent) { if (startX.current === null) return const dx = e.touches[0].clientX - startX.current setOffsetX(Math.max(Math.min(dx, 0), -120)) // swipe gauche uniquement } function onTouchEnd() { if (offsetX < -SWIPE_THRESHOLD) onDelete() setOffsetX(0) setIsDragging(false) startX.current = null } const minHeight = storeMode ? 64 : 52 return (
{/* Zone suppression révélée par swipe gauche */} {!storeMode && (
)} {/* Ligne principale */}
{/* Checkbox visuelle */}
{item.is_checked && }
{/* Nom + métadonnées */}
{item.display_name} {item.carried_over && ( )}
{(item.quantity || item.unit) && (
{item.quantity}{item.unit ? ` ${item.unit}` : ''}
)}
{/* Bouton supprimer en mode store (pas de swipe) */} {storeMode && !item.is_checked && ( )}
) } ``` - [ ] **Step 2 : Commit** ```bash rtk git add frontend/src/components/shopping/ItemRow.tsx rtk git commit -m "feat(shopping): composant ItemRow avec swipe-to-delete et mode magasin" ``` --- ## Task 9 : Page ShoppingPage **Files:** - Modify: `frontend/src/pages/ShoppingPage.tsx` La page gère trois états via `view` : `'lists'` (liste des listes), `'detail'` (détail d'une liste), `'store'` (mode magasin plein-écran). - [ ] **Step 1 : Écrire la page complète** ```tsx // frontend/src/pages/ShoppingPage.tsx import { useState, useEffect, useCallback } from 'react' import type { ShoppingList, ShoppingListDetail, ShoppingItem, Store } from '../api/shopping' import { fetchLists, createList, fetchListDetail, updateList, deleteList, addItem, updateItem, deleteItem, finishShopping, fetchStores, } from '../api/shopping' import Modal from '../components/Modal' import ItemRow from '../components/shopping/ItemRow' import { useWakeLock } from '../hooks/useWakeLock' type View = 'lists' | 'detail' | 'store' const inputStyle: React.CSSProperties = { width: '100%', background: 'var(--bg-4)', border: '1px solid var(--bg-5)', borderRadius: 8, padding: '10px 12px', color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14, boxSizing: 'border-box', } const STATUS_LABELS: Record = { draft: 'Brouillon', active: 'En cours', done: 'Terminée', } const STATUS_COLORS: Record = { draft: 'var(--ink-3)', active: 'var(--ok)', done: 'var(--accent)', } // Catégories dans l'ordre du rayon const CATEGORIES = [ 'Fruits', 'Légumes', 'Viandes', 'Charcuterie', 'Poissons', 'Produits laitiers', 'Boulangerie', 'Épicerie salée', 'Épicerie sucrée', 'Condiments', 'Boissons', 'Entretien', 'Pharmacie', 'Animaux', 'Divers', ] function groupByCategory(items: ShoppingItem[]): Record { const result: Record = {} for (const item of items) { // La catégorie vient du produit — on groupe les items custom sous "Divers" const cat = 'Divers' // sera enrichi quand le produit est connu if (!result[cat]) result[cat] = [] result[cat].push(item) } return result } export default function ShoppingPage() { const [view, setView] = useState('lists') const [lists, setLists] = useState([]) const [activeList, setActiveList] = useState(null) const [stores, setStores] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showCreateModal, setShowCreateModal] = useState(false) const [showAddItemModal, setShowAddItemModal] = useState(false) // Formulaire création liste const [newListName, setNewListName] = useState('') const [newListStore, setNewListStore] = useState('') // Formulaire ajout article const [newItemName, setNewItemName] = useState('') const [newItemQty, setNewItemQty] = useState('') const [newItemUnit, setNewItemUnit] = useState('') // Wake Lock activé uniquement en mode magasin useWakeLock(view === 'store') const loadLists = useCallback(async () => { setLoading(true) setError(null) try { const [listsData, storesData] = await Promise.all([fetchLists(), fetchStores()]) setLists(listsData) setStores(storesData) } catch { setError('Erreur lors du chargement') } finally { setLoading(false) } }, []) useEffect(() => { void loadLists() }, [loadLists]) async function openList(list: ShoppingList) { try { const detail = await fetchListDetail(list.id) setActiveList(detail) setView('detail') } catch { setError('Erreur lors du chargement de la liste') } } async function refreshActiveList() { if (!activeList) return try { setActiveList(await fetchListDetail(activeList.id)) } catch { setError('Erreur lors du rafraîchissement') } } async function handleCreateList() { if (!newListName.trim()) return try { await createList({ name: newListName.trim(), store_id: newListStore || undefined, }) setNewListName('') setNewListStore('') setShowCreateModal(false) void loadLists() } catch { setError('Erreur lors de la création') } } async function handleDeleteList(id: string) { try { await deleteList(id) void loadLists() } catch { setError('Erreur lors de la suppression') } } async function handleAddItem() { if (!activeList || !newItemName.trim()) return try { await addItem(activeList.id, { custom_name: newItemName.trim(), quantity: newItemQty || undefined, unit: newItemUnit || undefined, }) setNewItemName('') setNewItemQty('') setNewItemUnit('') setShowAddItemModal(false) void refreshActiveList() } catch { setError("Erreur lors de l'ajout") } } async function handleCheckItem(itemId: string, checked: boolean) { if (!activeList) return try { await updateItem(activeList.id, itemId, { is_checked: checked }) void refreshActiveList() } catch { setError('Erreur lors du cochage') } } async function handleDeleteItem(itemId: string) { if (!activeList) return try { await deleteItem(activeList.id, itemId) void refreshActiveList() } catch { setError('Erreur lors de la suppression') } } async function handleFinish() { if (!activeList) return try { await finishShopping(activeList.id) setView('lists') setActiveList(null) void loadLists() } catch { setError('Erreur lors de la finalisation') } } // ── Vue mode magasin ────────────────────────────────────────────────────── if (view === 'store' && activeList) { const unchecked = activeList.items.filter(i => !i.is_checked) const checked = activeList.items.filter(i => i.is_checked) return (
{/* Header mode magasin */}
Mode magasin
{checked.length}/{activeList.item_count} cochés
{/* Liste des articles */}
{unchecked.map(item => ( void handleCheckItem(item.id, true)} onDelete={() => void handleDeleteItem(item.id)} storeMode /> ))} {checked.length > 0 && ( <>
Cochés ({checked.length})
{checked.map(item => ( void handleCheckItem(item.id, false)} onDelete={() => void handleDeleteItem(item.id)} storeMode /> ))} )}
) } // ── Vue détail d'une liste ───────────────────────────────────────────────── if (view === 'detail' && activeList) { return (
{/* Header */}

{activeList.name ?? 'Liste de courses'}

{activeList.checked_count}/{activeList.item_count} cochés
{error && (

{error}

)} {/* Articles */} {activeList.items.length === 0 ? (

Aucun article — ajoutez-en avec le bouton +

) : (
{activeList.items.map(item => ( void handleCheckItem(item.id, !item.is_checked)} onDelete={() => void handleDeleteItem(item.id)} /> ))}
)} {/* FAB ajout article */} {/* Modal ajout article */} {showAddItemModal && ( setShowAddItemModal(false)}> setNewItemName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && void handleAddItem()} />
setNewItemQty(e.target.value)} /> setNewItemUnit(e.target.value)} />
)}
) } // ── Vue liste des listes ─────────────────────────────────────────────────── return (

Courses

{error && (

{error}

)} {loading &&

Chargement…

} {!loading && lists.length === 0 && (

Aucune liste — créez-en une avec le bouton +

)} {/* Cartes des listes */}
{lists.map(list => (
void openList(list)} style={{ borderRadius: 10, padding: '14px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }} >
{list.name ?? 'Liste sans nom'}
{STATUS_LABELS[list.status]} {list.checked_count}/{list.item_count} articles
))}
{/* FAB */} {/* Modal création liste */} {showCreateModal && ( setShowCreateModal(false)}> setNewListName(e.target.value)} autoFocus onKeyDown={e => e.key === 'Enter' && void handleCreateList()} />
)}
) } ``` - [ ] **Step 2 : Rebuild et vérifier** ```bash docker compose build frontend && docker compose up -d frontend ``` Ouvrir `http://localhost:3001/shopping` et vérifier : - La liste des listes s'affiche (vide au premier lancement) - Le bouton + ouvre un modal (pas un panneau inline) - Créer une liste → elle apparaît dans la liste des cartes - Cliquer dessus → vue détail, FAB pour ajouter un article - Ajouter un article → modal, puis il apparaît dans la liste - Tapper un article → il se coche (fond légèrement vert) - Swipe gauche → suppression - Bouton "Mode magasin" → vue plein-écran, grands boutons - Bouton "Terminer" → retour à la liste des listes, statut "Terminée" - [ ] **Step 3 : Commit** ```bash rtk git add frontend/src/pages/ShoppingPage.tsx rtk git commit -m "feat(shopping): page complète — listes, détail, mode magasin Wake Lock" ``` --- ## Task 10 : Migrer TodoForm vers Modal **Files:** - Modify: `frontend/src/pages/TodosPage.tsx` Le formulaire de création/édition de todos doit utiliser le composant `Modal` au lieu du panneau inline. - [ ] **Step 1 : Mettre à jour les imports** Dans `frontend/src/pages/TodosPage.tsx`, ajouter l'import Modal : ```typescript import Modal from '../components/Modal' ``` - [ ] **Step 2 : Remplacer le panneau création par un Modal** Remplacer le bloc `{showForm && ...}` par : ```tsx {showForm && ( setShowForm(false)}> setShowForm(false)} extended /> )} ``` - [ ] **Step 3 : Remplacer le panneau édition par un Modal** Remplacer le bloc `{editingTodo && ...}` par : ```tsx {editingTodo && ( setEditingTodo(null)}> handleUpdate(editingTodo.id, data)} onCancel={() => setEditingTodo(null)} extended submitLabel="Enregistrer" initialValues={{ title: editingTodo.title, domain: editingTodo.domain ?? undefined, priority: editingTodo.priority, due_date: editingTodo.due_date ?? undefined, body: editingTodo.body ?? undefined, url: editingTodo.url ?? undefined, tags: editingTodo.tags, }} /> )} ``` - [ ] **Step 4 : Supprimer les classes CSS devenues inutiles** Supprimer le `className="hidden lg:block"` / `className="block lg:hidden"` qui distinguait les deux formulaires — le Modal est identique sur toutes tailles d'écran. - [ ] **Step 5 : Rebuild et vérifier** ```bash docker compose build frontend && docker compose up -d frontend ``` Vérifier sur `http://localhost:3001/todos` : - Le bouton + ouvre un modal centré (overlay sombre) - Le double-tap/double-clic sur un todo ouvre un modal d'édition pré-rempli - La touche Escape ferme le modal - Cliquer en dehors du modal le ferme - [ ] **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 ``` --- ## Auto-review **Spec coverage :** - ✅ CRUD listes (create, list, detail, update status, delete) - ✅ Ajout/suppression/cochage d'articles - ✅ Report des articles non cochés à la fin des courses (`finish`) - ✅ Mode magasin plein-écran avec Wake Lock - ✅ Modal pour tous les formulaires (plus de panneau inline) - ✅ Seed produits et magasins déjà en place - ✅ Migration TodoForm → Modal - ✅ 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[]`. `generateMagicList()` retourne `ShoppingListDetail`. `_list_to_response()` et `_item_to_response()` sont les seuls points de conversion ORM→schema. **Placeholder scan :** aucun TBD — toutes les erreurs catchées avec setError().