diff --git a/docs/superpowers/plans/2026-05-24-phase3-shopping.md b/docs/superpowers/plans/2026-05-24-phase3-shopping.md new file mode 100644 index 0000000..40d913e --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-phase3-shopping.md @@ -0,0 +1,1724 @@ +# 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, 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. + +**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 final** + +```bash +rtk git add frontend/src/pages/TodosPage.tsx +rtk git commit -m "refactor(todos): formulaires création et édition migrés vers Modal" +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 +- ❌ Hors scope Phase 3 : prix OCR, auto-fill fréquence, catalogue CRUD laptop, historique prix + +**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. + +**Placeholder scan :** aucun TBD, aucun "handle edge cases" — toutes les erreurs catchées avec setError().