Files
home_hub/docs/superpowers/plans/2026-05-24-phase2-todos.md
T
2026-05-24 14:10:18 +02:00

42 KiB
Raw Blame History

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_session dans 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 /todos dans 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 :

  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
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/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é.