Files
home_hub/docs/superpowers/plans/2026-05-24-phase3-shopping.md
T

67 KiB

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 :

# 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 :

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
docker compose exec backend python -c "from app.models.shopping import ListItem; print('OK')"

Expected: OK

  • Step 3 : Commit
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

# 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
docker compose exec backend python -c "from app.schemas.shopping import ShoppingListCreate; print('OK')"

Expected: OK

  • Step 3 : Commit
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

# 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 :

from app.api.shopping import router as shopping_router

Et après app.include_router(todos_router, prefix="/api/todos") :

app.include_router(shopping_router, prefix="/api/shopping")
  • Step 3 : Vérifier que les routes sont enregistrées
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
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

# 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
docker compose exec backend python -m pytest backend/tests/test_shopping.py -v

Expected: 9 passed

  • Step 3 : Commit
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é

// 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<T>(res: Response): Promise<T> {
  if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
  if (res.status === 204) return undefined as T
  return res.json() as Promise<T>
}

export async function fetchStores(): Promise<Store[]> {
  return handleResponse(await fetch(`${BASE}/stores`))
}

export async function searchProducts(q?: string): Promise<Product[]> {
  const qs = q ? `?q=${encodeURIComponent(q)}&limit=30` : '?limit=30'
  return handleResponse(await fetch(`${BASE}/products${qs}`))
}

export async function fetchLists(): Promise<ShoppingList[]> {
  return handleResponse(await fetch(`${BASE}/lists`))
}

export async function createList(data: ShoppingListCreate): Promise<ShoppingListDetail> {
  return handleResponse(await fetch(`${BASE}/lists`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  }))
}

export async function fetchListDetail(id: string): Promise<ShoppingListDetail> {
  return handleResponse(await fetch(`${BASE}/lists/${id}`))
}

export async function updateList(id: string, data: ShoppingListUpdate): Promise<ShoppingListDetail> {
  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<void> {
  return handleResponse(await fetch(`${BASE}/lists/${id}`, { method: 'DELETE' }))
}

export async function addItem(listId: string, data: ShoppingItemCreate): Promise<ShoppingItem> {
  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<ShoppingItem> {
  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<void> {
  return handleResponse(await fetch(`${BASE}/lists/${listId}/items/${itemId}`, { method: 'DELETE' }))
}

export async function finishShopping(listId: string): Promise<ShoppingListDetail> {
  return handleResponse(await fetch(`${BASE}/lists/${listId}/finish`, { method: 'POST' }))
}
  • Step 2 : Vérifier que TypeScript compile
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
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

// 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 (
    <div
      onClick={onClose}
      style={{
        position: 'fixed',
        inset: 0,
        background: 'rgba(0,0,0,0.6)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: 200,
        padding: 16,
      }}
    >
      <div
        onClick={e => e.stopPropagation()}
        className="glass"
        style={{
          width: '100%',
          maxWidth: width,
          borderRadius: 12,
          padding: 20,
          display: 'flex',
          flexDirection: 'column',
          gap: 16,
          maxHeight: '90dvh',
          overflowY: 'auto',
        }}
      >
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
          <h2 style={{ margin: 0, color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 16 }}>
            {title}
          </h2>
          <button
            onClick={onClose}
            style={{
              background: 'transparent',
              border: 'none',
              color: 'var(--ink-3)',
              fontSize: 20,
              cursor: 'pointer',
              lineHeight: 1,
              padding: 4,
            }}
            aria-label="Fermer"
          >
            
          </button>
        </div>
        {children}
      </div>
    </div>
  )
}
  • Step 2 : Commit
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

// frontend/src/hooks/useWakeLock.ts
import { useEffect, useRef } from 'react'

export function useWakeLock(active: boolean) {
  const lockRef = useRef<WakeLockSentinel | null>(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
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

// 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<number | null>(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 (
    <div style={{ position: 'relative', overflow: 'hidden' }}>
      {/* Zone suppression révélée par swipe gauche */}
      {!storeMode && (
        <div style={{
          position: 'absolute', right: 0, top: 0, bottom: 0,
          width: 120, display: 'flex', alignItems: 'center', justifyContent: 'center',
          background: 'var(--err)',
          opacity: offsetX < -20 ? 1 : 0,
          transition: 'opacity 0.15s',
        }}>
          <span style={{ color: '#fff', fontSize: 20 }}></span>
        </div>
      )}

      {/* Ligne principale */}
      <div
        onTouchStart={onTouchStart}
        onTouchMove={onTouchMove}
        onTouchEnd={onTouchEnd}
        onClick={onCheck}
        style={{
          transform: `translateX(${offsetX}px)`,
          transition: isDragging ? 'none' : 'transform 0.2s ease',
          display: 'flex',
          alignItems: 'center',
          gap: 12,
          padding: storeMode ? '14px 16px' : '10px 16px',
          minHeight,
          cursor: 'pointer',
          background: item.is_checked ? 'rgba(142,192,124,0.08)' : 'transparent',
          borderBottom: '1px solid var(--bg-4)',
          userSelect: 'none',
        }}
      >
        {/* Checkbox visuelle */}
        <div style={{
          width: storeMode ? 28 : 22,
          height: storeMode ? 28 : 22,
          borderRadius: '50%',
          border: `2px solid ${item.is_checked ? 'var(--ok)' : 'var(--bg-5)'}`,
          background: item.is_checked ? 'var(--ok)' : 'transparent',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          flexShrink: 0,
          transition: 'all 0.15s',
        }}>
          {item.is_checked && <span style={{ color: '#1d2021', fontSize: storeMode ? 16 : 12, fontWeight: 700 }}></span>}
        </div>

        {/* Nom + métadonnées */}
        <div style={{ flex: 1, minWidth: 0 }}>
          <div style={{
            color: item.is_checked ? 'var(--ink-3)' : 'var(--ink-1)',
            fontSize: storeMode ? 18 : 14,
            fontFamily: 'var(--font-ui)',
            textDecoration: item.is_checked ? 'line-through' : 'none',
            overflow: 'hidden',
            textOverflow: 'ellipsis',
            whiteSpace: 'nowrap',
          }}>
            {item.display_name}
            {item.carried_over && (
              <span style={{ fontSize: 10, color: 'var(--info)', marginLeft: 6 }}></span>
            )}
          </div>
          {(item.quantity || item.unit) && (
            <div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2, fontFamily: 'var(--font-mono)' }}>
              {item.quantity}{item.unit ? ` ${item.unit}` : ''}
            </div>
          )}
        </div>

        {/* Bouton supprimer en mode store (pas de swipe) */}
        {storeMode && !item.is_checked && (
          <button
            onClick={e => { e.stopPropagation(); onDelete() }}
            style={{
              background: 'transparent',
              border: 'none',
              color: 'var(--ink-4)',
              fontSize: 18,
              cursor: 'pointer',
              padding: '4px 8px',
              minHeight: 44,
            }}
          ></button>
        )}
      </div>
    </div>
  )
}
  • Step 2 : Commit
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
// 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<string, string> = {
  draft: 'Brouillon',
  active: 'En cours',
  done: 'Terminée',
}

const STATUS_COLORS: Record<string, string> = {
  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<string, ShoppingItem[]> {
  const result: Record<string, ShoppingItem[]> = {}
  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<View>('lists')
  const [lists, setLists] = useState<ShoppingList[]>([])
  const [activeList, setActiveList] = useState<ShoppingListDetail | null>(null)
  const [stores, setStores] = useState<Store[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(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 (
      <div style={{
        position: 'fixed', inset: 0, background: 'var(--bg-1)',
        display: 'flex', flexDirection: 'column', zIndex: 100,
      }}>
        {/* Header mode magasin */}
        <div style={{
          background: 'var(--bg-2)',
          padding: '12px 16px',
          display: 'flex',
          alignItems: 'center',
          gap: 12,
          borderBottom: '1px solid var(--bg-4)',
        }}>
          <button
            onClick={() => setView('detail')}
            style={{ background: 'transparent', border: 'none', color: 'var(--ink-2)', fontSize: 20, cursor: 'pointer', padding: 4 }}
          ></button>
          <div style={{ flex: 1 }}>
            <div style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontSize: 14 }}>Mode magasin</div>
            <div style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
              {checked.length}/{activeList.item_count} cochés
            </div>
          </div>
          <button
            onClick={handleFinish}
            style={{
              background: 'var(--ok)', color: '#1d2021', border: 'none',
              borderRadius: 8, padding: '10px 16px', fontFamily: 'var(--font-ui)',
              fontWeight: 700, fontSize: 14, cursor: 'pointer', minHeight: 48,
            }}
          >Terminer </button>
        </div>

        {/* Liste des articles */}
        <div style={{ flex: 1, overflowY: 'auto' }}>
          {unchecked.map(item => (
            <ItemRow
              key={item.id}
              item={item}
              onCheck={() => void handleCheckItem(item.id, true)}
              onDelete={() => void handleDeleteItem(item.id)}
              storeMode
            />
          ))}
          {checked.length > 0 && (
            <>
              <div style={{ padding: '8px 16px', color: 'var(--ink-4)', fontSize: 11, fontFamily: 'var(--font-ui)', textTransform: 'uppercase', letterSpacing: 1 }}>
                Cochés ({checked.length})
              </div>
              {checked.map(item => (
                <ItemRow
                  key={item.id}
                  item={item}
                  onCheck={() => void handleCheckItem(item.id, false)}
                  onDelete={() => void handleDeleteItem(item.id)}
                  storeMode
                />
              ))}
            </>
          )}
        </div>
      </div>
    )
  }

  // ── Vue détail d'une liste ─────────────────────────────────────────────────
  if (view === 'detail' && activeList) {
    return (
      <div className="p-4">
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
          <button
            onClick={() => { setView('lists'); void loadLists() }}
            style={{ background: 'transparent', border: 'none', color: 'var(--ink-2)', fontSize: 20, cursor: 'pointer', padding: 4 }}
          ></button>
          <div style={{ flex: 1 }}>
            <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, fontSize: 18 }}>
              {activeList.name ?? 'Liste de courses'}
            </h1>
            <div style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>
              {activeList.checked_count}/{activeList.item_count} cochés
            </div>
          </div>
          <button
            onClick={() => setView('store')}
            style={{
              background: 'var(--accent)', color: '#1d2021', border: 'none',
              borderRadius: 8, padding: '8px 14px', fontFamily: 'var(--font-ui)',
              fontWeight: 600, fontSize: 13, cursor: 'pointer', minHeight: 44,
            }}
          >Mode magasin 🛒</button>
        </div>

        {error && (
          <p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
            {error}
          </p>
        )}

        {/* Articles */}
        {activeList.items.length === 0 ? (
          <p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
            Aucun article  ajoutez-en avec le bouton +
          </p>
        ) : (
          <div className="glass" style={{ borderRadius: 10, overflow: 'hidden', marginBottom: 80 }}>
            {activeList.items.map(item => (
              <ItemRow
                key={item.id}
                item={item}
                onCheck={() => void handleCheckItem(item.id, !item.is_checked)}
                onDelete={() => void handleDeleteItem(item.id)}
              />
            ))}
          </div>
        )}

        {/* FAB ajout article */}
        <button
          onClick={() => setShowAddItemModal(true)}
          aria-label="Ajouter un article"
          style={{
            position: 'fixed', bottom: 72, right: 20,
            width: 56, height: 56, borderRadius: '50%',
            background: 'var(--accent)', color: '#1d2021', border: 'none',
            fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}
        >+</button>

        {/* Modal ajout article */}
        {showAddItemModal && (
          <Modal title="Ajouter un article" onClose={() => setShowAddItemModal(false)}>
            <input
              style={inputStyle}
              placeholder="Nom de l'article *"
              value={newItemName}
              onChange={e => setNewItemName(e.target.value)}
              autoFocus
              onKeyDown={e => e.key === 'Enter' && void handleAddItem()}
            />
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
              <input
                style={inputStyle}
                placeholder="Quantité"
                value={newItemQty}
                onChange={e => setNewItemQty(e.target.value)}
              />
              <input
                style={inputStyle}
                placeholder="Unité (kg, L…)"
                value={newItemUnit}
                onChange={e => setNewItemUnit(e.target.value)}
              />
            </div>
            <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
              <button
                onClick={() => setShowAddItemModal(false)}
                style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
              >Annuler</button>
              <button
                onClick={() => void handleAddItem()}
                style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
              >Ajouter</button>
            </div>
          </Modal>
        )}
      </div>
    )
  }

  // ── Vue liste des listes ───────────────────────────────────────────────────
  return (
    <div className="p-4">
      <div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
        <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
      </div>

      {error && (
        <p style={{ color: 'var(--err)', background: 'var(--bg-3)', borderRadius: 8, padding: '8px 12px', marginBottom: 12, fontSize: 13, fontFamily: 'var(--font-ui)' }}>
          {error}
        </p>
      )}

      {loading && <p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement</p>}

      {!loading && lists.length === 0 && (
        <p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>
          Aucune liste  créez-en une avec le bouton +
        </p>
      )}

      {/* Cartes des listes */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
        {lists.map(list => (
          <div
            key={list.id}
            className="glass interactive"
            onClick={() => void openList(list)}
            style={{ borderRadius: 10, padding: '14px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 12 }}
          >
            <div style={{ flex: 1, minWidth: 0 }}>
              <div style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 15, fontWeight: 500 }}>
                {list.name ?? 'Liste sans nom'}
              </div>
              <div style={{ display: 'flex', gap: 12, marginTop: 4, alignItems: 'center' }}>
                <span style={{ color: STATUS_COLORS[list.status], fontSize: 11, fontFamily: 'var(--font-ui)', textTransform: 'uppercase', letterSpacing: 0.5 }}>
                  {STATUS_LABELS[list.status]}
                </span>
                <span style={{ color: 'var(--ink-3)', fontSize: 11, fontFamily: 'var(--font-mono)' }}>
                  {list.checked_count}/{list.item_count} articles
                </span>
              </div>
            </div>
            <button
              onClick={e => { e.stopPropagation(); void handleDeleteList(list.id) }}
              style={{ background: 'transparent', border: 'none', color: 'var(--ink-4)', fontSize: 18, cursor: 'pointer', padding: '4px 8px', minHeight: 44 }}
              title="Supprimer la liste"
            ></button>
          </div>
        ))}
      </div>

      {/* FAB */}
      <button
        onClick={() => setShowCreateModal(true)}
        aria-label="Nouvelle liste"
        style={{
          position: 'fixed', bottom: 72, right: 20,
          width: 56, height: 56, borderRadius: '50%',
          background: 'var(--accent)', color: '#1d2021', border: 'none',
          fontSize: 28, cursor: 'pointer', boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
        }}
      >+</button>

      {/* Modal création liste */}
      {showCreateModal && (
        <Modal title="Nouvelle liste de courses" onClose={() => setShowCreateModal(false)}>
          <input
            style={inputStyle}
            placeholder="Nom de la liste (ex: Semaine du 26 mai)"
            value={newListName}
            onChange={e => setNewListName(e.target.value)}
            autoFocus
            onKeyDown={e => e.key === 'Enter' && void handleCreateList()}
          />
          <select
            style={inputStyle}
            value={newListStore}
            onChange={e => setNewListStore(e.target.value)}
          >
            <option value="">Magasin (optionnel)</option>
            {stores.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
          </select>
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button
              onClick={() => setShowCreateModal(false)}
              style={{ padding: '10px 16px', borderRadius: 8, border: '1px solid var(--bg-5)', background: 'transparent', color: 'var(--ink-2)', cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
            >Annuler</button>
            <button
              onClick={() => void handleCreateList()}
              style={{ padding: '10px 20px', borderRadius: 8, border: 'none', background: 'var(--accent)', color: '#1d2021', cursor: 'pointer', fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48 }}
            >Créer</button>
          </div>
        </Modal>
      )}
    </div>
  )
}
  • Step 2 : Rebuild et vérifier
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

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 :

import Modal from '../components/Modal'
  • Step 2 : Remplacer le panneau création par un Modal

Remplacer le bloc {showForm && ...} par :

{showForm && (
  <Modal title="Nouvelle tâche" onClose={() => setShowForm(false)}>
    <TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} extended />
  </Modal>
)}
  • Step 3 : Remplacer le panneau édition par un Modal

Remplacer le bloc {editingTodo && ...} par :

{editingTodo && (
  <Modal title="Modifier la tâche" onClose={() => setEditingTodo(null)}>
    <TodoForm
      onSubmit={data => 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,
      }}
    />
  </Modal>
)}
  • 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
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

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 :

from sqlalchemy import func, text
from datetime import date, timedelta

Puis ajouter l'endpoint (à placer AVANT @router.post("/lists/{list_id}/finish")) :

@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 :

export async function generateMagicList(): Promise<ShoppingListDetail> {
  return handleResponse(await fetch(`${BASE}/lists/generate`, { method: 'POST' }))
}
  • Step 3 : Tester manuellement
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
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 :

import { generateMagicList } from '../api/shopping'

Dans le composant, ajouter les states :

const [showEditListModal, setShowEditListModal] = useState(false)
const [generating, setGenerating] = useState(false)
  • Step 2 : Handler génération liste magique

Ajouter après handleFinish :

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 :

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 :

<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
  <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
</div>

par :

<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
  <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1 }}>Courses</h1>
  <button
    onClick={() => void handleGenerateMagicList()}
    disabled={generating || lists.some(l => l.status === 'draft' || l.status === 'active')}
    title="Générer une liste automatiquement"
    style={{
      background: 'var(--bg-3)',
      border: '1px solid var(--bg-5)',
      borderRadius: 8,
      color: lists.some(l => l.status === 'draft' || l.status === 'active')
        ? 'var(--ink-4)'
        : 'var(--accent)',
      cursor: lists.some(l => l.status === 'draft' || l.status === 'active')
        ? 'not-allowed'
        : 'pointer',
      padding: '8px 12px',
      fontFamily: 'var(--font-ui)',
      fontSize: 13,
      display: 'flex',
      alignItems: 'center',
      gap: 6,
      minHeight: 44,
    }}
  >
    <i className="fa-solid fa-wand-magic-sparkles" />
    {generating ? 'Génération…' : 'Liste magique'}
  </button>
</div>
  • Step 5 : Bouton édition dans le header de la vue "détail"

Dans le header de la vue "détail" (bloc if (view === 'detail' && activeList)), ajouter le bouton ✏️ entre le titre et le bouton "Mode magasin" :

<button
  onClick={() => setShowEditListModal(true)}
  title="Modifier / gérer la liste"
  style={{
    background: 'var(--bg-3)',
    border: '1px solid var(--bg-5)',
    borderRadius: 8,
    color: 'var(--ink-2)',
    cursor: 'pointer',
    padding: '8px 12px',
    fontSize: 16,
    minHeight: 44,
  }}
>
  <i className="fa-solid fa-pen" />
</button>
  • Step 6 : Modal édition liste (ajout article + suppression)

Dans la vue "détail", ajouter après le modal d'ajout d'article existant :

{showEditListModal && (
  <Modal title="Gérer la liste" onClose={() => setShowEditListModal(false)}>
    {/* Ajout rapide d'article */}
    <p style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)', margin: 0, textTransform: 'uppercase', letterSpacing: 1 }}>
      Ajouter un article
    </p>
    <input
      style={inputStyle}
      placeholder="Nom de l'article *"
      value={newItemName}
      onChange={e => setNewItemName(e.target.value)}
      autoFocus
      onKeyDown={e => e.key === 'Enter' && void handleAddItem().then(() => setShowEditListModal(false))}
    />
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
      <input
        style={inputStyle}
        placeholder="Quantité"
        value={newItemQty}
        onChange={e => setNewItemQty(e.target.value)}
      />
      <input
        style={inputStyle}
        placeholder="Unité (kg, L…)"
        value={newItemUnit}
        onChange={e => setNewItemUnit(e.target.value)}
      />
    </div>
    <button
      onClick={() => void handleAddItem().then(() => { if (!error) setShowEditListModal(false) })}
      style={{
        padding: '10px 20px', borderRadius: 8, border: 'none',
        background: 'var(--accent)', color: '#1d2021', cursor: 'pointer',
        fontFamily: 'var(--font-ui)', fontWeight: 600, minHeight: 48, width: '100%',
      }}
    >
      <i className="fa-solid fa-plus" style={{ marginRight: 8 }} />
      Ajouter
    </button>

    {/* Séparateur */}
    <div style={{ borderTop: '1px solid var(--bg-4)', margin: '4px 0' }} />

    {/* Suppression liste */}
    <button
      onClick={() => {
        setShowEditListModal(false)
        void handleDeleteActiveList()
      }}
      style={{
        padding: '10px 20px', borderRadius: 8,
        border: '1px solid var(--err)',
        background: 'transparent', color: 'var(--err)',
        cursor: 'pointer', fontFamily: 'var(--font-ui)',
        fontWeight: 600, minHeight: 48, width: '100%',
        display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
      }}
    >
      <i className="fa-solid fa-trash" />
      Supprimer la liste en cours
    </button>
  </Modal>
)}
  • Step 7 : Rebuild et vérifier
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

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().