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

65 KiB
Raw Blame History

HomeHub Phase 1 — Socle Technique

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: Poser toute l'infrastructure de HomeHub : Docker Compose (3 services obligatoires), FastAPI async avec PostgreSQL multi-schémas, module media avec génération de miniatures, et scaffold React PWA avec design system Gruvbox.

Architecture: FastAPI (Python 3.12, async SQLAlchemy 2.0) connecté à PostgreSQL 16 via asyncpg. Alembic crée 3 schémas (todos, shopping, notes) avec toutes les tables. Le module media gère l'upload, la validation et la génération de miniatures Pillow. Le frontend React 18 + Vite + TypeScript intègre les tokens CSS Gruvbox, vite-plugin-pwa, et un layout responsive mobile/laptop.

Tech Stack: FastAPI 0.115 · SQLAlchemy 2.0 async · Alembic · Pillow · pytest-asyncio · React 18 · Vite 5 · TypeScript 5 · Tailwind CSS 3 · vite-plugin-pwa · react-router-dom v6


Cartographie des fichiers

home_hub/
├── backend/
│   ├── app/
│   │   ├── __init__.py
│   │   ├── main.py                    # App FastAPI, routers, CORS
│   │   ├── api/
│   │   │   ├── __init__.py
│   │   │   ├── health.py              # GET /api/health
│   │   │   └── media.py               # POST /api/media/upload, DELETE /api/media/{uuid}
│   │   ├── core/
│   │   │   ├── __init__.py
│   │   │   ├── config.py              # Settings via pydantic-settings
│   │   │   └── database.py            # Engine async, session, Base
│   │   ├── models/
│   │   │   ├── __init__.py
│   │   │   ├── todos.py               # TodoItem
│   │   │   ├── shopping.py            # Product, Store, PriceHistory, ShoppingList, ListItem
│   │   │   └── notes.py               # NoteItem, NoteAttachment
│   │   ├── schemas/
│   │   │   ├── __init__.py
│   │   │   └── media.py               # MediaUploadResponse
│   │   ├── services/
│   │   │   ├── __init__.py
│   │   │   └── media.py               # save_image(), save_audio(), delete_media()
│   │   └── data/
│   │       ├── seed.py                # Chargement seed_products.json + seed_stores.json
│   │       ├── seed_products.json     # déjà présent
│   │       └── seed_stores.json       # déjà présent
│   ├── tests/
│   │   ├── __init__.py
│   │   ├── conftest.py                # Fixtures pytest (client HTTPX)
│   │   ├── test_health.py
│   │   └── test_media.py
│   ├── alembic/
│   │   ├── env.py
│   │   ├── script.py.mako
│   │   └── versions/
│   │       └── 001_initial.py         # Schémas + toutes les tables
│   ├── alembic.ini
│   ├── Dockerfile
│   ├── pytest.ini
│   └── requirements.txt
├── frontend/
│   ├── public/
│   │   ├── manifest.json
│   │   └── icons/
│   │       ├── icon-192.png           # placeholder SVG→PNG
│   │       └── icon-512.png
│   ├── src/
│   │   ├── main.tsx
│   │   ├── App.tsx
│   │   ├── index.css                  # @import tokens.css + tailwind directives
│   │   ├── design-system/
│   │   │   ├── tokens.css             # copié depuis design_system/tokens/tokens.css
│   │   │   └── ui-kit.tsx             # adapté depuis design_system/components/ui-kit.jsx
│   │   ├── components/layout/
│   │   │   ├── Layout.tsx
│   │   │   ├── BottomNav.tsx
│   │   │   └── SideNav.tsx
│   │   └── pages/
│   │       └── HomePage.tsx
│   ├── index.html
│   ├── package.json
│   ├── vite.config.ts
│   ├── tailwind.config.ts
│   ├── postcss.config.js
│   ├── tsconfig.json
│   └── Dockerfile
├── docker-compose.yml
├── docker-compose.dev.yml
├── .env.example
└── dev.sh

Tâche 1 : Infrastructure Docker et variables d'environnement

Fichiers :

  • Créer : docker-compose.yml

  • Créer : .env.example

  • Créer : backend/ (structure de dossiers)

  • Étape 1 : Créer la structure de dossiers backend

mkdir -p backend/app/{api,core,models,schemas,services,data}
mkdir -p backend/tests
mkdir -p backend/alembic/versions
touch backend/app/__init__.py
touch backend/app/api/__init__.py
touch backend/app/core/__init__.py
touch backend/app/models/__init__.py
touch backend/app/schemas/__init__.py
touch backend/app/services/__init__.py
touch backend/tests/__init__.py
  • Étape 2 : Créer .env.example
# .env.example
DATABASE_URL=postgresql+asyncpg://homehub:homehub@db:5432/homehub
UPLOAD_DIR=/uploads
CORS_ORIGINS=http://localhost:3000,http://frontend:3000
  • Étape 3 : Créer docker-compose.yml
# docker-compose.yml
services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: homehub
      POSTGRES_PASSWORD: homehub
      POSTGRES_DB: homehub
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U homehub"]
      interval: 5s
      timeout: 5s
      retries: 10

  backend:
    build: ./backend
    environment:
      DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
      UPLOAD_DIR: /uploads
      CORS_ORIGINS: http://localhost:3000,http://frontend:3000
    volumes:
      - uploads:/uploads
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy

  frontend:
    build: ./frontend
    ports:
      - "3000:80"
    depends_on:
      - backend

volumes:
  db_data:
  uploads:
  • Étape 4 : Committer
git add docker-compose.yml .env.example backend/
git commit -m "chore: structure initiale backend et docker-compose"

Tâche 2 : Backend — requirements.txt et Dockerfile

Fichiers :

  • Créer : backend/requirements.txt

  • Créer : backend/Dockerfile

  • Créer : backend/pytest.ini

  • Étape 1 : Créer backend/requirements.txt

fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.0
pydantic-settings==2.6.1
pillow==11.1.0
python-multipart==0.0.20
httpx==0.28.0
pytest==8.3.4
pytest-asyncio==0.24.0
  • Étape 2 : Créer backend/Dockerfile
# backend/Dockerfile
FROM python:3.12-slim

WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq-dev gcc \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • Étape 3 : Créer backend/pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
  • Étape 4 : Committer
git add backend/requirements.txt backend/Dockerfile backend/pytest.ini
git commit -m "chore: dockerfile backend et dépendances python"

Tâche 3 : Backend — Configuration et base de données

Fichiers :

  • Créer : backend/app/core/config.py

  • Créer : backend/app/core/database.py

  • Étape 1 : Créer backend/app/core/config.py

# backend/app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict
from pathlib import Path


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    database_url: str = "postgresql+asyncpg://homehub:homehub@localhost:5432/homehub"
    upload_dir: str = "/uploads"
    cors_origins: str = "http://localhost:3000"

    @property
    def cors_origins_list(self) -> list[str]:
        return [o.strip() for o in self.cors_origins.split(",")]

    @property
    def upload_path(self) -> Path:
        return Path(self.upload_dir)


settings = Settings()
  • Étape 2 : Créer backend/app/core/database.py
# backend/app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings

engine = create_async_engine(settings.database_url, pool_pre_ping=True)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)


class Base(DeclarativeBase):
    pass


async def get_session() -> AsyncSession:
    async with AsyncSessionLocal() as session:
        yield session
  • Étape 3 : Committer
git add backend/app/core/
git commit -m "feat: configuration FastAPI et moteur SQLAlchemy async"

Tâche 4 : Backend — Modèles SQLAlchemy

Fichiers :

  • Créer : backend/app/models/todos.py

  • Créer : backend/app/models/shopping.py

  • Créer : backend/app/models/notes.py

  • Étape 1 : Créer backend/app/models/todos.py

# backend/app/models/todos.py
import uuid
from datetime import datetime
from sqlalchemy import String, Text, Integer, TIMESTAMP, text
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base


class TodoItem(Base):
    __tablename__ = "items"
    __table_args__ = {"schema": "todos"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    title: Mapped[str] = mapped_column(String(255), nullable=False)
    body: Mapped[str | None] = mapped_column(Text)
    url: Mapped[str | None] = mapped_column(Text)
    domain: Mapped[str | None] = mapped_column(String(50))
    category: Mapped[str | None] = mapped_column(String(50))
    tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]"))
    status: Mapped[str] = mapped_column(String(20), server_default="pending")
    priority: Mapped[str] = mapped_column(String(10), server_default="medium")
    due_date: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
    postponed_count: Mapped[int] = mapped_column(Integer, server_default=text("0"))
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
    updated_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
    owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
  • Étape 2 : Créer backend/app/models/shopping.py
# backend/app/models/shopping.py
import uuid
from datetime import datetime, date
from decimal import Decimal
from sqlalchemy import String, Text, Integer, TIMESTAMP, Date, Numeric, Boolean, ForeignKey, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base


class Product(Base):
    __tablename__ = "products"
    __table_args__ = {"schema": "shopping"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name: Mapped[str] = mapped_column(String(150), nullable=False)
    brand: Mapped[str | None] = mapped_column(String(100))
    category: Mapped[str | None] = mapped_column(String(50))
    image_path: Mapped[str | None] = mapped_column(String(255))
    thumbnail_path: Mapped[str | None] = mapped_column(String(255))
    default_unit: Mapped[str | None] = mapped_column(String(20))
    barcode: Mapped[str | None] = mapped_column(String(50))
    frequency_score: Mapped[int] = mapped_column(Integer, server_default=text("0"))
    owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))


class Store(Base):
    __tablename__ = "stores"
    __table_args__ = {"schema": "shopping"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name: Mapped[str] = mapped_column(String(100), nullable=False)
    location: Mapped[str | None] = mapped_column(Text)
    owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))


class PriceHistory(Base):
    __tablename__ = "price_history"
    __table_args__ = {"schema": "shopping"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    product_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="CASCADE"), nullable=False)
    store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
    price: Mapped[Decimal] = mapped_column(Numeric(8, 2), nullable=False)
    unit: Mapped[str | None] = mapped_column(String(20))
    quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
    source: Mapped[str] = mapped_column(String(20), server_default="manual")
    recorded_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))


class ShoppingList(Base):
    __tablename__ = "lists"
    __table_args__ = {"schema": "shopping"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    name: Mapped[str | None] = mapped_column(String(100))
    store_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.stores.id", ondelete="SET NULL"))
    week_date: Mapped[date | None] = mapped_column(Date)
    status: Mapped[str] = mapped_column(String(20), server_default="draft")
    owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))

    items: Mapped[list["ListItem"]] = relationship("ListItem", back_populates="shopping_list", cascade="all, delete-orphan")


class ListItem(Base):
    __tablename__ = "list_items"
    __table_args__ = {"schema": "shopping"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    list_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False)
    product_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("shopping.products.id", ondelete="SET NULL"))
    custom_name: Mapped[str | None] = mapped_column(String(150))
    quantity: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
    unit: Mapped[str | None] = mapped_column(String(20))
    is_checked: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
    price_recorded: Mapped[Decimal | None] = mapped_column(Numeric(8, 2))
    carried_over: Mapped[bool] = mapped_column(Boolean, server_default=text("false"))
    sort_order: Mapped[int | None] = mapped_column(Integer)

    shopping_list: Mapped["ShoppingList"] = relationship("ShoppingList", back_populates="items")
  • Étape 3 : Créer backend/app/models/notes.py
# backend/app/models/notes.py
import uuid
from datetime import datetime
from decimal import Decimal
from sqlalchemy import String, Text, TIMESTAMP, Numeric, ForeignKey, text
from sqlalchemy.dialects.postgresql import UUID, ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base


class NoteItem(Base):
    __tablename__ = "items"
    __table_args__ = {"schema": "notes"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    title: Mapped[str | None] = mapped_column(String(255))
    content: Mapped[str] = mapped_column(Text, nullable=False)
    category: Mapped[str | None] = mapped_column(String(50))
    tags: Mapped[list[str]] = mapped_column(ARRAY(String(50)), server_default=text("'{}'::varchar[]"))
    gps_lat: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
    gps_lon: Mapped[Decimal | None] = mapped_column(Numeric(10, 7))
    metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB)
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))
    owner_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))

    attachments: Mapped[list["NoteAttachment"]] = relationship("NoteAttachment", back_populates="note", cascade="all, delete-orphan")


class NoteAttachment(Base):
    __tablename__ = "attachments"
    __table_args__ = {"schema": "notes"}

    id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
    note_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("notes.items.id", ondelete="CASCADE"), nullable=False)
    file_path: Mapped[str | None] = mapped_column(String(255))
    thumbnail_path: Mapped[str | None] = mapped_column(String(255))
    file_type: Mapped[str | None] = mapped_column(String(20))
    original_name: Mapped[str | None] = mapped_column(String(255))
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=text("now()"))

    note: Mapped["NoteItem"] = relationship("NoteItem", back_populates="attachments")
  • Étape 4 : Committer
git add backend/app/models/
git commit -m "feat: modèles SQLAlchemy pour todos, shopping et notes"

Tâche 5 : Backend — main.py et endpoint health

Fichiers :

  • Créer : backend/app/main.py

  • Créer : backend/app/api/health.py

  • Créer : backend/tests/conftest.py

  • Créer : backend/tests/test_health.py

  • Étape 1 : Écrire le test en premier

# backend/tests/test_health.py
async def test_health_retourne_ok(client):
    response = await client.get("/api/health")
    assert response.status_code == 200
    assert response.json() == {"status": "ok"}
  • Étape 2 : Créer backend/tests/conftest.py
# backend/tests/conftest.py
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app


@pytest.fixture
async def client():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
        yield ac
  • Étape 3 : Lancer le test — doit échouer
cd backend && python -m pytest tests/test_health.py -v

Résultat attendu : FAILEDImportError: cannot import name 'app'

  • Étape 4 : Créer backend/app/api/health.py
# backend/app/api/health.py
from fastapi import APIRouter

router = APIRouter()


@router.get("/health")
async def health():
    return {"status": "ok"}
  • Étape 5 : Créer backend/app/main.py
# backend/app/main.py
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.core.config import settings

app = FastAPI(title="HomeHub API", version="0.1.0")

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")

Note : media_router sera créé à la tâche 7. Pour l'instant, créer un routeur vide temporaire :

# backend/app/api/media.py  (version temporaire)
from fastapi import APIRouter

router = APIRouter()
  • Étape 6 : Lancer le test — doit passer
cd backend && python -m pytest tests/test_health.py -v

Résultat attendu :

tests/test_health.py::test_health_retourne_ok PASSED
  • Étape 7 : Committer
git add backend/app/main.py backend/app/api/health.py backend/app/api/media.py backend/tests/
git commit -m "feat: endpoint GET /api/health + tests"

Tâche 6 : Alembic — migration initiale (schémas + tables)

Fichiers :

  • Créer : backend/alembic.ini

  • Créer : backend/alembic/env.py

  • Créer : backend/alembic/script.py.mako

  • Créer : backend/alembic/versions/001_initial.py

  • Étape 1 : Créer backend/alembic.ini

[alembic]
script_location = alembic
file_template = %%(rev)s_%%(slug)s
prepend_sys_path = .
sqlalchemy.url = postgresql+asyncpg://homehub:homehub@localhost:5432/homehub

[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
  • Étape 2 : Créer backend/alembic/script.py.mako
"""${message}

Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}

revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}


def upgrade() -> None:
    ${upgrades if upgrades else "pass"}


def downgrade() -> None:
    ${downgrades if downgrades else "pass"}
  • Étape 3 : Créer backend/alembic/env.py
# backend/alembic/env.py
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from app.core.config import settings

config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

if config.config_file_name is not None:
    fileConfig(config.config_file_name)

from app.core.database import Base
from app.models import todos, shopping, notes  # noqa: F401 — enregistre les modèles

target_metadata = Base.metadata


def run_migrations_offline() -> None:
    url = config.get_main_option("sqlalchemy.url")
    context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
    with context.begin_transaction():
        context.run_migrations()


def do_run_migrations(connection: Connection) -> None:
    context.configure(connection=connection, target_metadata=target_metadata)
    with context.begin_transaction():
        context.run_migrations()


async def run_async_migrations() -> None:
    connectable = async_engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )
    async with connectable.connect() as connection:
        await connection.run_sync(do_run_migrations)
    await connectable.dispose()


def run_migrations_online() -> None:
    asyncio.run(run_async_migrations())


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
  • Étape 4 : Créer backend/alembic/versions/001_initial.py
# backend/alembic/versions/001_initial.py
"""Schémas initiaux et toutes les tables

Revision ID: 001
Revises:
Create Date: 2026-05-24
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = "001"
down_revision = None
branch_labels = None
depends_on = None


def upgrade() -> None:
    op.execute("CREATE SCHEMA IF NOT EXISTS todos")
    op.execute("CREATE SCHEMA IF NOT EXISTS shopping")
    op.execute("CREATE SCHEMA IF NOT EXISTS notes")

    # ── todos.items ──────────────────────────────────────────────
    op.create_table(
        "items",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("title", sa.String(255), nullable=False),
        sa.Column("body", sa.Text),
        sa.Column("url", sa.Text),
        sa.Column("domain", sa.String(50)),
        sa.Column("category", sa.String(50)),
        sa.Column("tags", postgresql.ARRAY(sa.String(50)), server_default=sa.text("'{}'::varchar[]")),
        sa.Column("status", sa.String(20), server_default="pending"),
        sa.Column("priority", sa.String(10), server_default="medium"),
        sa.Column("due_date", sa.TIMESTAMP(timezone=True)),
        sa.Column("postponed_count", sa.Integer, server_default="0"),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        sa.Column("updated_at", sa.TIMESTAMP(timezone=True)),
        sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
        schema="todos",
    )
    op.create_index("idx_todos_tags", "items", [sa.text("tags")], schema="todos", postgresql_using="gin")

    # ── shopping.products ────────────────────────────────────────
    op.create_table(
        "products",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("name", sa.String(150), nullable=False),
        sa.Column("brand", sa.String(100)),
        sa.Column("category", sa.String(50)),
        sa.Column("image_path", sa.String(255)),
        sa.Column("thumbnail_path", sa.String(255)),
        sa.Column("default_unit", sa.String(20)),
        sa.Column("barcode", sa.String(50)),
        sa.Column("frequency_score", sa.Integer, server_default="0"),
        sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        schema="shopping",
    )

    # ── shopping.stores ──────────────────────────────────────────
    op.create_table(
        "stores",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("name", sa.String(100), nullable=False),
        sa.Column("location", sa.Text),
        sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
        schema="shopping",
    )

    # ── shopping.price_history ───────────────────────────────────
    op.create_table(
        "price_history",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.products.id", ondelete="CASCADE"), nullable=False),
        sa.Column("store_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.stores.id", ondelete="SET NULL")),
        sa.Column("price", sa.Numeric(8, 2), nullable=False),
        sa.Column("unit", sa.String(20)),
        sa.Column("quantity", sa.Numeric(8, 3)),
        sa.Column("source", sa.String(20), server_default="manual"),
        sa.Column("recorded_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        schema="shopping",
    )

    # ── shopping.lists ───────────────────────────────────────────
    op.create_table(
        "lists",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("name", sa.String(100)),
        sa.Column("store_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.stores.id", ondelete="SET NULL")),
        sa.Column("week_date", sa.Date),
        sa.Column("status", sa.String(20), server_default="draft"),
        sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        schema="shopping",
    )

    # ── shopping.list_items ──────────────────────────────────────
    op.create_table(
        "list_items",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("list_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.lists.id", ondelete="CASCADE"), nullable=False),
        sa.Column("product_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("shopping.products.id", ondelete="SET NULL")),
        sa.Column("custom_name", sa.String(150)),
        sa.Column("quantity", sa.Numeric(8, 3)),
        sa.Column("unit", sa.String(20)),
        sa.Column("is_checked", sa.Boolean, server_default="false"),
        sa.Column("price_recorded", sa.Numeric(8, 2)),
        sa.Column("carried_over", sa.Boolean, server_default="false"),
        sa.Column("sort_order", sa.Integer),
        schema="shopping",
    )

    # ── notes.items ──────────────────────────────────────────────
    op.create_table(
        "items",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("title", sa.String(255)),
        sa.Column("content", sa.Text, nullable=False),
        sa.Column("category", sa.String(50)),
        sa.Column("tags", postgresql.ARRAY(sa.String(50)), server_default=sa.text("'{}'::varchar[]")),
        sa.Column("gps_lat", sa.Numeric(10, 7)),
        sa.Column("gps_lon", sa.Numeric(10, 7)),
        sa.Column("metadata", postgresql.JSONB),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        sa.Column("owner_id", postgresql.UUID(as_uuid=True)),
        schema="notes",
    )
    op.create_index("idx_notes_tags", "items", [sa.text("tags")], schema="notes", postgresql_using="gin")
    op.create_index(
        "idx_notes_fts",
        "items",
        [sa.text("to_tsvector('french', coalesce(title,'') || ' ' || content)")],
        schema="notes",
        postgresql_using="gin",
    )

    # ── notes.attachments ────────────────────────────────────────
    op.create_table(
        "attachments",
        sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
        sa.Column("note_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("notes.items.id", ondelete="CASCADE"), nullable=False),
        sa.Column("file_path", sa.String(255)),
        sa.Column("thumbnail_path", sa.String(255)),
        sa.Column("file_type", sa.String(20)),
        sa.Column("original_name", sa.String(255)),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.text("now()")),
        schema="notes",
    )


def downgrade() -> None:
    op.drop_table("attachments", schema="notes")
    op.drop_table("items", schema="notes")
    op.drop_table("list_items", schema="shopping")
    op.drop_table("lists", schema="shopping")
    op.drop_table("price_history", schema="shopping")
    op.drop_table("stores", schema="shopping")
    op.drop_table("products", schema="shopping")
    op.drop_table("items", schema="todos")
    op.execute("DROP SCHEMA IF EXISTS notes CASCADE")
    op.execute("DROP SCHEMA IF EXISTS shopping CASCADE")
    op.execute("DROP SCHEMA IF EXISTS todos CASCADE")
  • Étape 5 : Lancer les migrations (PostgreSQL doit tourner)
cd backend && docker compose up db -d
# Attendre que postgres soit prêt (healthcheck)
DATABASE_URL=postgresql+asyncpg://homehub:homehub@localhost:5432/homehub alembic upgrade head

Résultat attendu :

INFO  [alembic.runtime.migration] Running upgrade  -> 001, Schémas initiaux et toutes les tables
  • Étape 6 : Vérifier les schémas en base
docker exec -it homehub-db-1 psql -U homehub -c "\dn"

Résultat attendu : 3 schémas notes, shopping, todos listés.

  • Étape 7 : Committer
git add backend/alembic.ini backend/alembic/
git commit -m "feat: migration initiale — schémas todos/shopping/notes et toutes les tables"

Tâche 7 : Module media — service, schéma et endpoint

Fichiers :

  • Créer : backend/app/services/media.py

  • Créer : backend/app/schemas/media.py

  • Modifier : backend/app/api/media.py

  • Créer : backend/tests/test_media.py

  • Étape 1 : Écrire les tests en premier

# backend/tests/test_media.py
import io
import pytest
from PIL import Image
from pathlib import Path


def _make_jpeg(width: int = 200, height: int = 200) -> bytes:
    img = Image.new("RGB", (width, height), color=(120, 80, 40))
    buf = io.BytesIO()
    img.save(buf, format="JPEG")
    return buf.getvalue()


async def test_upload_image_retourne_chemins(client, tmp_path, monkeypatch):
    from app.services import media as media_svc
    monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path)

    response = await client.post(
        "/api/media/upload",
        files={"file": ("photo.jpg", _make_jpeg(), "image/jpeg")},
        params={"context": "note"},
    )
    assert response.status_code == 200
    data = response.json()
    assert "file_id" in data
    assert data["file_path"].startswith("images/originals/")
    assert data["thumbnail_path"].startswith("images/thumbnails/")


async def test_upload_format_invalide_retourne_400(client, tmp_path, monkeypatch):
    from app.services import media as media_svc
    monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path)

    response = await client.post(
        "/api/media/upload",
        files={"file": ("doc.pdf", b"contenu pdf", "application/pdf")},
    )
    assert response.status_code == 400


async def test_miniature_produit_max_150px(client, tmp_path, monkeypatch):
    from app.services import media as media_svc
    monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path)

    response = await client.post(
        "/api/media/upload",
        files={"file": ("prod.jpg", _make_jpeg(800, 600), "image/jpeg")},
        params={"context": "product"},
    )
    assert response.status_code == 200
    data = response.json()
    thumb = Image.open(tmp_path / data["thumbnail_path"])
    assert thumb.width <= 150
    assert thumb.height <= 150


async def test_upload_audio_retourne_chemin(client, tmp_path, monkeypatch):
    from app.services import media as media_svc
    monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path)

    response = await client.post(
        "/api/media/upload",
        files={"file": ("enreg.webm", b"fake webm bytes", "audio/webm")},
        params={"context": "note"},
    )
    assert response.status_code == 200
    data = response.json()
    assert data["file_path"].startswith("audio/")
    assert data["thumbnail_path"] is None


async def test_suppression_media(client, tmp_path, monkeypatch):
    from app.services import media as media_svc
    monkeypatch.setattr(media_svc, "UPLOAD_DIR", tmp_path)

    # Upload d'abord
    up = await client.post(
        "/api/media/upload",
        files={"file": ("photo.jpg", _make_jpeg(), "image/jpeg")},
        params={"context": "note"},
    )
    data = up.json()
    file_id = data["file_id"]

    # Suppression
    response = await client.delete(f"/api/media/{file_id}")
    assert response.status_code == 204
    assert not (tmp_path / data["file_path"]).exists()
  • Étape 2 : Lancer les tests — doivent échouer
cd backend && python -m pytest tests/test_media.py -v

Résultat attendu : FAILED — les endpoints n'existent pas encore.

  • Étape 3 : Créer backend/app/schemas/media.py
# backend/app/schemas/media.py
from pydantic import BaseModel


class MediaUploadResponse(BaseModel):
    file_id: str
    file_path: str
    thumbnail_path: str | None
    file_type: str
  • Étape 4 : Créer backend/app/services/media.py
# backend/app/services/media.py
import io
import uuid
from pathlib import Path

from fastapi import HTTPException, UploadFile
from PIL import Image

from app.core.config import settings

UPLOAD_DIR: Path = settings.upload_path

ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/svg+xml"}
ALLOWED_AUDIO_TYPES = {"audio/webm", "audio/mp4"}

THUMBNAIL_SIZES = {
    "product": (150, 150),
    "note": (300, 300),
    "attachment": (400, 300),
}


async def save_image(file: UploadFile, context: str = "note") -> dict:
    if file.content_type not in ALLOWED_IMAGE_TYPES:
        raise HTTPException(status_code=400, detail=f"Format non supporté : {file.content_type}")

    file_id = str(uuid.uuid4())
    content = await file.read()

    orig_dir = UPLOAD_DIR / "images" / "originals"
    orig_dir.mkdir(parents=True, exist_ok=True)
    orig_path = orig_dir / f"{file_id}.webp"

    if file.content_type == "image/svg+xml":
        orig_path.write_bytes(content)
        return {
            "file_id": file_id,
            "file_path": str(orig_path.relative_to(UPLOAD_DIR)),
            "thumbnail_path": None,
            "file_type": "image",
        }

    img = Image.open(io.BytesIO(content)).convert("RGB")
    img.save(orig_path, "WEBP", quality=85)

    thumb_dir = UPLOAD_DIR / "images" / "thumbnails"
    thumb_dir.mkdir(parents=True, exist_ok=True)
    thumb_path = thumb_dir / f"{file_id}_thumb.webp"

    size = THUMBNAIL_SIZES.get(context, (300, 300))
    img_thumb = Image.open(io.BytesIO(content)).convert("RGB")
    img_thumb.thumbnail(size, Image.LANCZOS)
    img_thumb.save(thumb_path, "WEBP", quality=80)

    return {
        "file_id": file_id,
        "file_path": str(orig_path.relative_to(UPLOAD_DIR)),
        "thumbnail_path": str(thumb_path.relative_to(UPLOAD_DIR)),
        "file_type": "image",
    }


async def save_audio(file: UploadFile) -> dict:
    if file.content_type not in ALLOWED_AUDIO_TYPES:
        raise HTTPException(status_code=400, detail=f"Format audio non supporté : {file.content_type}")

    file_id = str(uuid.uuid4())
    audio_dir = UPLOAD_DIR / "audio"
    audio_dir.mkdir(parents=True, exist_ok=True)

    ext = ".webm" if "webm" in (file.content_type or "") else ".m4a"
    audio_path = audio_dir / f"{file_id}{ext}"
    audio_path.write_bytes(await file.read())

    return {
        "file_id": file_id,
        "file_path": str(audio_path.relative_to(UPLOAD_DIR)),
        "thumbnail_path": None,
        "file_type": "audio",
    }


def delete_media(file_id: str, file_path: str, thumbnail_path: str | None = None) -> None:
    (UPLOAD_DIR / file_path).unlink(missing_ok=True)
    if thumbnail_path:
        (UPLOAD_DIR / thumbnail_path).unlink(missing_ok=True)
  • Étape 5 : Remplacer backend/app/api/media.py (version complète)
# backend/app/api/media.py
from fastapi import APIRouter, UploadFile, File, Query
from fastapi.responses import Response

from app.schemas.media import MediaUploadResponse
from app.services.media import save_image, save_audio, delete_media, ALLOWED_IMAGE_TYPES, ALLOWED_AUDIO_TYPES

router = APIRouter()


@router.post("/upload", response_model=MediaUploadResponse)
async def upload_media(
    file: UploadFile = File(...),
    context: str = Query(default="note", pattern="^(product|note|attachment)$"),
):
    if file.content_type in ALLOWED_IMAGE_TYPES:
        result = await save_image(file, context=context)
    elif file.content_type in ALLOWED_AUDIO_TYPES:
        result = await save_audio(file)
    else:
        from fastapi import HTTPException
        raise HTTPException(status_code=400, detail=f"Type de fichier non supporté : {file.content_type}")

    return MediaUploadResponse(**result)


@router.delete("/{file_id}", status_code=204)
async def delete_media_endpoint(file_id: str, file_path: str = Query(...), thumbnail_path: str | None = Query(default=None)):
    delete_media(file_id=file_id, file_path=file_path, thumbnail_path=thumbnail_path)
    return Response(status_code=204)
  • Étape 6 : Lancer les tests — doivent passer
cd 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_retourne_chemins PASSED
tests/test_media.py::test_upload_format_invalide_retourne_400 PASSED
tests/test_media.py::test_miniature_produit_max_150px PASSED
tests/test_media.py::test_upload_audio_retourne_chemin PASSED
tests/test_media.py::test_suppression_media PASSED
  • Étape 7 : Committer
git add backend/app/services/media.py backend/app/schemas/media.py backend/app/api/media.py backend/tests/test_media.py
git commit -m "feat: module media — upload, miniatures Pillow et suppression"

Tâche 8 : Script de seed — données initiales

Fichiers :

  • Créer : backend/app/data/seed.py

  • Étape 1 : Créer backend/app/data/seed.py

# backend/app/data/seed.py
"""
Lance le seed uniquement si la table shopping.stores est vide.
Usage : python -m app.data.seed
"""
import asyncio
import json
from pathlib import Path

from sqlalchemy import select, text

from app.core.database import AsyncSessionLocal
from app.models.shopping import Product, Store

DATA_DIR = Path(__file__).parent


async def run_seed() -> None:
    async with AsyncSessionLocal() as session:
        # Ne rien faire si des données existent déjà
        count = await session.scalar(select(Store).limit(1))
        if count is not None:
            print("Seed déjà chargé — rien à faire.")
            return

        stores_raw = json.loads((DATA_DIR / "seed_stores.json").read_text())
        for s in stores_raw:
            session.add(Store(name=s["name"], location=s.get("location")))

        products_raw = json.loads((DATA_DIR / "seed_products.json").read_text())
        for p in products_raw:
            session.add(Product(
                name=p["name"],
                category=p.get("category"),
                default_unit=p.get("default_unit"),
                frequency_score=p.get("frequency_score", 0),
            ))

        await session.commit()
        print(f"Seed : {len(stores_raw)} magasins, {len(products_raw)} produits chargés.")


if __name__ == "__main__":
    asyncio.run(run_seed())
  • Étape 2 : Appeler le seed au démarrage de l'app

Dans backend/app/main.py, ajouter un lifespan :

# 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.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")
  • Étape 3 : Vérifier que les tests passent toujours
cd backend && python -m pytest tests/ -v

Résultat attendu : tous les tests passent (le lifespan ne se déclenche pas dans les tests HTTPX).

  • Étape 4 : Committer
git add backend/app/data/seed.py backend/app/main.py
git commit -m "feat: seed données initiales (113 produits + 9 magasins) au démarrage"

Tâche 9 : Frontend — scaffold Vite + React + TypeScript

Fichiers :

  • Créer : frontend/package.json

  • Créer : frontend/vite.config.ts

  • Créer : frontend/tsconfig.json

  • Créer : frontend/index.html

  • Créer : frontend/src/main.tsx

  • Créer : frontend/src/App.tsx

  • Étape 1 : Créer la structure de dossiers frontend

mkdir -p frontend/src/{components/layout,pages,design-system}
mkdir -p frontend/public/icons
  • Étape 2 : Créer frontend/package.json
{
  "name": "homehub-frontend",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-router-dom": "^6.28.0"
  },
  "devDependencies": {
    "@types/react": "^18.3.12",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.3",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "tailwindcss": "^3.4.15",
    "typescript": "^5.6.3",
    "vite": "^5.4.10",
    "vite-plugin-pwa": "^0.21.1"
  }
}
  • Étape 3 : Créer frontend/tsconfig.json
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" },
    { "path": "./tsconfig.node.json" }
  ]
}

Créer aussi frontend/tsconfig.app.json :

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true
  },
  "include": ["src"]
}

Créer aussi frontend/tsconfig.node.json :

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true
  },
  "include": ["vite.config.ts"]
}
  • Étape 4 : Créer frontend/index.html
<!doctype html>
<html lang="fr" data-theme="dark">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta name="theme-color" content="#fe8019" />
    <link rel="manifest" href="/manifest.json" />
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&family=Share+Tech+Mono&display=swap" rel="stylesheet" />
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" />
    <title>HomeHub</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>
  • Étape 5 : Créer frontend/src/main.tsx
// frontend/src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
  • Étape 6 : Créer frontend/src/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'

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<HomePage />} />
        </Route>
      </Routes>
    </BrowserRouter>
  )
}
  • Étape 7 : Installer les dépendances et vérifier que ça compile
cd frontend && npm install && npm run build

Résultat attendu : build réussi dans frontend/dist/.

  • Étape 8 : Committer
git add frontend/
git commit -m "feat: scaffold frontend React 18 + Vite 5 + TypeScript"

Tâche 10 : Frontend — Tailwind CSS et Design System Gruvbox

Fichiers :

  • Créer : frontend/tailwind.config.ts

  • Créer : frontend/postcss.config.js

  • Créer : frontend/src/index.css

  • Créer : frontend/src/design-system/tokens.css

  • Créer : frontend/src/design-system/ui-kit.tsx

  • Étape 1 : Créer frontend/postcss.config.js

// frontend/postcss.config.js
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}
  • Étape 2 : Créer frontend/tailwind.config.ts
// frontend/tailwind.config.ts
import type { Config } from 'tailwindcss'

export default {
  content: ['./index.html', './src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        accent: 'var(--accent)',
        'accent-soft': 'var(--accent-soft)',
        'bg-0': 'var(--bg-0)',
        'bg-1': 'var(--bg-1)',
        'bg-2': 'var(--bg-2)',
        'bg-3': 'var(--bg-3)',
        'bg-4': 'var(--bg-4)',
        'bg-5': 'var(--bg-5)',
        'ink-1': 'var(--ink-1)',
        'ink-2': 'var(--ink-2)',
        'ink-3': 'var(--ink-3)',
        'ink-4': 'var(--ink-4)',
        ok: 'var(--ok)',
        warn: 'var(--warn)',
        err: 'var(--err)',
        info: 'var(--info)',
        blue: 'var(--blue)',
        purple: 'var(--purple)',
      },
      fontFamily: {
        ui: ['Inter', 'system-ui', 'sans-serif'],
        mono: ['"JetBrains Mono"', 'monospace'],
        terminal: ['"Share Tech Mono"', 'monospace'],
      },
      minHeight: {
        touch: '48px',
      },
    },
  },
  plugins: [],
} satisfies Config
  • Étape 3 : Copier les tokens CSS
cp design_system/tokens/tokens.css frontend/src/design-system/tokens.css
  • Étape 4 : Créer frontend/src/index.css
/* frontend/src/index.css */
@import './design-system/tokens.css';

@tailwind base;
@tailwind components;
@tailwind utilities;

* {
  box-sizing: border-box;
}

body {
  font-family: var(--font-ui);
  background-color: var(--bg-1);
  color: var(--ink-1);
  margin: 0;
}
  • Étape 5 : Adapter ui-kit.jsx en module TypeScript

Copier design_system/components/ui-kit.jsx vers frontend/src/design-system/ui-kit.tsx, puis ajouter en tête du fichier :

// frontend/src/design-system/ui-kit.tsx
// Adapté depuis design_system/components/ui-kit.jsx
import { useState, useRef, useEffect } from 'react'

Et supprimer la ligne d'origine :

const { useState, useRef, useEffect } = React;

Puis ajouter à la fin du fichier les exports des composants existants :

export { Button, IconButton, Toggle, Tooltip, StatusLed, BatteryGauge, RadialGauge, BigRadialGauge, Popup, TreeNav, Sparkline, LineChart, Icon }
  • Étape 6 : Vérifier la compilation
cd frontend && npm run build

Résultat attendu : build réussi sans erreur.

  • Étape 7 : Committer
git add frontend/tailwind.config.ts frontend/postcss.config.js frontend/src/index.css frontend/src/design-system/
git commit -m "feat: tailwind CSS + design system Gruvbox seventies intégré"

Tâche 11 : Frontend — Configuration PWA (vite-plugin-pwa + manifest)

Fichiers :

  • Modifier : frontend/vite.config.ts

  • Créer : frontend/public/manifest.json

  • Créer : frontend/public/icons/icon-192.png (placeholder)

  • Créer : frontend/public/icons/icon-512.png (placeholder)

  • Étape 1 : Créer frontend/vite.config.ts

// frontend/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'

export default defineConfig({
  plugins: [
    react(),
    VitePWA({
      registerType: 'autoUpdate',
      workbox: {
        globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff2}'],
        runtimeCaching: [
          {
            urlPattern: /^\/api\//,
            handler: 'NetworkFirst',
            options: {
              cacheName: 'api-cache',
              expiration: { maxEntries: 100, maxAgeSeconds: 86400 },
            },
          },
        ],
      },
      manifest: {
        name: 'HomeHub',
        short_name: 'HomeHub',
        description: 'Organisation personnelle auto-hébergée',
        theme_color: '#fe8019',
        background_color: '#2a231d',
        display: 'standalone',
        orientation: 'portrait-primary',
        start_url: '/',
        icons: [
          { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
          { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
        ],
      },
    }),
  ],
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8000', changeOrigin: true },
    },
  },
})
  • Étape 2 : Créer les icônes placeholder
# Générer des icônes SVG→PNG avec Python (Pillow déjà installé en backend)
cd backend && python - <<'EOF'
from PIL import Image, ImageDraw
import os

os.makedirs("../frontend/public/icons", exist_ok=True)

for size in [192, 512]:
    img = Image.new("RGB", (size, size), color="#2a231d")
    draw = ImageDraw.Draw(img)
    draw.ellipse([size//4, size//4, size*3//4, size*3//4], fill="#fe8019")
    img.save(f"../frontend/public/icons/icon-{size}.png")
    print(f"Icône {size}x{size} créée")
EOF
  • Étape 3 : Vérifier le build avec PWA
cd frontend && npm run build && ls dist/

Résultat attendu : dossier dist/ avec sw.js, manifest.webmanifest, index.html, assets JS/CSS.

  • Étape 4 : Committer
git add frontend/vite.config.ts frontend/public/
git commit -m "feat: PWA configurée — service worker, manifest, icônes"

Tâche 12 : Frontend — Layout responsive et navigation

Fichiers :

  • Créer : frontend/src/components/layout/Layout.tsx

  • Créer : frontend/src/components/layout/BottomNav.tsx

  • Créer : frontend/src/components/layout/SideNav.tsx

  • Créer : frontend/src/pages/HomePage.tsx

  • Étape 1 : Créer frontend/src/pages/HomePage.tsx

// frontend/src/pages/HomePage.tsx
export default function HomePage() {
  return (
    <div className="p-4">
      <div className="glass" style={{ padding: 20, borderRadius: 10, marginBottom: 16 }}>
        <h1 style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', margin: 0 }}>
          HomeHub
        </h1>
        <p style={{ color: 'var(--ink-2)', marginTop: 8 }}>
          Application d&apos;organisation personnelle
        </p>
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12 }}>
        {[
          { label: 'Todos', icon: 'list', path: '/todos' },
          { label: 'Courses', icon: 'cart-shopping', path: '/shopping' },
          { label: 'Notes', icon: 'note-sticky', path: '/notes' },
        ].map((item) => (
          <div
            key={item.path}
            className="glass interactive"
            style={{ padding: 20, borderRadius: 10, textAlign: 'center', cursor: 'pointer', minHeight: 80, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8 }}
          >
            <i className={`fa-solid fa-${item.icon}`} style={{ fontSize: 24, color: 'var(--accent)' }} />
            <span style={{ color: 'var(--ink-1)', fontFamily: 'var(--font-ui)', fontSize: 14 }}>{item.label}</span>
          </div>
        ))}
      </div>
    </div>
  )
}
  • Étape 2 : Créer frontend/src/components/layout/BottomNav.tsx
// frontend/src/components/layout/BottomNav.tsx
import { NavLink } from 'react-router-dom'

const NAV_ITEMS = [
  { to: '/',         icon: 'house',          label: 'Accueil' },
  { to: '/todos',    icon: 'list-check',     label: 'Todos' },
  { to: '/shopping', icon: 'cart-shopping',  label: 'Courses' },
  { to: '/notes',    icon: 'note-sticky',    label: 'Notes' },
]

export default function BottomNav() {
  return (
    <nav style={{
      display: 'flex',
      background: 'var(--bg-2)',
      borderTop: '1px solid var(--border-2)',
      paddingBottom: 'env(safe-area-inset-bottom)',
    }}>
      {NAV_ITEMS.map((item) => (
        <NavLink
          key={item.to}
          to={item.to}
          end={item.to === '/'}
          style={({ isActive }) => ({
            flex: 1,
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            minHeight: 56,
            gap: 4,
            color: isActive ? 'var(--accent)' : 'var(--ink-3)',
            textDecoration: 'none',
            fontSize: 10,
            fontFamily: 'var(--font-ui)',
            letterSpacing: '0.05em',
            textTransform: 'uppercase',
          })}
        >
          <i className={`fa-solid fa-${item.icon}`} style={{ fontSize: 20 }} />
          {item.label}
        </NavLink>
      ))}
    </nav>
  )
}
  • Étape 3 : Créer frontend/src/components/layout/SideNav.tsx
// frontend/src/components/layout/SideNav.tsx
import { NavLink } from 'react-router-dom'

const NAV_ITEMS = [
  { to: '/',         icon: 'house',          label: 'Accueil' },
  { to: '/todos',    icon: 'list-check',     label: 'Todos' },
  { to: '/shopping', icon: 'cart-shopping',  label: 'Courses' },
  { to: '/notes',    icon: 'note-sticky',    label: 'Notes' },
]

export default function SideNav() {
  return (
    <nav style={{
      width: 220,
      background: 'var(--bg-2)',
      borderRight: '1px solid var(--border-1)',
      display: 'flex',
      flexDirection: 'column',
      padding: '16px 0',
      height: '100%',
    }}>
      <div style={{ padding: '0 16px 16px', borderBottom: '1px solid var(--border-1)', marginBottom: 8 }}>
        <span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)', fontWeight: 700, fontSize: 16 }}>
          HomeHub
        </span>
      </div>
      {NAV_ITEMS.map((item) => (
        <NavLink
          key={item.to}
          to={item.to}
          end={item.to === '/'}
          style={({ isActive }) => ({
            display: 'flex',
            alignItems: 'center',
            gap: 12,
            padding: '10px 16px',
            color: isActive ? 'var(--accent)' : 'var(--ink-2)',
            background: isActive ? 'var(--accent-tint)' : 'transparent',
            borderLeft: isActive ? '2px solid var(--accent)' : '2px solid transparent',
            textDecoration: 'none',
            fontFamily: 'var(--font-ui)',
            fontSize: 14,
            minHeight: 40,
          })}
        >
          <i className={`fa-solid fa-${item.icon}`} style={{ width: 18 }} />
          {item.label}
        </NavLink>
      ))}
    </nav>
  )
}
  • Étape 4 : Créer frontend/src/components/layout/Layout.tsx
// frontend/src/components/layout/Layout.tsx
import { Outlet } from 'react-router-dom'
import BottomNav from './BottomNav'
import SideNav from './SideNav'

export default function Layout() {
  return (
    <div style={{ display: 'flex', height: '100dvh', background: 'var(--bg-1)' }}>
      {/* Sidebar — visible uniquement sur laptop (lg et +) */}
      <div className="hidden lg:flex" style={{ flexShrink: 0 }}>
        <SideNav />
      </div>

      {/* Contenu principal */}
      <main style={{ flex: 1, overflow: 'auto', paddingBottom: 0 }} className="lg:pb-0 pb-14">
        <Outlet />
      </main>

      {/* Navigation bas — visible uniquement sur mobile */}
      <div className="lg:hidden" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 50 }}>
        <BottomNav />
      </div>
    </div>
  )
}
  • Étape 5 : Vérifier la compilation
cd frontend && npm run build

Résultat attendu : build réussi, 0 erreur TypeScript.

  • Étape 6 : Committer
git add frontend/src/
git commit -m "feat: layout responsive mobile (bottom nav) / laptop (sidebar) + page d'accueil"

Tâche 13 : Frontend — Dockerfile

Fichiers :

  • Créer : frontend/Dockerfile

  • Modifier : docker-compose.yml (déjà référence ./frontend)

  • Étape 1 : Créer frontend/Dockerfile

# frontend/Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
  • Étape 2 : Créer frontend/nginx.conf
# frontend/nginx.conf
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # Compression gzip
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;

    # Cache assets statiques (JS/CSS avec hash)
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|webp|woff2|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # PWA : toujours servir index.html (SPA routing)
    location / {
        try_files $uri $uri/ /index.html;
    }
}
  • Étape 3 : Build du conteneur Docker frontend
docker build -t homehub-frontend ./frontend

Résultat attendu : image construite avec succès.

  • Étape 4 : Committer
git add frontend/Dockerfile frontend/nginx.conf
git commit -m "feat: dockerfile frontend nginx + cache statique"

Tâche 14 : Environnement de développement local

Fichiers :

  • Créer : docker-compose.dev.yml

  • Créer : dev.sh

  • Étape 1 : Créer docker-compose.dev.yml

# docker-compose.dev.yml
# Surcharge docker-compose.yml pour le développement local
# Utilisation : docker compose -f docker-compose.yml -f docker-compose.dev.yml up
services:
  db:
    ports:
      - "5432:5432"   # Accès direct depuis la machine host

  backend:
    build:
      context: ./backend
    volumes:
      - ./backend:/app          # Hot reload : les fichiers Python sont montés
      - uploads:/uploads
    environment:
      DATABASE_URL: postgresql+asyncpg://homehub:homehub@db:5432/homehub
      UPLOAD_DIR: /uploads
      CORS_ORIGINS: http://localhost:3000
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
    ports:
      - "8000:8000"

  frontend:
    image: node:20-alpine
    working_dir: /app
    volumes:
      - ./frontend:/app
      - /app/node_modules        # Volume anonyme pour node_modules (évite l'écrasement)
    environment:
      VITE_API_URL: http://localhost:8000
    command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
    ports:
      - "3000:5173"
    depends_on:
      - backend
  • Étape 2 : Créer dev.sh
#!/bin/bash
# dev.sh — démarre l'environnement de développement local HomeHub
set -e

echo "=== HomeHub Dev ==="

# Copier .env.example si .env n'existe pas
if [ ! -f .env ]; then
  cp .env.example .env
  echo "✓ .env créé depuis .env.example"
fi

# Démarrer les services
docker compose -f docker-compose.yml -f docker-compose.dev.yml up "$@"
chmod +x dev.sh
  • Étape 3 : Tester le démarrage complet
./dev.sh

Résultat attendu dans les logs :

backend-1   | INFO:     Application startup complete.
backend-1   | Seed : 9 magasins, 113 produits chargés.
frontend-1  | VITE v5.x.x  ready in XXXms
frontend-1  | ➜  Local:   http://0.0.0.0:5173/

Ouvrir http://localhost:3000 dans un navigateur. Vérifier : fond brun Gruvbox, titre "HomeHub" en orange, 3 tuiles (Todos, Courses, Notes), navigation bas sur mobile / sidebar sur laptop.

  • Étape 4 : Vérifier l'endpoint health
curl http://localhost:8000/api/health

Résultat attendu : {"status":"ok"}

  • Étape 5 : Committer
git add docker-compose.dev.yml dev.sh
git commit -m "chore: environnement dev local (hot reload backend + frontend)"

Tâche 15 : Migration en production et vérification finale

  • Étape 1 : Build production complet
docker compose build
docker compose up -d
  • Étape 2 : Appliquer les migrations en production
docker compose exec backend alembic upgrade head

Résultat attendu :

INFO  [alembic.runtime.migration] Running upgrade  -> 001, Schémas initiaux et toutes les tables
  • Étape 3 : Vérifier les schémas PostgreSQL
docker compose exec db psql -U homehub -c "\dn"

Résultat attendu :

  List of schemas
   Name   |  Owner
----------+---------
 notes    | homehub
 public   | pg_database_owner
 shopping | homehub
 todos    | homehub
  • Étape 4 : Vérifier le seed
docker compose exec db psql -U homehub -c "SELECT COUNT(*) FROM shopping.products;"

Résultat attendu : 113

docker compose exec db psql -U homehub -c "SELECT name FROM shopping.stores ORDER BY name;"

Résultat attendu : 9 magasins listés (Bricocash, Cosi, Gamm Vert, Intermarché, Lidl, Marie Blachère, Super U, Tinel, Weldom).

  • Étape 5 : Lancer la suite de tests complète
cd backend && python -m pytest tests/ -v

Résultat attendu : tous les tests passent.

  • Étape 6 : Committer et taguer la Phase 1
git add -A
git commit -m "chore: Phase 1 complète — socle technique HomeHub opérationnel"
git tag v0.1.0-phase1

Auto-révision du plan

Couverture spec :

  • 3 services Docker obligatoires (frontend, backend, db)
  • PostgreSQL 16 avec schémas todos, shopping, notes
  • Toutes les tables et colonnes définies dans la spec section 3
  • Colonnes owner_id nullable dans toutes les tables
  • Index GIN sur todos.tags, notes.tags, notes.FTS
  • Module media : upload, validation formats, miniatures Pillow (150×150/300×300/400×300)
  • Structure /uploads/images/originals/ + /uploads/images/thumbnails/ + /uploads/audio/
  • Design system Gruvbox : tokens CSS importés, composants adaptés
  • Tailwind configuré avec variables CSS Gruvbox
  • PWA : vite-plugin-pwa, manifest.json, icônes, service worker NetworkFirst pour /api/
  • Layout responsive : bottom nav (mobile) + sidebar (laptop ≥ lg)
  • Seed : 113 produits + 9 magasins au démarrage
  • Script dev.sh + docker-compose.dev.yml (hot reload)
  • CORS configuré pour réseau local
  • Tests : health + media (upload, validation, taille thumbnail, audio, suppression)

Hors scope Phase 1 (prévu phases suivantes) :

  • Endpoints métier todos/shopping/notes → Phase 2/3/4
  • Services OCR, product-search, SearXNG → Phase 4b/5
  • Authentification JWT → Phase 8