Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
65 KiB
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 : FAILED — ImportError: 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.jsxen 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'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_idnullable 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