b87c96ceab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1290 lines
42 KiB
Markdown
1290 lines
42 KiB
Markdown
# 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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```tsx
|
||
// 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 `/todos` dans App.tsx**
|
||
|
||
```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**
|
||
|
||
```bash
|
||
docker compose build frontend && docker compose up -d frontend
|
||
```
|
||
|
||
Ouvrir `http://localhost:3001/todos` et vérifier :
|
||
1. La page s'affiche avec les filtres en haut
|
||
2. Le bouton + ouvre le formulaire (mobile) ou le formulaire inline (laptop)
|
||
3. Créer une tâche → apparaît dans la liste
|
||
4. Sur mobile (DevTools → mode responsive) : le swipe droit sur un item le marque done
|
||
5. Le swipe gauche révèle les boutons +1j / +1S / ✕
|
||
6. Sur laptop : le tableau affiche les colonnes, les filtres fonctionnent
|
||
|
||
- [ ] **Étape 4 : Vérifier la compilation TypeScript sans erreur**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```bash
|
||
docker compose exec backend python -m pytest tests/ -v
|
||
```
|
||
|
||
Résultat attendu : `15 passed`
|
||
|
||
- [ ] **Étape 6 : Commit**
|
||
|
||
```bash
|
||
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/todos` avec filtres (domain, status, priority, tag, due_after, due_before)
|
||
- ✅ `POST /api/todos` création
|
||
- ✅ `PATCH /api/todos/{id}` mise à jour partielle
|
||
- ✅ `DELETE /api/todos/{id}` suppression 204
|
||
- ✅ `POST /api/todos/{id}/postpone` incré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é.**
|