# Phase 2 — Module Todos — 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 Todos complet : 5 endpoints CRUD + postpone côté backend, et deux vues frontend responsives (liste swipeable mobile + tableau filtrable laptop). **Architecture:** Le backend ajoute `backend/app/schemas/todos.py` (Pydantic), `backend/app/api/todos.py` (5 routes) enregistré dans `main.py`. Le modèle `TodoItem` existe déjà (`backend/app/models/todos.py`, table `todos.items`). Le frontend ajoute un client API TypeScript, deux composants réutilisables (SwipeableRow, TodoForm) et la page TodosPage qui bascule entre vue mobile groupée par domaine et tableau laptop via les classes Tailwind `block lg:hidden` / `hidden lg:block`. **Tech Stack:** FastAPI 0.115 · SQLAlchemy 2.0 async · Pydantic v2 · React 18 · TypeScript strict · Tailwind CSS 3 · design system Gruvbox (CSS variables + ui-kit.tsx) --- ## Carte des fichiers | Statut | Chemin | Rôle | |--------|--------|------| | Créer | `backend/app/schemas/todos.py` | TodoCreate, TodoUpdate, PostponeRequest, TodoResponse | | Créer | `backend/app/api/todos.py` | 5 endpoints REST | | Modifier | `backend/app/main.py` | Enregistrement du routeur `/api/todos` | | Modifier | `backend/tests/conftest.py` | Ajout fixture `db_session` | | Créer | `backend/tests/test_todos.py` | 9 tests d'intégration | | Créer | `frontend/src/api/todos.ts` | Client fetch + interface Todo | | Créer | `frontend/src/components/todos/SwipeableRow.tsx` | Swipe touch (seuil 80px) | | Créer | `frontend/src/components/todos/TodoForm.tsx` | Formulaire rapide / étendu | | Créer | `frontend/src/pages/TodosPage.tsx` | Vue mobile + vue laptop | | Modifier | `frontend/src/App.tsx` | Route `/todos` | --- ## Tâche 1 : Schémas Pydantic + tests qui échouent **Fichiers :** - Créer : `backend/app/schemas/todos.py` - Modifier : `backend/tests/conftest.py` - Créer : `backend/tests/test_todos.py` - [ ] **Étape 1 : Créer les schémas Pydantic** ```python # backend/app/schemas/todos.py import uuid from datetime import datetime from pydantic import BaseModel, ConfigDict class TodoCreate(BaseModel): title: str body: str | None = None url: str | None = None domain: str | None = None category: str | None = None tags: list[str] = [] status: str = "pending" priority: str = "medium" due_date: datetime | None = None class TodoUpdate(BaseModel): title: str | None = None body: str | None = None url: str | None = None domain: str | None = None category: str | None = None tags: list[str] | None = None status: str | None = None priority: str | None = None due_date: datetime | None = None class PostponeRequest(BaseModel): days: int # 1 ou 7 class TodoResponse(BaseModel): model_config = ConfigDict(from_attributes=True) id: uuid.UUID title: str body: str | None url: str | None domain: str | None category: str | None tags: list[str] status: str priority: str due_date: datetime | None postponed_count: int created_at: datetime updated_at: datetime | None owner_id: uuid.UUID | None ``` - [ ] **Étape 2 : Ajouter la fixture `db_session` dans conftest** ```python # backend/tests/conftest.py import pytest from httpx import AsyncClient, ASGITransport from app.main import app from app.core.database import AsyncSessionLocal @pytest.fixture async def client(): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac @pytest.fixture async def db_session(): async with AsyncSessionLocal() as session: yield session ``` - [ ] **Étape 3 : Écrire tous les tests (ils échoueront — endpoints absents)** ```python # backend/tests/test_todos.py import pytest from sqlalchemy import delete from app.models.todos import TodoItem @pytest.fixture(autouse=True) async def cleanup(db_session): yield await db_session.execute(delete(TodoItem).where(TodoItem.title.like("TEST_%"))) await db_session.commit() async def test_creer_todo(client): resp = await client.post("/api/todos/", json={ "title": "TEST_tâche simple", "domain": "informatique", "priority": "high", }) assert resp.status_code == 201 data = resp.json() assert data["title"] == "TEST_tâche simple" assert data["status"] == "pending" assert data["postponed_count"] == 0 assert data["tags"] == [] async def test_lister_todos_filtre_status(client): await client.post("/api/todos/", json={"title": "TEST_en cours", "status": "pending"}) await client.post("/api/todos/", json={"title": "TEST_terminée", "status": "done"}) resp = await client.get("/api/todos/?status=pending") assert resp.status_code == 200 titres = [t["title"] for t in resp.json()] assert "TEST_en cours" in titres assert "TEST_terminée" not in titres async def test_lister_todos_filtre_domaine(client): await client.post("/api/todos/", json={"title": "TEST_info", "domain": "informatique"}) await client.post("/api/todos/", json={"title": "TEST_jardin", "domain": "jardin"}) resp = await client.get("/api/todos/?domain=informatique&status=") assert resp.status_code == 200 titres = [t["title"] for t in resp.json()] assert "TEST_info" in titres assert "TEST_jardin" not in titres async def test_mettre_a_jour_todo(client): cr = await client.post("/api/todos/", json={"title": "TEST_avant"}) item_id = cr.json()["id"] resp = await client.patch(f"/api/todos/{item_id}", json={"title": "TEST_après", "status": "done"}) assert resp.status_code == 200 assert resp.json()["title"] == "TEST_après" assert resp.json()["status"] == "done" assert resp.json()["updated_at"] is not None async def test_mettre_a_jour_todo_inexistant(client): resp = await client.patch( "/api/todos/00000000-0000-0000-0000-000000000000", json={"title": "TEST_ghost"}, ) assert resp.status_code == 404 async def test_supprimer_todo(client): cr = await client.post("/api/todos/", json={"title": "TEST_à supprimer"}) item_id = cr.json()["id"] resp = await client.delete(f"/api/todos/{item_id}") assert resp.status_code == 204 resp2 = await client.patch(f"/api/todos/{item_id}", json={"title": "TEST_fantôme"}) assert resp2.status_code == 404 async def test_reporter_todo_1_jour(client): due = "2026-06-01T10:00:00+00:00" cr = await client.post("/api/todos/", json={"title": "TEST_reporter", "due_date": due}) item_id = cr.json()["id"] resp = await client.post(f"/api/todos/{item_id}/postpone", json={"days": 1}) assert resp.status_code == 200 data = resp.json() assert data["postponed_count"] == 1 assert data["due_date"].startswith("2026-06-02") async def test_reporter_todo_1_semaine(client): due = "2026-06-01T10:00:00+00:00" cr = await client.post("/api/todos/", json={"title": "TEST_reporter7", "due_date": due}) item_id = cr.json()["id"] resp = await client.post(f"/api/todos/{item_id}/postpone", json={"days": 7}) assert resp.status_code == 200 data = resp.json() assert data["postponed_count"] == 1 assert data["due_date"].startswith("2026-06-08") async def test_reporter_jours_invalides(client): cr = await client.post("/api/todos/", json={"title": "TEST_invalide"}) item_id = cr.json()["id"] resp = await client.post(f"/api/todos/{item_id}/postpone", json={"days": 3}) assert resp.status_code == 422 ``` - [ ] **Étape 4 : Lancer les tests pour vérifier qu'ils échouent** ```bash docker compose exec backend python -m pytest tests/test_todos.py -v 2>&1 | head -40 ``` Résultat attendu : `9 failed` — les endpoints `/api/todos/` retournent 404 ou 405. - [ ] **Étape 5 : Commit** ```bash rtk git add backend/app/schemas/todos.py backend/tests/conftest.py backend/tests/test_todos.py rtk git commit -m "test(todos): schémas Pydantic + 9 tests d'intégration todos (en échec)" ``` --- ## Tâche 2 : Endpoints CRUD + enregistrement du routeur **Fichiers :** - Créer : `backend/app/api/todos.py` - Modifier : `backend/app/main.py` - [ ] **Étape 1 : Créer les endpoints** ```python # backend/app/api/todos.py import uuid from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response from sqlalchemy import select, and_ from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_session from app.models.todos import TodoItem from app.schemas.todos import TodoCreate, TodoUpdate, PostponeRequest, TodoResponse router = APIRouter() @router.get("/", response_model=list[TodoResponse]) async def list_todos( domain: str | None = None, status: str | None = "pending", priority: str | None = None, tag: str | None = None, due_after: str | None = None, due_before: str | None = None, limit: int = 200, session: AsyncSession = Depends(get_session), ): conditions = [] if domain: conditions.append(TodoItem.domain == domain) if status: conditions.append(TodoItem.status == status) if priority: conditions.append(TodoItem.priority == priority) if tag: conditions.append(TodoItem.tags.contains([tag])) if due_after: conditions.append(TodoItem.due_date >= datetime.fromisoformat(due_after)) if due_before: conditions.append(TodoItem.due_date <= datetime.fromisoformat(due_before)) stmt = select(TodoItem) if conditions: stmt = stmt.where(and_(*conditions)) stmt = stmt.limit(limit) result = await session.execute(stmt) return result.scalars().all() @router.post("/", response_model=TodoResponse, status_code=201) async def create_todo( payload: TodoCreate, session: AsyncSession = Depends(get_session), ): item = TodoItem(**payload.model_dump()) session.add(item) await session.commit() await session.refresh(item) return item @router.patch("/{item_id}", response_model=TodoResponse) async def update_todo( item_id: uuid.UUID, payload: TodoUpdate, session: AsyncSession = Depends(get_session), ): item = await session.get(TodoItem, item_id) if not item: raise HTTPException(status_code=404, detail="Tâche introuvable") for field, value in payload.model_dump(exclude_unset=True).items(): setattr(item, field, value) item.updated_at = datetime.now(timezone.utc) await session.commit() await session.refresh(item) return item @router.delete("/{item_id}", status_code=204) async def delete_todo( item_id: uuid.UUID, session: AsyncSession = Depends(get_session), ): item = await session.get(TodoItem, item_id) if not item: raise HTTPException(status_code=404, detail="Tâche introuvable") await session.delete(item) await session.commit() return Response(status_code=204) @router.post("/{item_id}/postpone", response_model=TodoResponse) async def postpone_todo( item_id: uuid.UUID, payload: PostponeRequest, session: AsyncSession = Depends(get_session), ): item = await session.get(TodoItem, item_id) if not item: raise HTTPException(status_code=404, detail="Tâche introuvable") if payload.days not in (1, 7): raise HTTPException(status_code=422, detail="days doit être 1 ou 7") now = datetime.now(timezone.utc) base = item.due_date if item.due_date else now item.due_date = base + timedelta(days=payload.days) item.postponed_count += 1 item.updated_at = now await session.commit() await session.refresh(item) return item ``` - [ ] **Étape 2 : Enregistrer le routeur dans main.py** ```python # backend/app/main.py from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.health import router as health_router from app.api.media import router as media_router from app.api.todos import router as todos_router from app.core.config import settings from app.data.seed import run_seed @asynccontextmanager async def lifespan(app: FastAPI): await run_seed() yield app = FastAPI(title="HomeHub API", version="0.1.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(health_router, prefix="/api") app.include_router(media_router, prefix="/api/media") app.include_router(todos_router, prefix="/api/todos") ``` - [ ] **Étape 3 : Lancer tous les tests** ```bash docker compose exec backend python -m pytest tests/ -v ``` Résultat attendu : ``` tests/test_health.py::test_health_retourne_ok PASSED tests/test_media.py::test_upload_image PASSED tests/test_media.py::... (5 tests media) tests/test_todos.py::test_creer_todo PASSED tests/test_todos.py::test_lister_todos_filtre_status PASSED tests/test_todos.py::test_lister_todos_filtre_domaine PASSED tests/test_todos.py::test_mettre_a_jour_todo PASSED tests/test_todos.py::test_mettre_a_jour_todo_inexistant PASSED tests/test_todos.py::test_supprimer_todo PASSED tests/test_todos.py::test_reporter_todo_1_jour PASSED tests/test_todos.py::test_reporter_todo_1_semaine PASSED tests/test_todos.py::test_reporter_jours_invalides PASSED 15 passed ``` - [ ] **Étape 4 : Commit** ```bash rtk git add backend/app/api/todos.py backend/app/main.py rtk git commit -m "feat(todos): endpoints CRUD + postpone — 9/9 tests passent" ``` --- ## Tâche 3 : Client API TypeScript **Fichiers :** - Créer : `frontend/src/api/todos.ts` - [ ] **Étape 1 : Créer le client API** ```typescript // frontend/src/api/todos.ts export interface Todo { id: string title: string body: string | null url: string | null domain: string | null category: string | null tags: string[] status: 'pending' | 'done' | 'cancelled' priority: 'low' | 'medium' | 'high' due_date: string | null postponed_count: number created_at: string updated_at: string | null owner_id: string | null } export interface TodoCreate { title: string body?: string url?: string domain?: string category?: string tags?: string[] status?: string priority?: string due_date?: string } export interface TodoUpdate { title?: string body?: string url?: string domain?: string category?: string tags?: string[] status?: string priority?: string due_date?: string } export interface TodoFilters { domain?: string status?: string priority?: string tag?: string due_after?: string due_before?: string } const BASE = '/api/todos' async function handleResponse(res: Response): Promise { if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) return res.json() as Promise } export async function fetchTodos(filters?: TodoFilters): Promise { const qs = new URLSearchParams() if (filters?.domain) qs.set('domain', filters.domain) if (filters?.status !== undefined) qs.set('status', filters.status) if (filters?.priority) qs.set('priority', filters.priority) if (filters?.tag) qs.set('tag', filters.tag) if (filters?.due_after) qs.set('due_after', filters.due_after) if (filters?.due_before) qs.set('due_before', filters.due_before) const res = await fetch(`${BASE}/?${qs}`) return handleResponse(res) } export async function createTodo(data: TodoCreate): Promise { const res = await fetch(`${BASE}/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) return handleResponse(res) } export async function updateTodo(id: string, data: TodoUpdate): Promise { const res = await fetch(`${BASE}/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }) return handleResponse(res) } export async function deleteTodo(id: string): Promise { const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) if (!res.ok) throw new Error(`${res.status} ${res.statusText}`) } export async function postponeTodo(id: string, days: 1 | 7): Promise { const res = await fetch(`${BASE}/${id}/postpone`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ days }), }) return handleResponse(res) } ``` - [ ] **Étape 2 : Vérifier la compilation TypeScript** ```bash docker compose exec frontend sh -c "cd /app && npx tsc --noEmit 2>&1" 2>/dev/null || \ docker compose -f docker-compose.yml -f docker-compose.dev.yml exec frontend sh -c "npx tsc --noEmit" ``` Résultat attendu : aucune erreur TypeScript. - [ ] **Étape 3 : Commit** ```bash rtk git add frontend/src/api/todos.ts rtk git commit -m "feat(todos): client API TypeScript avec types Todo" ``` --- ## Tâche 4 : Composant SwipeableRow **Fichiers :** - Créer : `frontend/src/components/todos/SwipeableRow.tsx` - [ ] **Étape 1 : Créer le composant** ```tsx // frontend/src/components/todos/SwipeableRow.tsx import { useRef, useState } from 'react' interface SwipeableRowProps { children: React.ReactNode rightContent: React.ReactNode // actions révélées par swipe gauche onSwipeRight?: () => void // callback swipe droit (marquer done) } const THRESHOLD = 80 // pixels pour déclencher une action export default function SwipeableRow({ children, rightContent, onSwipeRight }: SwipeableRowProps) { const [offsetX, setOffsetX] = useState(0) const startX = useRef(null) const dragging = useRef(false) function onTouchStart(e: React.TouchEvent) { startX.current = e.touches[0].clientX dragging.current = false } function onTouchMove(e: React.TouchEvent) { if (startX.current === null) return dragging.current = true const dx = e.touches[0].clientX - startX.current // Clamp : +120px à droite, -160px à gauche (largeur des boutons) setOffsetX(Math.max(Math.min(dx, 120), -160)) } function onTouchEnd() { if (offsetX > THRESHOLD && onSwipeRight) { onSwipeRight() } setOffsetX(0) startX.current = null dragging.current = false } const revealActions = offsetX < -(THRESHOLD / 2) return (
{/* Boutons d'action révélés à droite (swipe gauche) */}
{rightContent}
{/* Rangée principale déplaçable */}
THRESHOLD / 2 ? 'var(--ok)' : 'var(--bg-3)', position: 'relative', zIndex: 1, }} > {children}
) } ``` - [ ] **Étape 2 : Commit** ```bash rtk git add frontend/src/components/todos/SwipeableRow.tsx rtk git commit -m "feat(todos): composant SwipeableRow (swipe touch, seuil 80px)" ``` --- ## Tâche 5 : Composant TodoForm **Fichiers :** - Créer : `frontend/src/components/todos/TodoForm.tsx` - [ ] **Étape 1 : Créer le composant** ```tsx // frontend/src/components/todos/TodoForm.tsx import { useState } from 'react' import type { TodoCreate } from '../../api/todos' const DOMAINS = [ 'informatique', 'diy', 'electronique', 'domotique', 'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux', ] interface TodoFormProps { onSubmit: (data: TodoCreate) => Promise onCancel: () => void extended?: boolean // true = tous les champs (vue laptop) } 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', } export default function TodoForm({ onSubmit, onCancel, extended = false }: TodoFormProps) { const [title, setTitle] = useState('') const [domain, setDomain] = useState('') const [priority, setPriority] = useState('medium') const [dueDate, setDueDate] = useState('') const [body, setBody] = useState('') const [url, setUrl] = useState('') const [tags, setTags] = useState('') const [loading, setLoading] = useState(false) async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (!title.trim()) return setLoading(true) try { await onSubmit({ title: title.trim(), domain: domain || undefined, priority, due_date: dueDate ? new Date(dueDate).toISOString() : undefined, body: body.trim() || undefined, url: url.trim() || undefined, tags: tags ? tags.split(',').map(t => t.trim()).filter(Boolean) : [], }) } finally { setLoading(false) } } return (
setTitle(e.target.value)} autoFocus required />
setDueDate(e.target.value)} placeholder="Date objectif" /> {extended && ( <>