Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
42 KiB
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
# 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_sessiondans conftest
# 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)
# 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
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
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
# 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
# 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
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
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
// 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<T>(res: Response): Promise<T> {
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
return res.json() as Promise<T>
}
export async function fetchTodos(filters?: TodoFilters): Promise<Todo[]> {
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<Todo[]>(res)
}
export async function createTodo(data: TodoCreate): Promise<Todo> {
const res = await fetch(`${BASE}/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return handleResponse<Todo>(res)
}
export async function updateTodo(id: string, data: TodoUpdate): Promise<Todo> {
const res = await fetch(`${BASE}/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
return handleResponse<Todo>(res)
}
export async function deleteTodo(id: string): Promise<void> {
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<Todo> {
const res = await fetch(`${BASE}/${id}/postpone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ days }),
})
return handleResponse<Todo>(res)
}
- Étape 2 : Vérifier la compilation TypeScript
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
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
// 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<number | null>(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 (
<div style={{ position: 'relative', overflow: 'hidden' }}>
{/* Boutons d'action révélés à droite (swipe gauche) */}
<div
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
opacity: revealActions ? 1 : 0,
transition: 'opacity 0.15s',
maxWidth: 160,
pointerEvents: revealActions ? 'auto' : 'none',
}}
>
{rightContent}
</div>
{/* Rangée principale déplaçable */}
<div
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
style={{
transform: `translateX(${offsetX}px)`,
transition: dragging.current ? 'none' : 'transform 0.2s ease',
background: offsetX > THRESHOLD / 2 ? 'var(--ok)' : 'var(--bg-3)',
position: 'relative',
zIndex: 1,
}}
>
{children}
</div>
</div>
)
}
- Étape 2 : Commit
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
// 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<void>
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 (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input
style={inputStyle}
placeholder="Titre de la tâche *"
value={title}
onChange={e => setTitle(e.target.value)}
autoFocus
required
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<select style={inputStyle} value={domain} onChange={e => setDomain(e.target.value)}>
<option value="">Domaine</option>
{DOMAINS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select style={inputStyle} value={priority} onChange={e => setPriority(e.target.value)}>
<option value="low">Priorité basse</option>
<option value="medium">Priorité moyenne</option>
<option value="high">Priorité haute</option>
</select>
</div>
<input
style={inputStyle}
type="date"
value={dueDate}
onChange={e => setDueDate(e.target.value)}
placeholder="Date objectif"
/>
{extended && (
<>
<textarea
style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }}
placeholder="Description"
value={body}
onChange={e => setBody(e.target.value)}
/>
<input
style={inputStyle}
placeholder="URL (lien externe optionnel)"
value={url}
onChange={e => setUrl(e.target.value)}
/>
<input
style={inputStyle}
placeholder="Tags (séparés par virgule)"
value={tags}
onChange={e => setTags(e.target.value)}
/>
</>
)}
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button
type="button"
onClick={onCancel}
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
type="submit"
disabled={loading}
style={{
padding: '10px 20px',
borderRadius: 8,
border: 'none',
background: 'var(--accent)',
color: '#1d2021',
cursor: 'pointer',
fontFamily: 'var(--font-ui)',
fontWeight: 600,
minHeight: 48,
}}
>
{loading ? '…' : 'Créer'}
</button>
</div>
</form>
)
}
- Étape 2 : Commit
rtk git add frontend/src/components/todos/TodoForm.tsx
rtk git commit -m "feat(todos): composant TodoForm (rapide mobile / étendu laptop)"
Tâche 6 : Page TodosPage + route
Fichiers :
-
Créer :
frontend/src/pages/TodosPage.tsx -
Modifier :
frontend/src/App.tsx -
Étape 1 : Créer la page
// frontend/src/pages/TodosPage.tsx
import { useState, useEffect, useCallback } from 'react'
import type { Todo, TodoCreate, TodoFilters } from '../api/todos'
import { fetchTodos, createTodo, updateTodo, deleteTodo, postponeTodo } from '../api/todos'
import SwipeableRow from '../components/todos/SwipeableRow'
import TodoForm from '../components/todos/TodoForm'
const DOMAINS = [
'informatique', 'diy', 'electronique', 'domotique',
'bricolage', 'jardin', 'cuisine', 'voyage', 'animaux',
]
const STATUS_LABELS: Record<string, string> = {
pending: 'En cours', done: 'Terminé', cancelled: 'Annulé',
}
const PRIORITY_COLORS: Record<string, string> = {
high: 'var(--err)', medium: 'var(--warn)', low: 'var(--ink-3)',
}
const selectStyle: React.CSSProperties = {
background: 'var(--bg-3)',
border: '1px solid var(--bg-5)',
borderRadius: 8,
padding: '6px 10px',
color: 'var(--ink-1)',
fontFamily: 'var(--font-ui)',
fontSize: 13,
}
export default function TodosPage() {
const [todos, setTodos] = useState<Todo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [filters, setFilters] = useState<TodoFilters>({ status: 'pending' })
const load = useCallback(async () => {
setLoading(true)
try {
setTodos(await fetchTodos(filters))
} finally {
setLoading(false)
}
}, [filters])
useEffect(() => { void load() }, [load])
async function handleCreate(data: TodoCreate) {
await createTodo(data)
setShowForm(false)
void load()
}
async function handleDone(id: string) {
await updateTodo(id, { status: 'done' })
void load()
}
async function handleDelete(id: string) {
await deleteTodo(id)
void load()
}
async function handlePostpone(id: string, days: 1 | 7) {
await postponeTodo(id, days)
void load()
}
// Grouper par domaine pour la vue mobile
const grouped = DOMAINS.reduce<Record<string, Todo[]>>((acc, d) => {
const items = todos.filter(t => t.domain === d)
if (items.length > 0) acc[d] = items
return acc
}, {})
const sansDomaine = todos.filter(t => !t.domain)
if (sansDomaine.length > 0) grouped['—'] = sansDomaine
return (
<div className="p-4">
{/* En-tête + filtres */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
<h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0, flex: 1, minWidth: 100 }}>
Tâches
</h1>
<select
style={selectStyle}
value={filters.status ?? 'pending'}
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
>
<option value="pending">En cours</option>
<option value="done">Terminé</option>
<option value="cancelled">Annulé</option>
<option value="">Tous</option>
</select>
<select
style={selectStyle}
value={filters.domain ?? ''}
onChange={e => setFilters(f => ({ ...f, domain: e.target.value || undefined }))}
>
<option value="">Tous domaines</option>
{DOMAINS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
<select
style={selectStyle}
value={filters.priority ?? ''}
onChange={e => setFilters(f => ({ ...f, priority: e.target.value || undefined }))}
>
<option value="">Toutes priorités</option>
<option value="high">Haute</option>
<option value="medium">Moyenne</option>
<option value="low">Basse</option>
</select>
</div>
{/* Formulaire de création */}
{showForm && (
<div className="glass" style={{ padding: 16, borderRadius: 10, marginBottom: 16 }}>
<div className="hidden lg:block">
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} extended />
</div>
<div className="block lg:hidden">
<TodoForm onSubmit={handleCreate} onCancel={() => setShowForm(false)} />
</div>
</div>
)}
{loading && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', padding: 24 }}>Chargement…</p>
)}
{/* Vue mobile — liste groupée par domaine */}
<div className="block lg:hidden">
{!loading && Object.entries(grouped).map(([domain, items]) => (
<div key={domain} style={{ marginBottom: 20 }}>
{/* Entête de groupe avec badge compteur */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{
color: 'var(--accent)', fontFamily: 'var(--font-mono)',
fontSize: 12, textTransform: 'uppercase', letterSpacing: 1,
}}>
{domain}
</span>
<span style={{
background: 'var(--bg-4)', color: 'var(--ink-3)',
fontSize: 11, borderRadius: 999, padding: '1px 7px',
}}>
{items.length}
</span>
</div>
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden' }}>
{items.map((todo, idx) => (
<SwipeableRow
key={todo.id}
onSwipeRight={() => void handleDone(todo.id)}
rightContent={
<div style={{ display: 'flex', gap: 4, padding: '0 8px' }}>
<button
onClick={() => void handlePostpone(todo.id, 1)}
style={{ background: 'var(--info)', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>+1j</button>
<button
onClick={() => void handlePostpone(todo.id, 7)}
style={{ background: 'var(--warn)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>+1S</button>
<button
onClick={() => void handleDelete(todo.id)}
style={{ background: 'var(--err)', color: '#fff', border: 'none', borderRadius: 6, padding: '8px 10px', fontSize: 12, cursor: 'pointer', fontFamily: 'var(--font-ui)', minHeight: 48 }}
>✕</button>
</div>
}
>
<div style={{
padding: '12px 16px',
minHeight: 48,
borderBottom: idx < items.length - 1 ? '1px solid var(--bg-4)' : 'none',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: PRIORITY_COLORS[todo.priority], flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ color: 'var(--ink-1)', fontSize: 14, fontFamily: 'var(--font-ui)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{todo.title}
</div>
{todo.due_date && (
<div style={{ color: 'var(--ink-3)', fontSize: 11, marginTop: 2 }}>
{new Date(todo.due_date).toLocaleDateString('fr-FR')}
{todo.postponed_count > 0 && ` · reporté ${todo.postponed_count}×`}
</div>
)}
</div>
</div>
</SwipeableRow>
))}
</div>
</div>
))}
{!loading && todos.length === 0 && (
<p style={{ color: 'var(--ink-3)', textAlign: 'center', marginTop: 40 }}>Aucune tâche</p>
)}
</div>
{/* Vue laptop — tableau filtrable */}
<div className="hidden lg:block">
{!loading && (
<>
{/* Filtre période (laptop uniquement) */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, alignItems: 'center' }}>
<span style={{ color: 'var(--ink-3)', fontSize: 12, fontFamily: 'var(--font-ui)' }}>Période :</span>
<input
type="date"
style={selectStyle}
value={filters.due_after ?? ''}
onChange={e => setFilters(f => ({ ...f, due_after: e.target.value || undefined }))}
placeholder="Après le"
/>
<input
type="date"
style={selectStyle}
value={filters.due_before ?? ''}
onChange={e => setFilters(f => ({ ...f, due_before: e.target.value || undefined }))}
placeholder="Avant le"
/>
</div>
<div className="glass" style={{ borderRadius: 10, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontFamily: 'var(--font-ui)', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--bg-4)' }}>
{['Titre', 'Domaine', 'Priorité', 'Statut', 'Date objectif', 'Reports', 'Actions'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', color: 'var(--ink-3)', fontWeight: 500 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{todos.map((todo, idx) => (
<tr
key={todo.id}
style={{
borderBottom: idx < todos.length - 1 ? '1px solid var(--bg-4)' : 'none',
background: idx % 2 === 0 ? 'transparent' : 'rgba(0,0,0,0.1)',
}}
>
<td style={{ padding: '10px 14px', color: 'var(--ink-1)', maxWidth: 280 }}>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{todo.title}
</div>
{todo.tags.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
{todo.tags.map(tag => (
<span key={tag} style={{ background: 'var(--bg-4)', color: 'var(--ink-3)', fontSize: 10, borderRadius: 999, padding: '1px 6px' }}>
{tag}
</span>
))}
</div>
)}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>{todo.domain ?? '—'}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ color: PRIORITY_COLORS[todo.priority], fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{todo.priority}
</span>
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)' }}>
{STATUS_LABELS[todo.status] ?? todo.status}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-2)', fontFamily: 'var(--font-mono)', fontSize: 12 }}>
{todo.due_date ? new Date(todo.due_date).toLocaleDateString('fr-FR') : '—'}
</td>
<td style={{ padding: '10px 14px', color: 'var(--ink-3)', fontFamily: 'var(--font-mono)' }}>
{todo.postponed_count > 0 ? `${todo.postponed_count}×` : '—'}
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }}>
{todo.status === 'pending' && (
<>
<button
onClick={() => void handleDone(todo.id)}
title="Marquer terminé"
style={{ background: 'var(--ok)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>✓</button>
<button
onClick={() => void handlePostpone(todo.id, 1)}
title="Reporter d'1 jour"
style={{ background: 'var(--info)', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>+1j</button>
<button
onClick={() => void handlePostpone(todo.id, 7)}
title="Reporter d'1 semaine"
style={{ background: 'var(--warn)', color: '#1d2021', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>+1S</button>
</>
)}
<button
onClick={() => void handleDelete(todo.id)}
title="Supprimer"
style={{ background: 'var(--err)', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 8px', cursor: 'pointer', fontSize: 12 }}
>✕</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{todos.length === 0 && (
<p style={{ padding: 24, color: 'var(--ink-3)', textAlign: 'center' }}>Aucune tâche</p>
)}
</div>
</>
)}
</div>
{/* FAB mobile (au-dessus de la barre de navigation) */}
{!showForm && (
<button
className="lg:hidden"
onClick={() => setShowForm(true)}
aria-label="Nouvelle tâche"
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',
lineHeight: 1,
}}
>+</button>
)}
{/* Bouton création laptop */}
{!showForm && (
<button
className="hidden lg:flex"
onClick={() => setShowForm(true)}
style={{
position: 'fixed',
bottom: 24,
right: 24,
padding: '10px 20px',
borderRadius: 8,
background: 'var(--accent)',
color: '#1d2021',
border: 'none',
cursor: 'pointer',
fontFamily: 'var(--font-ui)',
fontWeight: 600,
fontSize: 14,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
alignItems: 'center',
gap: 6,
}}
>
+ Nouvelle tâche
</button>
)}
</div>
)
}
- Étape 2 : Ajouter la route
/todosdans App.tsx
// frontend/src/App.tsx
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Layout from './components/layout/Layout'
import HomePage from './pages/HomePage'
import TodosPage from './pages/TodosPage'
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HomePage />} />
<Route path="todos" element={<TodosPage />} />
</Route>
</Routes>
</BrowserRouter>
)
}
- Étape 3 : Rebuild le frontend et vérifier en navigateur
docker compose build frontend && docker compose up -d frontend
Ouvrir http://localhost:3001/todos et vérifier :
- La page s'affiche avec les filtres en haut
- Le bouton + ouvre le formulaire (mobile) ou le formulaire inline (laptop)
- Créer une tâche → apparaît dans la liste
- Sur mobile (DevTools → mode responsive) : le swipe droit sur un item le marque done
- Le swipe gauche révèle les boutons +1j / +1S / ✕
- Sur laptop : le tableau affiche les colonnes, les filtres fonctionnent
- Étape 4 : Vérifier la compilation TypeScript sans erreur
docker compose exec frontend sh -c "npx tsc --noEmit" 2>&1 || true
Résultat attendu : aucune erreur.
- Étape 5 : Relancer la suite de tests backend pour vérifier aucune régression
docker compose exec backend python -m pytest tests/ -v
Résultat attendu : 15 passed
- Étape 6 : Commit
rtk git add frontend/src/pages/TodosPage.tsx frontend/src/App.tsx
rtk git commit -m "feat(todos): page TodosPage — vue mobile swipeable + vue laptop tableau filtrable"
Auto-revue du plan
Couverture spec :
- ✅
GET /api/todosavec filtres (domain, status, priority, tag, due_after, due_before) - ✅
POST /api/todoscréation - ✅
PATCH /api/todos/{id}mise à jour partielle - ✅
DELETE /api/todos/{id}suppression 204 - ✅
POST /api/todos/{id}/postponeincrémente postponed_count + décale due_date - ✅ Schémas Pydantic : TodoCreate, TodoUpdate, PostponeRequest, TodoResponse
- ✅ Vue mobile : liste groupée par domaine, swipe droit → done, swipe gauche → +1j/+1S/✕
- ✅ Vue laptop : tableau avec filtres (domaine, statut, priorité, tags, période)
- ✅ Formulaire rapide mobile / formulaire étendu laptop
- ✅ FAB mobile (au-dessus de la bottom nav à 72px)
- ✅ Badge compteur par domaine
Consistance des types : TodoResponse.from_attributes=True correspond aux champs de TodoItem. TodoCreate.model_dump() fournit exactement les colonnes de TodoItem (les champs absents comme id, created_at ont des défauts serveur/DB).
Aucun placeholder détecté.