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 RESTbackend/tests/test_shopping.py— 9 tests d'intégrationfrontend/src/api/shopping.ts— client fetch typéfrontend/src/components/Modal.tsx— modal générique réutilisablefrontend/src/components/shopping/ItemRow.tsx— ligne article avec check + swipe suppressionfrontend/src/hooks/useWakeLock.ts— hook Wake Lock API avec fallback
Modifier :
backend/app/models/shopping.py— ajouter relationproductsurListItembackend/app/main.py— enregistrer le router shoppingfrontend/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
productsurListItem
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 :
- Vue "listes" : bouton ✨ "Liste magique" dans le header, désactivé si une liste
draftouactiveexiste - 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/activeexiste → 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().