Files
jardin/docs/plans/2026-02-21-jardin-webapp-implementation.md
gilles c1fe3e2636 docs: plan d'implémentation webapp jardin (11 tâches)
Plan complet : Docker, FastAPI + SQLModel + tests TDD, Vue 3 + Tailwind Gruvbox.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 21:07:19 +01:00

72 KiB
Raw Permalink Blame History

Webapp Jardin — Plan d'implémentation

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Scaffold complet (Docker + FastAPI backend + Vue 3 frontend) avec CRUD fonctionnel pour jardins, variétés, plantations et tâches, données de démo incluses.

Architecture: Monorepo backend/ (FastAPI + SQLModel + SQLite) + frontend/ (Vue 3 + Vite + TypeScript + Tailwind + Pinia). Docker Compose orchestre les deux services + volume persistant data/.

Tech Stack: Python 3.12, FastAPI 0.115, SQLModel, SQLite, Vue 3, Vite, TypeScript, Tailwind CSS 3, Pinia, Vue Router 4, Axios, Docker Compose, Nginx


Task 1: Structure du projet + Docker Compose

Files:

  • Create: docker-compose.yml
  • Create: backend/Dockerfile
  • Create: frontend/Dockerfile
  • Create: frontend/nginx.conf
  • Create: .env.example
  • Create: data/.gitkeep
  • Create: .gitignore

Step 1: Créer les répertoires

mkdir -p backend/app/{models,routers} backend/tests data \
  frontend/src/{api,components,router,stores,views}
touch data/.gitkeep backend/app/__init__.py backend/app/models/__init__.py backend/app/routers/__init__.py backend/tests/__init__.py

Step 2: Créer .env.example

BACKEND_PORT=8000
CORS_ORIGINS=http://localhost:5173,http://localhost
DATABASE_URL=sqlite:////data/jardin.db
UPLOAD_DIR=/data/uploads
VITE_API_URL=http://localhost:8000

Step 3: Créer .gitignore

data/*.db
data/uploads/
backend/__pycache__/
backend/.venv/
backend/*.pyc
frontend/node_modules/
frontend/dist/
.env
*.egg-info/
.pytest_cache/

Step 4: Créer docker-compose.yml

services:
  backend:
    build: ./backend
    volumes:
      - ./data:/data
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=sqlite:////data/jardin.db
      - UPLOAD_DIR=/data/uploads
      - CORS_ORIGINS=http://localhost
    restart: unless-stopped

  frontend:
    build: ./frontend
    ports:
      - "80:80"
    depends_on:
      - backend
    restart: unless-stopped

Step 5: Créer backend/Dockerfile

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /data/uploads
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Step 6: Créer frontend/Dockerfile

FROM node:22-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

Step 7: Créer frontend/nginx.conf

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    location /api/ {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
    }

    location /uploads/ {
        proxy_pass http://backend:8000;
    }

    location / {
        try_files $uri $uri/ /index.html;
    }
}

Step 8: Commit

git add .
git commit -m "chore: scaffold projet + docker-compose"

Task 2: Backend — Setup FastAPI + DB

Files:

  • Create: backend/requirements.txt
  • Create: backend/app/config.py
  • Create: backend/app/database.py
  • Create: backend/app/seed.py
  • Create: backend/app/main.py

Step 1: Créer backend/requirements.txt

fastapi==0.115.5
uvicorn[standard]==0.32.1
sqlmodel==0.0.22
python-multipart==0.0.12
aiofiles==24.1.0
pytest==8.3.3
httpx==0.28.0

Step 2: Créer backend/app/config.py

import os

DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./jardin.db")
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "./data/uploads")
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "http://localhost:5173").split(",")

Step 3: Créer backend/app/database.py

from sqlmodel import SQLModel, create_engine, Session
from app.config import DATABASE_URL

engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})


def get_session():
    with Session(engine) as session:
        yield session


def create_db_and_tables():
    SQLModel.metadata.create_all(engine)

Step 4: Créer backend/app/seed.py (stub, rempli en Task 6)

def run_seed():
    pass

Step 5: Créer backend/app/main.py

import os
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from app.config import CORS_ORIGINS, UPLOAD_DIR
from app.database import create_db_and_tables


@asynccontextmanager
async def lifespan(app: FastAPI):
    os.makedirs(UPLOAD_DIR, exist_ok=True)
    import app.models  # noqa — enregistre tous les modèles avant create_all
    create_db_and_tables()
    from app.seed import run_seed
    run_seed()
    yield


app = FastAPI(title="Jardin API", lifespan=lifespan)

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    allow_methods=["*"],
    allow_headers=["*"],
)


@app.get("/api/health")
def health():
    return {"status": "ok"}


# Routers enregistrés après leur création (Tasks 4-6)

Step 6: Commit

git add backend/
git commit -m "feat(backend): setup FastAPI + SQLite + config"

Task 3: Backend — Modèles SQLModel

Files:

  • Create: backend/app/models/garden.py
  • Create: backend/app/models/plant.py
  • Create: backend/app/models/planting.py
  • Create: backend/app/models/task.py
  • Create: backend/app/models/settings.py
  • Modify: backend/app/models/__init__.py

Step 1: Créer backend/app/models/garden.py

from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel


class Garden(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    nom: str
    description: Optional[str] = None
    type: str = "plein_air"  # plein_air | serre | tunnel
    latitude: Optional[float] = None
    longitude: Optional[float] = None
    altitude: Optional[float] = None
    adresse: Optional[str] = None
    exposition: Optional[str] = None
    ombre: Optional[str] = None  # ombre | mi-ombre | plein_soleil
    sol_type: Optional[str] = None
    sol_ph: Optional[float] = None
    grille_largeur: int = 6
    grille_hauteur: int = 4
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)


class GardenCell(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    garden_id: int = Field(foreign_key="garden.id", index=True)
    col: int
    row: int
    libelle: Optional[str] = None
    largeur_m: Optional[float] = None
    hauteur_m: Optional[float] = None
    etat: str = "libre"  # libre | occupe
    notes: Optional[str] = None


class GardenImage(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    garden_id: int = Field(foreign_key="garden.id", index=True)
    filename: str
    caption: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)


class Measurement(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    garden_id: int = Field(foreign_key="garden.id", index=True)
    temp_air: Optional[float] = None
    temp_sol: Optional[float] = None
    humidite_air: Optional[float] = None
    humidite_sol: Optional[float] = None
    source: str = "manuel"  # manuel | capteur
    ts: datetime = Field(default_factory=datetime.utcnow)

Step 2: Créer backend/app/models/plant.py

from datetime import datetime
from typing import Optional
from sqlmodel import Field, SQLModel


class PlantVariety(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    nom_commun: str
    nom_botanique: Optional[str] = None
    variete: Optional[str] = None
    famille: Optional[str] = None
    tags: Optional[str] = None  # CSV
    type_plante: Optional[str] = None  # legume | fruit | aromatique | fleur
    besoin_eau: Optional[str] = None  # faible | moyen | fort
    besoin_soleil: Optional[str] = None
    espacement_cm: Optional[int] = None
    temp_min_c: Optional[float] = None
    duree_culture_j: Optional[int] = None
    profondeur_semis_cm: Optional[float] = None
    sol_conseille: Optional[str] = None
    semis_interieur_mois: Optional[str] = None  # ex: "2,3"
    semis_exterieur_mois: Optional[str] = None
    repiquage_mois: Optional[str] = None
    plantation_mois: Optional[str] = None
    recolte_mois: Optional[str] = None
    notes: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)


class PlantImage(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    variety_id: int = Field(foreign_key="plantvariety.id", index=True)
    filename: str
    caption: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)

Step 3: Créer backend/app/models/planting.py

from datetime import date, datetime
from typing import Optional
from sqlmodel import Field, SQLModel


class Planting(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    garden_id: int = Field(foreign_key="garden.id", index=True)
    variety_id: int = Field(foreign_key="plantvariety.id", index=True)
    cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id")
    date_semis: Optional[date] = None
    date_plantation: Optional[date] = None
    date_repiquage: Optional[date] = None
    quantite: int = 1
    statut: str = "prevu"  # prevu | en_cours | termine | echoue
    date_recolte_debut: Optional[date] = None
    date_recolte_fin: Optional[date] = None
    rendement_estime: Optional[float] = None
    rendement_reel: Optional[float] = None
    notes: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)


class PlantingEvent(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    planting_id: int = Field(foreign_key="planting.id", index=True)
    type: str  # arrosage | taille | traitement | observation | autre
    note: Optional[str] = None
    ts: datetime = Field(default_factory=datetime.utcnow)

Step 4: Créer backend/app/models/task.py

from datetime import date, datetime
from typing import Optional
from sqlmodel import Field, SQLModel


class Task(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    titre: str
    description: Optional[str] = None
    garden_id: Optional[int] = Field(default=None, foreign_key="garden.id")
    planting_id: Optional[int] = Field(default=None, foreign_key="planting.id")
    priorite: str = "normale"  # basse | normale | haute
    echeance: Optional[date] = None
    recurrence: Optional[str] = None  # quotidien | hebdomadaire | mensuel
    statut: str = "a_faire"  # a_faire | en_cours | fait | annule
    created_at: datetime = Field(default_factory=datetime.utcnow)
    updated_at: datetime = Field(default_factory=datetime.utcnow)

Step 5: Créer backend/app/models/settings.py

from datetime import date
from typing import Optional
from sqlmodel import Field, SQLModel


class UserSettings(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    cle: str = Field(unique=True)
    valeur: str


class LunarCalendarEntry(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    jour: date = Field(unique=True)
    phase: str  # nouvelle_lune | premier_quartier | pleine_lune | dernier_quartier | croissante | decroissante
    type_jour: Optional[str] = None  # racine | feuille | fleur | fruit
    lune_montante: Optional[bool] = None

Step 6: Remplir backend/app/models/__init__.py

from app.models.garden import Garden, GardenCell, GardenImage, Measurement  # noqa
from app.models.plant import PlantVariety, PlantImage  # noqa
from app.models.planting import Planting, PlantingEvent  # noqa
from app.models.task import Task  # noqa
from app.models.settings import UserSettings, LunarCalendarEntry  # noqa

Step 7: Commit

git add backend/app/models/
git commit -m "feat(backend): modèles SQLModel (10 tables)"

Task 4: Backend — Router jardins (TDD)

Files:

  • Create: backend/tests/conftest.py
  • Create: backend/tests/test_gardens.py
  • Create: backend/app/routers/gardens.py
  • Modify: backend/app/main.py

Step 1: Créer backend/tests/conftest.py

import pytest
from fastapi.testclient import TestClient
from sqlmodel import SQLModel, create_engine, Session
from sqlmodel.pool import StaticPool

import app.models  # noqa — force l'enregistrement des modèles
from app.main import app
from app.database import get_session


@pytest.fixture(name="session")
def session_fixture():
    engine = create_engine(
        "sqlite://",
        connect_args={"check_same_thread": False},
        poolclass=StaticPool,
    )
    SQLModel.metadata.create_all(engine)
    with Session(engine) as session:
        yield session


@pytest.fixture(name="client")
def client_fixture(session: Session):
    def get_session_override():
        yield session

    app.dependency_overrides[get_session] = get_session_override
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

Step 2: Créer backend/tests/test_gardens.py

def test_health(client):
    r = client.get("/api/health")
    assert r.status_code == 200


def test_create_garden(client):
    r = client.post("/api/gardens", json={"nom": "Mon potager", "type": "plein_air"})
    assert r.status_code == 201
    data = r.json()
    assert data["nom"] == "Mon potager"
    assert data["id"] is not None


def test_list_gardens(client):
    client.post("/api/gardens", json={"nom": "Jardin 1", "type": "plein_air"})
    client.post("/api/gardens", json={"nom": "Jardin 2", "type": "serre"})
    r = client.get("/api/gardens")
    assert r.status_code == 200
    assert len(r.json()) == 2


def test_get_garden(client):
    r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
    id = r.json()["id"]
    r2 = client.get(f"/api/gardens/{id}")
    assert r2.status_code == 200
    assert r2.json()["nom"] == "Potager"


def test_update_garden(client):
    r = client.post("/api/gardens", json={"nom": "Vieux nom", "type": "plein_air"})
    id = r.json()["id"]
    r2 = client.put(f"/api/gardens/{id}", json={"nom": "Nouveau nom", "type": "serre"})
    assert r2.status_code == 200
    assert r2.json()["nom"] == "Nouveau nom"


def test_delete_garden(client):
    r = client.post("/api/gardens", json={"nom": "À supprimer", "type": "plein_air"})
    id = r.json()["id"]
    r2 = client.delete(f"/api/gardens/{id}")
    assert r2.status_code == 204
    r3 = client.get(f"/api/gardens/{id}")
    assert r3.status_code == 404


def test_create_measurement(client):
    r = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"})
    id = r.json()["id"]
    r2 = client.post(
        f"/api/gardens/{id}/measurements",
        json={"temp_air": 22.5, "humidite_air": 60.0},
    )
    assert r2.status_code == 201

Step 3: Lancer les tests — vérifier qu'ils échouent

cd backend && pip install -r requirements.txt && pytest tests/test_gardens.py -v

Attendu : ImportError ou 404 (routers non créés).

Step 4: Créer backend/app/routers/gardens.py

from datetime import datetime
from typing import List

from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select

from app.database import get_session
from app.models.garden import Garden, GardenCell, GardenImage, Measurement

router = APIRouter(tags=["jardins"])


@router.get("/gardens", response_model=List[Garden])
def list_gardens(session: Session = Depends(get_session)):
    return session.exec(select(Garden)).all()


@router.post("/gardens", response_model=Garden, status_code=status.HTTP_201_CREATED)
def create_garden(garden: Garden, session: Session = Depends(get_session)):
    session.add(garden)
    session.commit()
    session.refresh(garden)
    return garden


@router.get("/gardens/{id}", response_model=Garden)
def get_garden(id: int, session: Session = Depends(get_session)):
    g = session.get(Garden, id)
    if not g:
        raise HTTPException(status_code=404, detail="Jardin introuvable")
    return g


@router.put("/gardens/{id}", response_model=Garden)
def update_garden(id: int, data: Garden, session: Session = Depends(get_session)):
    g = session.get(Garden, id)
    if not g:
        raise HTTPException(status_code=404, detail="Jardin introuvable")
    for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
        setattr(g, k, v)
    g.updated_at = datetime.utcnow()
    session.add(g)
    session.commit()
    session.refresh(g)
    return g


@router.delete("/gardens/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_garden(id: int, session: Session = Depends(get_session)):
    g = session.get(Garden, id)
    if not g:
        raise HTTPException(status_code=404, detail="Jardin introuvable")
    session.delete(g)
    session.commit()


@router.get("/gardens/{id}/cells", response_model=List[GardenCell])
def list_cells(id: int, session: Session = Depends(get_session)):
    return session.exec(select(GardenCell).where(GardenCell.garden_id == id)).all()


@router.post("/gardens/{id}/cells", response_model=GardenCell, status_code=status.HTTP_201_CREATED)
def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_session)):
    cell.garden_id = id
    session.add(cell)
    session.commit()
    session.refresh(cell)
    return cell


@router.get("/gardens/{id}/measurements", response_model=List[Measurement])
def list_measurements(id: int, session: Session = Depends(get_session)):
    return session.exec(select(Measurement).where(Measurement.garden_id == id)).all()


@router.post("/gardens/{id}/measurements", response_model=Measurement, status_code=status.HTTP_201_CREATED)
def create_measurement(id: int, m: Measurement, session: Session = Depends(get_session)):
    m.garden_id = id
    session.add(m)
    session.commit()
    session.refresh(m)
    return m

Step 5: Créer les routers stub pour les autres ressources (pour que main.py démarre) :

backend/app/routers/varieties.py, plantings.py, tasks.py, settings.py, media.py — chacun :

from fastapi import APIRouter
router = APIRouter()

Step 6: Enregistrer les routers dans backend/app/main.py — ajouter après le middleware :

from app.routers import gardens, varieties, plantings, tasks, settings, media

app.include_router(gardens.router, prefix="/api")
app.include_router(varieties.router, prefix="/api")
app.include_router(plantings.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(media.router, prefix="/api")

Aussi ajouter le mount des uploads après les routers :

from fastapi.staticfiles import StaticFiles
app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads")

Step 7: Lancer les tests — vérifier qu'ils passent

cd backend && pytest tests/test_gardens.py -v

Attendu : tous PASS.

Step 8: Commit

git add backend/
git commit -m "feat(backend): CRUD jardins + tests"

Task 5: Backend — Routers variétés, plantations, tâches (TDD)

Files:

  • Create: backend/tests/test_varieties.py
  • Create: backend/tests/test_plantings.py
  • Create: backend/tests/test_tasks.py
  • Modify: backend/app/routers/varieties.py
  • Modify: backend/app/routers/plantings.py
  • Modify: backend/app/routers/tasks.py

Step 1: Créer backend/tests/test_varieties.py

def test_create_variety(client):
    r = client.post("/api/varieties", json={"nom_commun": "Tomate", "famille": "Solanacées"})
    assert r.status_code == 201
    assert r.json()["nom_commun"] == "Tomate"


def test_list_varieties(client):
    client.post("/api/varieties", json={"nom_commun": "Tomate"})
    client.post("/api/varieties", json={"nom_commun": "Courgette"})
    r = client.get("/api/varieties")
    assert r.status_code == 200
    assert len(r.json()) == 2


def test_get_variety(client):
    r = client.post("/api/varieties", json={"nom_commun": "Basilic"})
    id = r.json()["id"]
    r2 = client.get(f"/api/varieties/{id}")
    assert r2.status_code == 200


def test_delete_variety(client):
    r = client.post("/api/varieties", json={"nom_commun": "Test"})
    id = r.json()["id"]
    r2 = client.delete(f"/api/varieties/{id}")
    assert r2.status_code == 204

Step 2: Créer backend/tests/test_plantings.py

def test_create_planting(client):
    g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
    v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
    r = client.post("/api/plantings", json={
        "garden_id": g["id"], "variety_id": v["id"], "quantite": 3
    })
    assert r.status_code == 201
    assert r.json()["statut"] == "prevu"


def test_list_plantings(client):
    g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
    v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
    client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]})
    r = client.get("/api/plantings")
    assert r.status_code == 200
    assert len(r.json()) >= 1


def test_add_planting_event(client):
    g = client.post("/api/gardens", json={"nom": "Potager", "type": "plein_air"}).json()
    v = client.post("/api/varieties", json={"nom_commun": "Tomate"}).json()
    p = client.post("/api/plantings", json={"garden_id": g["id"], "variety_id": v["id"]}).json()
    r = client.post(f"/api/plantings/{p['id']}/events", json={"type": "arrosage", "note": "Bien arrosé"})
    assert r.status_code == 201

Step 3: Créer backend/tests/test_tasks.py

def test_create_task(client):
    r = client.post("/api/tasks", json={"titre": "Arroser les tomates", "priorite": "haute"})
    assert r.status_code == 201
    assert r.json()["statut"] == "a_faire"


def test_list_tasks(client):
    client.post("/api/tasks", json={"titre": "Tâche 1"})
    client.post("/api/tasks", json={"titre": "Tâche 2"})
    r = client.get("/api/tasks")
    assert r.status_code == 200
    assert len(r.json()) == 2


def test_filter_tasks_by_statut(client):
    client.post("/api/tasks", json={"titre": "À faire", "statut": "a_faire"})
    client.post("/api/tasks", json={"titre": "Fait", "statut": "fait"})
    r = client.get("/api/tasks?statut=a_faire")
    assert r.status_code == 200
    assert all(t["statut"] == "a_faire" for t in r.json())


def test_update_task_statut(client):
    r = client.post("/api/tasks", json={"titre": "À faire"})
    id = r.json()["id"]
    r2 = client.put(f"/api/tasks/{id}", json={"titre": "À faire", "statut": "fait"})
    assert r2.status_code == 200
    assert r2.json()["statut"] == "fait"

Step 4: Lancer les tests — vérifier qu'ils échouent

cd backend && pytest tests/test_varieties.py tests/test_plantings.py tests/test_tasks.py -v

Attendu : 404 (routers vides).

Step 5: Implémenter backend/app/routers/varieties.py

from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.plant import PlantVariety

router = APIRouter(tags=["variétés"])


@router.get("/varieties", response_model=List[PlantVariety])
def list_varieties(session: Session = Depends(get_session)):
    return session.exec(select(PlantVariety)).all()


@router.post("/varieties", response_model=PlantVariety, status_code=status.HTTP_201_CREATED)
def create_variety(v: PlantVariety, session: Session = Depends(get_session)):
    session.add(v)
    session.commit()
    session.refresh(v)
    return v


@router.get("/varieties/{id}", response_model=PlantVariety)
def get_variety(id: int, session: Session = Depends(get_session)):
    v = session.get(PlantVariety, id)
    if not v:
        raise HTTPException(status_code=404, detail="Variété introuvable")
    return v


@router.put("/varieties/{id}", response_model=PlantVariety)
def update_variety(id: int, data: PlantVariety, session: Session = Depends(get_session)):
    v = session.get(PlantVariety, id)
    if not v:
        raise HTTPException(status_code=404, detail="Variété introuvable")
    for k, val in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
        setattr(v, k, val)
    session.add(v)
    session.commit()
    session.refresh(v)
    return v


@router.delete("/varieties/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_variety(id: int, session: Session = Depends(get_session)):
    v = session.get(PlantVariety, id)
    if not v:
        raise HTTPException(status_code=404, detail="Variété introuvable")
    session.delete(v)
    session.commit()

Step 6: Implémenter backend/app/routers/plantings.py

from datetime import datetime
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.planting import Planting, PlantingEvent

router = APIRouter(tags=["plantations"])


@router.get("/plantings", response_model=List[Planting])
def list_plantings(session: Session = Depends(get_session)):
    return session.exec(select(Planting)).all()


@router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED)
def create_planting(p: Planting, session: Session = Depends(get_session)):
    session.add(p)
    session.commit()
    session.refresh(p)
    return p


@router.get("/plantings/{id}", response_model=Planting)
def get_planting(id: int, session: Session = Depends(get_session)):
    p = session.get(Planting, id)
    if not p:
        raise HTTPException(status_code=404, detail="Plantation introuvable")
    return p


@router.put("/plantings/{id}", response_model=Planting)
def update_planting(id: int, data: Planting, session: Session = Depends(get_session)):
    p = session.get(Planting, id)
    if not p:
        raise HTTPException(status_code=404, detail="Plantation introuvable")
    for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
        setattr(p, k, v)
    p.updated_at = datetime.utcnow()
    session.add(p)
    session.commit()
    session.refresh(p)
    return p


@router.delete("/plantings/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_planting(id: int, session: Session = Depends(get_session)):
    p = session.get(Planting, id)
    if not p:
        raise HTTPException(status_code=404, detail="Plantation introuvable")
    session.delete(p)
    session.commit()


@router.get("/plantings/{id}/events", response_model=List[PlantingEvent])
def list_events(id: int, session: Session = Depends(get_session)):
    return session.exec(select(PlantingEvent).where(PlantingEvent.planting_id == id)).all()


@router.post("/plantings/{id}/events", response_model=PlantingEvent, status_code=status.HTTP_201_CREATED)
def create_event(id: int, e: PlantingEvent, session: Session = Depends(get_session)):
    e.planting_id = id
    session.add(e)
    session.commit()
    session.refresh(e)
    return e

Step 7: Implémenter backend/app/routers/tasks.py

from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.task import Task

router = APIRouter(tags=["tâches"])


@router.get("/tasks", response_model=List[Task])
def list_tasks(
    statut: Optional[str] = None,
    garden_id: Optional[int] = None,
    session: Session = Depends(get_session),
):
    q = select(Task)
    if statut:
        q = q.where(Task.statut == statut)
    if garden_id:
        q = q.where(Task.garden_id == garden_id)
    return session.exec(q).all()


@router.post("/tasks", response_model=Task, status_code=status.HTTP_201_CREATED)
def create_task(t: Task, session: Session = Depends(get_session)):
    session.add(t)
    session.commit()
    session.refresh(t)
    return t


@router.get("/tasks/{id}", response_model=Task)
def get_task(id: int, session: Session = Depends(get_session)):
    t = session.get(Task, id)
    if not t:
        raise HTTPException(status_code=404, detail="Tâche introuvable")
    return t


@router.put("/tasks/{id}", response_model=Task)
def update_task(id: int, data: Task, session: Session = Depends(get_session)):
    t = session.get(Task, id)
    if not t:
        raise HTTPException(status_code=404, detail="Tâche introuvable")
    for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
        setattr(t, k, v)
    t.updated_at = datetime.utcnow()
    session.add(t)
    session.commit()
    session.refresh(t)
    return t


@router.delete("/tasks/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(id: int, session: Session = Depends(get_session)):
    t = session.get(Task, id)
    if not t:
        raise HTTPException(status_code=404, detail="Tâche introuvable")
    session.delete(t)
    session.commit()

Step 8: Lancer tous les tests

cd backend && pytest tests/ -v

Attendu : tous PASS.

Step 9: Commit

git add backend/
git commit -m "feat(backend): CRUD variétés, plantations, tâches + tests"

Task 6: Backend — Settings, upload media + seed

Files:

  • Modify: backend/app/routers/settings.py
  • Modify: backend/app/routers/media.py
  • Modify: backend/app/seed.py

Step 1: Implémenter backend/app/routers/settings.py

from datetime import date
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from app.database import get_session
from app.models.settings import UserSettings, LunarCalendarEntry

router = APIRouter(tags=["réglages"])


@router.get("/settings")
def get_settings(session: Session = Depends(get_session)):
    rows = session.exec(select(UserSettings)).all()
    return {r.cle: r.valeur for r in rows}


@router.put("/settings")
def update_settings(data: dict, session: Session = Depends(get_session)):
    for cle, valeur in data.items():
        row = session.exec(select(UserSettings).where(UserSettings.cle == cle)).first()
        if row:
            row.valeur = str(valeur)
        else:
            row = UserSettings(cle=cle, valeur=str(valeur))
        session.add(row)
    session.commit()
    return {"ok": True}


@router.get("/lunar")
def get_lunar(month: str, session: Session = Depends(get_session)):
    year, m = map(int, month.split("-"))
    first = date(year, m, 1)
    last_m, last_y = (m + 1, year) if m < 12 else (1, year + 1)
    last = date(last_y, last_m, 1)
    return session.exec(
        select(LunarCalendarEntry)
        .where(LunarCalendarEntry.jour >= first)
        .where(LunarCalendarEntry.jour < last)
    ).all()

Step 2: Implémenter backend/app/routers/media.py

import os
import uuid
from fastapi import APIRouter, File, HTTPException, UploadFile
from app.config import UPLOAD_DIR

router = APIRouter(tags=["media"])

ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif"}


@router.post("/upload")
async def upload_file(file: UploadFile = File(...)):
    ext = os.path.splitext(file.filename or "")[-1].lower()
    if ext not in ALLOWED_EXT:
        raise HTTPException(status_code=400, detail="Format non supporté")
    filename = f"{uuid.uuid4().hex}{ext}"
    dest = os.path.join(UPLOAD_DIR, filename)
    content = await file.read()
    with open(dest, "wb") as f:
        f.write(content)
    return {"filename": filename, "url": f"/uploads/{filename}"}


@router.delete("/upload/{filename}", status_code=204)
def delete_file(filename: str):
    path = os.path.join(UPLOAD_DIR, filename)
    if os.path.exists(path):
        os.remove(path)

Step 3: Remplir backend/app/seed.py

from datetime import date
from sqlmodel import Session, select
from app.database import engine
import app.models  # noqa


def run_seed():
    from app.models.garden import Garden, GardenCell, Measurement
    from app.models.plant import PlantVariety
    from app.models.planting import Planting, PlantingEvent
    from app.models.task import Task

    with Session(engine) as session:
        if session.exec(select(Garden)).first():
            return  # déjà seedé

        jardin = Garden(
            nom="Mon potager",
            description="Potager principal plein sud",
            type="plein_air",
            exposition="S",
            ombre="plein_soleil",
            sol_type="limoneux",
            grille_largeur=6,
            grille_hauteur=4,
        )
        session.add(jardin)
        session.flush()

        for row in range(4):
            for col in range(6):
                session.add(GardenCell(
                    garden_id=jardin.id,
                    col=col, row=row,
                    libelle=f"{chr(65 + row)}{col + 1}",
                ))

        session.add(Measurement(garden_id=jardin.id, temp_air=18.0, humidite_air=65.0))

        tomate = PlantVariety(
            nom_commun="Tomate", variete="Andine Cornue",
            famille="Solanacées", type_plante="legume",
            besoin_eau="fort", espacement_cm=60,
            plantation_mois="4,5", recolte_mois="7,8,9",
        )
        courgette = PlantVariety(
            nom_commun="Courgette", variete="Verte",
            famille="Cucurbitacées", type_plante="legume",
            besoin_eau="moyen", espacement_cm=80,
            plantation_mois="5,6", recolte_mois="7,8",
        )
        salade = PlantVariety(
            nom_commun="Laitue", variete="Batavia",
            famille="Astéracées", type_plante="legume",
            besoin_eau="moyen", espacement_cm=25,
        )
        session.add_all([tomate, courgette, salade])
        session.flush()

        p1 = Planting(garden_id=jardin.id, variety_id=tomate.id,
                      date_plantation=date(2026, 5, 1), quantite=6, statut="en_cours")
        p2 = Planting(garden_id=jardin.id, variety_id=courgette.id,
                      date_plantation=date(2026, 5, 15), quantite=3, statut="prevu")
        session.add_all([p1, p2])
        session.flush()

        session.add(PlantingEvent(planting_id=p1.id, type="arrosage", note="Arrosage du matin"))

        session.add(Task(titre="Arroser les tomates", priorite="haute",
                         statut="a_faire", garden_id=jardin.id))
        session.add(Task(titre="Traiter contre les pucerons", priorite="normale", statut="a_faire"))
        session.add(Task(titre="Préparer le compost", priorite="basse", statut="en_cours"))

        session.commit()

Step 4: Lancer tous les tests une dernière fois

cd backend && pytest tests/ -v

Attendu : tous PASS.

Step 5: Commit

git add backend/
git commit -m "feat(backend): settings, upload media, seed données démo"

Task 7: Frontend — Scaffold Vue 3 + Vite + Tailwind

Files:

  • Create: frontend/package.json
  • Create: frontend/vite.config.ts
  • Create: frontend/tailwind.config.js
  • Create: frontend/postcss.config.js
  • Create: frontend/tsconfig.json
  • Create: frontend/index.html
  • Create: frontend/src/style.css
  • Create: frontend/src/main.ts
  • Create: frontend/src/App.vue

Step 1: Créer frontend/package.json

{
  "name": "jardin-frontend",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "vue-tsc --noEmit"
  },
  "dependencies": {
    "vue": "^3.5.13",
    "vue-router": "^4.5.0",
    "pinia": "^2.3.0",
    "axios": "^1.7.9"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1",
    "vite": "^6.0.7",
    "typescript": "^5.7.3",
    "vue-tsc": "^2.2.0",
    "tailwindcss": "^3.4.17",
    "postcss": "^8.5.1",
    "autoprefixer": "^10.4.20"
  }
}

Step 2: Créer frontend/vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  server: {
    proxy: {
      '/api': 'http://localhost:8000',
      '/uploads': 'http://localhost:8000',
    },
  },
})

Step 3: Créer frontend/tailwind.config.js

/** @type {import('tailwindcss').Config} */
export default {
  content: ['./index.html', './src/**/*.{vue,ts}'],
  theme: {
    extend: {
      colors: {
        bg:           '#282828',
        'bg-soft':    '#3c3836',
        'bg-hard':    '#1d2021',
        text:         '#ebdbb2',
        'text-muted': '#a89984',
        green:        '#b8bb26',
        yellow:       '#fabd2f',
        blue:         '#83a598',
        orange:       '#fe8019',
        red:          '#fb4934',
        purple:       '#d3869b',
        aqua:         '#8ec07c',
      },
      fontFamily: {
        mono: ['"Fira Code"', '"Courier New"', 'monospace'],
      },
    },
  },
}

Step 4: Créer frontend/postcss.config.js

export default {
  plugins: { tailwindcss: {}, autoprefixer: {} },
}

Step 5: Créer frontend/tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "jsx": "preserve",
    "lib": ["ES2020", "DOM"],
    "skipLibCheck": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src/**/*.ts", "src/**/*.vue"]
}

Step 6: Créer frontend/index.html

<!DOCTYPE html>
<html lang="fr">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>🌿 Jardin</title>
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&display=swap" rel="stylesheet" />
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

Step 7: Créer frontend/src/style.css

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

body {
  @apply bg-bg text-text font-mono;
  min-height: 100vh;
}

* { box-sizing: border-box; }

::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: #1d2021; }
::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; }

Step 8: Créer frontend/src/main.ts

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import './style.css'

createApp(App).use(createPinia()).use(router).mount('#app')

Step 9: Créer frontend/src/App.vue (stub — sera remplacé en Task 9)

<template>
  <RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>

Step 10: Vérifier l'install

cd frontend && npm install && npm run build

Attendu : build sans erreur, dossier dist/ créé.

Step 11: Commit

git add frontend/
git commit -m "feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox"

Task 8: Frontend — API layer + Stores Pinia

Files:

  • Create: frontend/src/api/client.ts
  • Create: frontend/src/api/gardens.ts
  • Create: frontend/src/api/varieties.ts
  • Create: frontend/src/api/plantings.ts
  • Create: frontend/src/api/tasks.ts
  • Create: frontend/src/stores/gardens.ts
  • Create: frontend/src/stores/varieties.ts
  • Create: frontend/src/stores/plantings.ts
  • Create: frontend/src/stores/tasks.ts

Step 1: Créer frontend/src/api/client.ts

import axios from 'axios'

export default axios.create({
  baseURL: import.meta.env.VITE_API_URL ?? '',
})

Step 2: Créer frontend/src/api/gardens.ts

import client from './client'

export interface Garden {
  id?: number
  nom: string
  description?: string
  type: string
  latitude?: number
  longitude?: number
  adresse?: string
  exposition?: string
  ombre?: string
  sol_type?: string
  grille_largeur: number
  grille_hauteur: number
}

export interface GardenCell {
  id?: number
  garden_id?: number
  col: number
  row: number
  libelle?: string
  etat: string
  notes?: string
}

export interface Measurement {
  id?: number
  garden_id?: number
  temp_air?: number
  temp_sol?: number
  humidite_air?: number
  humidite_sol?: number
  source?: string
  ts?: string
}

export const gardensApi = {
  list: () => client.get<Garden[]>('/api/gardens').then(r => r.data),
  get: (id: number) => client.get<Garden>(`/api/gardens/${id}`).then(r => r.data),
  create: (g: Partial<Garden>) => client.post<Garden>('/api/gardens', g).then(r => r.data),
  update: (id: number, g: Partial<Garden>) => client.put<Garden>(`/api/gardens/${id}`, g).then(r => r.data),
  delete: (id: number) => client.delete(`/api/gardens/${id}`),
  cells: (id: number) => client.get<GardenCell[]>(`/api/gardens/${id}/cells`).then(r => r.data),
  measurements: (id: number) => client.get<Measurement[]>(`/api/gardens/${id}/measurements`).then(r => r.data),
  addMeasurement: (id: number, m: Partial<Measurement>) =>
    client.post<Measurement>(`/api/gardens/${id}/measurements`, m).then(r => r.data),
}

Step 3: Créer frontend/src/api/varieties.ts

import client from './client'

export interface PlantVariety {
  id?: number
  nom_commun: string
  nom_botanique?: string
  variete?: string
  famille?: string
  type_plante?: string
  besoin_eau?: string
  espacement_cm?: number
  plantation_mois?: string
  recolte_mois?: string
  notes?: string
}

export const varietiesApi = {
  list: () => client.get<PlantVariety[]>('/api/varieties').then(r => r.data),
  get: (id: number) => client.get<PlantVariety>(`/api/varieties/${id}`).then(r => r.data),
  create: (v: Partial<PlantVariety>) => client.post<PlantVariety>('/api/varieties', v).then(r => r.data),
  update: (id: number, v: Partial<PlantVariety>) => client.put<PlantVariety>(`/api/varieties/${id}`, v).then(r => r.data),
  delete: (id: number) => client.delete(`/api/varieties/${id}`),
}

Step 4: Créer frontend/src/api/plantings.ts

import client from './client'

export interface Planting {
  id?: number
  garden_id: number
  variety_id: number
  cell_id?: number
  date_plantation?: string
  quantite: number
  statut: string
  notes?: string
}

export interface PlantingEvent {
  id?: number
  planting_id?: number
  type: string
  note?: string
  ts?: string
}

export const plantingsApi = {
  list: () => client.get<Planting[]>('/api/plantings').then(r => r.data),
  get: (id: number) => client.get<Planting>(`/api/plantings/${id}`).then(r => r.data),
  create: (p: Partial<Planting>) => client.post<Planting>('/api/plantings', p).then(r => r.data),
  update: (id: number, p: Partial<Planting>) => client.put<Planting>(`/api/plantings/${id}`, p).then(r => r.data),
  delete: (id: number) => client.delete(`/api/plantings/${id}`),
  events: (id: number) => client.get<PlantingEvent[]>(`/api/plantings/${id}/events`).then(r => r.data),
  addEvent: (id: number, e: Partial<PlantingEvent>) =>
    client.post<PlantingEvent>(`/api/plantings/${id}/events`, e).then(r => r.data),
}

Step 5: Créer frontend/src/api/tasks.ts

import client from './client'

export interface Task {
  id?: number
  titre: string
  description?: string
  garden_id?: number
  priorite: string
  echeance?: string
  statut: string
}

export const tasksApi = {
  list: (params?: { statut?: string; garden_id?: number }) =>
    client.get<Task[]>('/api/tasks', { params }).then(r => r.data),
  get: (id: number) => client.get<Task>(`/api/tasks/${id}`).then(r => r.data),
  create: (t: Partial<Task>) => client.post<Task>('/api/tasks', t).then(r => r.data),
  update: (id: number, t: Partial<Task>) => client.put<Task>(`/api/tasks/${id}`, t).then(r => r.data),
  delete: (id: number) => client.delete(`/api/tasks/${id}`),
}

Step 6: Créer frontend/src/stores/gardens.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { gardensApi, type Garden } from '@/api/gardens'

export const useGardensStore = defineStore('gardens', () => {
  const gardens = ref<Garden[]>([])
  const loading = ref(false)

  async function fetchAll() {
    loading.value = true
    gardens.value = await gardensApi.list()
    loading.value = false
  }

  async function create(g: Partial<Garden>) {
    const created = await gardensApi.create(g)
    gardens.value.push(created)
    return created
  }

  async function remove(id: number) {
    await gardensApi.delete(id)
    gardens.value = gardens.value.filter(g => g.id !== id)
  }

  return { gardens, loading, fetchAll, create, remove }
})

Step 7: Créer frontend/src/stores/varieties.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { varietiesApi, type PlantVariety } from '@/api/varieties'

export const useVarietiesStore = defineStore('varieties', () => {
  const varieties = ref<PlantVariety[]>([])
  const loading = ref(false)

  async function fetchAll() {
    loading.value = true
    varieties.value = await varietiesApi.list()
    loading.value = false
  }

  async function create(v: Partial<PlantVariety>) {
    const created = await varietiesApi.create(v)
    varieties.value.push(created)
    return created
  }

  async function remove(id: number) {
    await varietiesApi.delete(id)
    varieties.value = varieties.value.filter(v => v.id !== id)
  }

  return { varieties, loading, fetchAll, create, remove }
})

Step 8: Créer frontend/src/stores/plantings.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { plantingsApi, type Planting } from '@/api/plantings'

export const usePlantingsStore = defineStore('plantings', () => {
  const plantings = ref<Planting[]>([])
  const loading = ref(false)

  async function fetchAll() {
    loading.value = true
    plantings.value = await plantingsApi.list()
    loading.value = false
  }

  async function create(p: Partial<Planting>) {
    const created = await plantingsApi.create(p)
    plantings.value.push(created)
    return created
  }

  async function remove(id: number) {
    await plantingsApi.delete(id)
    plantings.value = plantings.value.filter(p => p.id !== id)
  }

  return { plantings, loading, fetchAll, create, remove }
})

Step 9: Créer frontend/src/stores/tasks.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { tasksApi, type Task } from '@/api/tasks'

export const useTasksStore = defineStore('tasks', () => {
  const tasks = ref<Task[]>([])
  const loading = ref(false)

  async function fetchAll(params?: { statut?: string; garden_id?: number }) {
    loading.value = true
    tasks.value = await tasksApi.list(params)
    loading.value = false
  }

  async function create(t: Partial<Task>) {
    const created = await tasksApi.create(t)
    tasks.value.push(created)
    return created
  }

  async function updateStatut(id: number, statut: string) {
    const t = tasks.value.find(t => t.id === id)
    if (!t) return
    const updated = await tasksApi.update(id, { ...t, statut })
    Object.assign(t, updated)
  }

  async function remove(id: number) {
    await tasksApi.delete(id)
    tasks.value = tasks.value.filter(t => t.id !== id)
  }

  return { tasks, loading, fetchAll, create, updateStatut, remove }
})

Step 10: Commit

git add frontend/src/api/ frontend/src/stores/
git commit -m "feat(frontend): API layer + stores Pinia"

Task 9: Frontend — Layout + Router

Files:

  • Create: frontend/src/router/index.ts
  • Create: frontend/src/components/AppHeader.vue
  • Create: frontend/src/components/AppDrawer.vue
  • Modify: frontend/src/App.vue

Step 1: Créer frontend/src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router'

export default createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/',            component: () => import('@/views/DashboardView.vue') },
    { path: '/jardins',     component: () => import('@/views/JardinsView.vue') },
    { path: '/jardins/:id', component: () => import('@/views/JardinDetailView.vue') },
    { path: '/varietes',    component: () => import('@/views/VarietesView.vue') },
    { path: '/plantations', component: () => import('@/views/PlantationsView.vue') },
    { path: '/planning',    component: () => import('@/views/PlanningView.vue') },
    { path: '/taches',      component: () => import('@/views/TachesView.vue') },
    { path: '/lunaire',     component: () => import('@/views/LunaireView.vue') },
    { path: '/reglages',    component: () => import('@/views/ReglagesView.vue') },
  ],
})

Step 2: Créer frontend/src/components/AppHeader.vue

<template>
  <header class="fixed top-0 left-0 right-0 z-50 bg-bg-hard border-b border-bg-soft h-14 flex items-center px-4 gap-4">
    <button class="md:hidden text-text-muted hover:text-text text-xl" @click="$emit('toggle-drawer')"></button>
    <RouterLink to="/" class="text-green font-bold text-lg tracking-wide">🌿 Jardin</RouterLink>
    <nav class="hidden md:flex gap-5 ml-4">
      <RouterLink
        v-for="l in links" :key="l.to" :to="l.to"
        class="text-text-muted hover:text-text transition-colors text-sm"
        active-class="text-green font-semibold"
      >{{ l.label }}</RouterLink>
    </nav>
  </header>
</template>

<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineEmits(['toggle-drawer'])
const links = [
  { to: '/', label: 'Dashboard' },
  { to: '/jardins', label: 'Jardins' },
  { to: '/varietes', label: 'Variétés' },
  { to: '/plantations', label: 'Plantations' },
  { to: '/taches', label: 'Tâches' },
  { to: '/planning', label: 'Planning' },
  { to: '/lunaire', label: 'Lunaire' },
  { to: '/reglages', label: 'Réglages' },
]
</script>

Step 3: Créer frontend/src/components/AppDrawer.vue

<template>
  <Transition name="slide">
    <div v-if="open" class="fixed inset-0 z-40 flex md:hidden" @click.self="$emit('close')">
      <nav class="bg-bg-hard w-64 h-full p-6 flex flex-col gap-1 border-r border-bg-soft shadow-2xl">
        <span class="text-green font-bold text-xl mb-6">🌿 Jardin</span>
        <RouterLink
          v-for="l in links" :key="l.to" :to="l.to"
          class="text-text-muted hover:text-text py-2 px-3 rounded-lg text-sm transition-colors"
          active-class="bg-bg-soft text-green"
          @click="$emit('close')"
        >{{ l.label }}</RouterLink>
      </nav>
    </div>
  </Transition>
</template>

<script setup lang="ts">
import { RouterLink } from 'vue-router'
defineProps<{ open: boolean }>()
defineEmits(['close'])
const links = [
  { to: '/', label: 'Dashboard' },
  { to: '/jardins', label: 'Jardins' },
  { to: '/varietes', label: 'Variétés' },
  { to: '/plantations', label: 'Plantations' },
  { to: '/taches', label: 'Tâches' },
  { to: '/planning', label: 'Planning' },
  { to: '/lunaire', label: 'Calendrier lunaire' },
  { to: '/reglages', label: 'Réglages' },
]
</script>

<style scoped>
.slide-enter-active, .slide-leave-active { transition: opacity 0.2s; }
.slide-enter-from, .slide-leave-to { opacity: 0; }
</style>

Step 4: Remplacer frontend/src/App.vue

<template>
  <AppHeader @toggle-drawer="drawerOpen = !drawerOpen" />
  <AppDrawer :open="drawerOpen" @close="drawerOpen = false" />
  <main class="pt-14 min-h-screen">
    <RouterView />
  </main>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { RouterView } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppDrawer from '@/components/AppDrawer.vue'

const drawerOpen = ref(false)
</script>

Step 5: Commit

git add frontend/src/
git commit -m "feat(frontend): layout header + drawer + router"

Task 10: Frontend — Vues MVP

Files:

  • Create: frontend/src/views/DashboardView.vue
  • Create: frontend/src/views/JardinsView.vue
  • Create: frontend/src/views/JardinDetailView.vue
  • Create: frontend/src/views/VarietesView.vue
  • Create: frontend/src/views/PlantationsView.vue
  • Create: frontend/src/views/TachesView.vue
  • Create: frontend/src/views/PlanningView.vue
  • Create: frontend/src/views/LunaireView.vue
  • Create: frontend/src/views/ReglagesView.vue

Step 1: Créer frontend/src/views/DashboardView.vue

<template>
  <div class="p-4 max-w-2xl mx-auto">
    <h1 class="text-2xl font-bold text-green mb-6">Tableau de bord</h1>

    <section class="mb-6">
      <h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Tâches à faire</h2>
      <div v-if="!tasksStore.tasks.filter(t => t.statut === 'a_faire').length" class="text-text-muted text-sm">
        Aucune tâche en attente.
      </div>
      <div
        v-for="t in tasksStore.tasks.filter(t => t.statut === 'a_faire').slice(0, 5)"
        :key="t.id"
        class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard"
      >
        <span :class="{ 'text-red': t.priorite === 'haute', 'text-yellow': t.priorite === 'normale', 'text-text-muted': t.priorite === 'basse' }"></span>
        <span class="text-text text-sm flex-1">{{ t.titre }}</span>
        <button class="text-xs text-green hover:underline" @click="tasksStore.updateStatut(t.id!, 'fait')"> Fait</button>
      </div>
    </section>

    <section>
      <h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">Jardins</h2>
      <div
        v-for="g in gardensStore.gardens"
        :key="g.id"
        class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard cursor-pointer hover:border-green transition-colors"
        @click="router.push(`/jardins/${g.id}`)"
      >
        <span class="text-text font-medium">{{ g.nom }}</span>
        <span class="ml-2 text-xs text-text-muted px-2 py-0.5 bg-bg rounded">{{ g.type }}</span>
      </div>
    </section>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'
import { useTasksStore } from '@/stores/tasks'

const router = useRouter()
const gardensStore = useGardensStore()
const tasksStore = useTasksStore()

onMounted(() => { gardensStore.fetchAll(); tasksStore.fetchAll() })
</script>

Step 2: Créer frontend/src/views/JardinsView.vue

<template>
  <div class="p-4 max-w-2xl mx-auto">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold text-green">Jardins</h1>
      <button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90 transition-opacity"
        @click="showForm = !showForm">+ Nouveau</button>
    </div>

    <form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
      <div class="grid gap-3">
        <div>
          <label class="text-text-muted text-xs block mb-1">Nom *</label>
          <input v-model="form.nom" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
        </div>
        <div>
          <label class="text-text-muted text-xs block mb-1">Type</label>
          <select v-model="form.type" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
            <option value="plein_air">Plein air</option>
            <option value="serre">Serre</option>
            <option value="tunnel">Tunnel</option>
          </select>
        </div>
        <div class="grid grid-cols-2 gap-3">
          <div>
            <label class="text-text-muted text-xs block mb-1">Largeur grille</label>
            <input v-model.number="form.grille_largeur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Hauteur grille</label>
            <input v-model.number="form.grille_hauteur" type="number" min="1" max="20" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm" />
          </div>
        </div>
      </div>
      <div class="flex gap-2 mt-4">
        <button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
        <button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
      </div>
    </form>

    <div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
    <div v-for="g in store.gardens" :key="g.id"
      class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard flex items-center gap-3 cursor-pointer hover:border-green transition-colors group"
      @click="router.push(`/jardins/${g.id}`)">
      <div class="flex-1">
        <div class="text-text font-medium group-hover:text-green transition-colors">{{ g.nom }}</div>
        <div class="text-text-muted text-xs mt-1">{{ g.type }} · {{ g.grille_largeur }}×{{ g.grille_hauteur }} cases</div>
      </div>
      <button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
        @click.stop="store.remove(g.id!)"></button>
    </div>

    <div v-if="!store.loading && !store.gardens.length" class="text-text-muted text-sm text-center py-8">
      Aucun jardin. Créez-en un !
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useGardensStore } from '@/stores/gardens'

const router = useRouter()
const store = useGardensStore()
const showForm = ref(false)
const form = reactive({ nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })

onMounted(() => store.fetchAll())

async function submit() {
  await store.create({ ...form })
  showForm.value = false
  Object.assign(form, { nom: '', type: 'plein_air', grille_largeur: 6, grille_hauteur: 4 })
}
</script>

Step 3: Créer frontend/src/views/JardinDetailView.vue

<template>
  <div class="p-4 max-w-3xl mx-auto">
    <button class="text-text-muted text-sm mb-4 hover:text-text flex items-center gap-1" @click="router.back()"> Retour</button>

    <div v-if="garden">
      <h1 class="text-2xl font-bold text-green mb-1">{{ garden.nom }}</h1>
      <p class="text-text-muted text-sm mb-6">{{ garden.type }} · {{ garden.exposition ?? 'exposition non définie' }}</p>

      <h2 class="text-text-muted text-xs uppercase tracking-widest mb-3">
        Grille {{ garden.grille_largeur }}×{{ garden.grille_hauteur }}
      </h2>
      <div class="overflow-x-auto pb-2">
        <div class="grid gap-1 w-max"
          :style="`grid-template-columns: repeat(${garden.grille_largeur}, 52px)`">
          <div
            v-for="cell in displayCells" :key="`${cell.row}-${cell.col}`"
            class="w-[52px] h-[52px] bg-bg-soft border border-bg-hard rounded-md flex items-center justify-center text-xs text-text-muted cursor-pointer hover:border-green transition-colors select-none"
            :class="{ 'border-orange/60 bg-orange/10 text-orange': cell.etat === 'occupe' }"
            :title="cell.libelle"
          >
            {{ cell.libelle }}
          </div>
        </div>
      </div>
    </div>
    <div v-else class="text-text-muted text-sm">Chargement...</div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { gardensApi, type Garden, type GardenCell } from '@/api/gardens'

const route = useRoute()
const router = useRouter()
const garden = ref<Garden | null>(null)
const cells = ref<GardenCell[]>([])

const displayCells = computed(() => {
  if (!garden.value) return []
  const map = new Map(cells.value.map(c => [`${c.row}-${c.col}`, c]))
  const result: GardenCell[] = []
  for (let row = 0; row < garden.value.grille_hauteur; row++) {
    for (let col = 0; col < garden.value.grille_largeur; col++) {
      result.push(map.get(`${row}-${col}`) ?? {
        col, row,
        libelle: `${String.fromCharCode(65 + row)}${col + 1}`,
        etat: 'libre',
      })
    }
  }
  return result
})

onMounted(async () => {
  const id = Number(route.params.id)
  garden.value = await gardensApi.get(id)
  cells.value = await gardensApi.cells(id)
})
</script>

Step 4: Créer frontend/src/views/VarietesView.vue

<template>
  <div class="p-4 max-w-2xl mx-auto">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold text-green">Variétés</h1>
      <button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
        @click="showForm = !showForm">+ Nouvelle</button>
    </div>

    <form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
      <div class="grid gap-3">
        <div>
          <label class="text-text-muted text-xs block mb-1">Nom commun *</label>
          <input v-model="form.nom_commun" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
        </div>
        <div class="grid grid-cols-2 gap-3">
          <div>
            <label class="text-text-muted text-xs block mb-1">Variété</label>
            <input v-model="form.variete" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Famille</label>
            <input v-model="form.famille" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
          </div>
        </div>
        <div>
          <label class="text-text-muted text-xs block mb-1">Besoin en eau</label>
          <select v-model="form.besoin_eau" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
            <option value=""></option>
            <option value="faible">Faible</option>
            <option value="moyen">Moyen</option>
            <option value="fort">Fort</option>
          </select>
        </div>
      </div>
      <div class="flex gap-2 mt-4">
        <button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
        <button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
      </div>
    </form>

    <div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
    <div v-for="v in store.varieties" :key="v.id"
      class="bg-bg-soft rounded-lg p-4 mb-2 border border-bg-hard flex items-start gap-3">
      <div class="flex-1">
        <div class="text-text font-medium">{{ v.nom_commun }} <span v-if="v.variete" class="text-text-muted text-xs"> {{ v.variete }}</span></div>
        <div class="text-text-muted text-xs mt-1">{{ v.famille }}</div>
        <div class="flex gap-2 mt-2">
          <span v-if="v.besoin_eau" class="text-xs px-2 py-0.5 bg-bg rounded text-blue">💧 {{ v.besoin_eau }}</span>
          <span v-if="v.espacement_cm" class="text-xs px-2 py-0.5 bg-bg rounded text-text-muted"> {{ v.espacement_cm }}cm</span>
        </div>
      </div>
      <button class="text-text-muted hover:text-red text-sm px-2 py-1 rounded hover:bg-bg transition-colors"
        @click="store.remove(v.id!)"></button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useVarietiesStore } from '@/stores/varieties'

const store = useVarietiesStore()
const showForm = ref(false)
const form = reactive({ nom_commun: '', variete: '', famille: '', besoin_eau: '' })

onMounted(() => store.fetchAll())

async function submit() {
  await store.create({ ...form })
  showForm.value = false
  Object.assign(form, { nom_commun: '', variete: '', famille: '', besoin_eau: '' })
}
</script>

Step 5: Créer frontend/src/views/TachesView.vue

<template>
  <div class="p-4 max-w-2xl mx-auto">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold text-green">Tâches</h1>
      <button class="bg-green text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90"
        @click="showForm = !showForm">+ Nouvelle</button>
    </div>

    <form v-if="showForm" class="bg-bg-soft rounded-lg p-4 mb-6 border border-green/30" @submit.prevent="submit">
      <div class="grid gap-3">
        <div>
          <label class="text-text-muted text-xs block mb-1">Titre *</label>
          <input v-model="form.titre" required class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
        </div>
        <div class="grid grid-cols-2 gap-3">
          <div>
            <label class="text-text-muted text-xs block mb-1">Priorité</label>
            <select v-model="form.priorite" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
              <option value="basse">Basse</option>
              <option value="normale">Normale</option>
              <option value="haute">Haute</option>
            </select>
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Échéance</label>
            <input v-model="form.echeance" type="date" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm focus:border-green outline-none" />
          </div>
        </div>
      </div>
      <div class="flex gap-2 mt-4">
        <button type="submit" class="bg-green text-bg px-4 py-2 rounded text-sm font-semibold">Créer</button>
        <button type="button" class="text-text-muted text-sm px-4 py-2 hover:text-text" @click="showForm = false">Annuler</button>
      </div>
    </form>

    <div v-for="[groupe, label] in groupes" :key="groupe" class="mb-6">
      <h2 class="text-text-muted text-xs uppercase tracking-widest mb-2">{{ label }}</h2>
      <div v-if="!byStatut(groupe).length" class="text-text-muted text-xs pl-2"></div>
      <div v-for="t in byStatut(groupe)" :key="t.id"
        class="bg-bg-soft rounded-lg p-3 mb-2 flex items-center gap-3 border border-bg-hard">
        <span :class="{
          'text-red': t.priorite === 'haute',
          'text-yellow': t.priorite === 'normale',
          'text-text-muted': t.priorite === 'basse'
        }"></span>
        <span class="text-text text-sm flex-1">{{ t.titre }}</span>
        <div class="flex gap-1">
          <button v-if="t.statut === 'a_faire'" class="text-xs text-blue hover:underline"
            @click="store.updateStatut(t.id!, 'en_cours')"> En cours</button>
          <button v-if="t.statut === 'en_cours'" class="text-xs text-green hover:underline"
            @click="store.updateStatut(t.id!, 'fait')"> Fait</button>
          <button class="text-xs text-text-muted hover:text-red ml-2" @click="store.remove(t.id!)"></button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useTasksStore } from '@/stores/tasks'

const store = useTasksStore()
const showForm = ref(false)
const form = reactive({ titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })

const groupes: [string, string][] = [
  ['a_faire', 'À faire'],
  ['en_cours', 'En cours'],
  ['fait', 'Terminé'],
]

const byStatut = (s: string) => store.tasks.filter(t => t.statut === s)

onMounted(() => store.fetchAll())

async function submit() {
  await store.create({ ...form })
  showForm.value = false
  Object.assign(form, { titre: '', priorite: 'normale', statut: 'a_faire', echeance: '' })
}
</script>

Step 6: Créer frontend/src/views/PlantationsView.vue

<template>
  <div class="p-4 max-w-2xl mx-auto">
    <h1 class="text-2xl font-bold text-green mb-6">Plantations</h1>
    <div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
    <div v-for="p in store.plantings" :key="p.id"
      class="bg-bg-soft rounded-lg p-4 mb-3 border border-bg-hard">
      <div class="flex items-start gap-3">
        <div class="flex-1">
          <div class="text-text font-medium">Plantation #{{ p.id }}</div>
          <div class="text-text-muted text-xs mt-1">
            Jardin {{ p.garden_id }} · Variété {{ p.variety_id }} · {{ p.quantite }} plant(s)
          </div>
          <span class="inline-block mt-2 text-xs px-2 py-0.5 rounded"
            :class="{
              'bg-blue/20 text-blue': p.statut === 'prevu',
              'bg-green/20 text-green': p.statut === 'en_cours',
              'bg-text-muted/20 text-text-muted': p.statut === 'termine',
              'bg-red/20 text-red': p.statut === 'echoue',
            }">{{ p.statut }}</span>
        </div>
        <button class="text-text-muted hover:text-red text-sm" @click="store.remove(p.id!)"></button>
      </div>
    </div>
    <div v-if="!store.loading && !store.plantings.length" class="text-text-muted text-sm text-center py-8">
      Aucune plantation enregistrée.
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted } from 'vue'
import { usePlantingsStore } from '@/stores/plantings'

const store = usePlantingsStore()
onMounted(() => store.fetchAll())
</script>

Step 7: Créer les vues squelettes (PlanningView, LunaireView, ReglagesView)

Pour chacune, même template minimal :

<!-- PlanningView.vue -->
<template>
  <div class="p-4 max-w-2xl mx-auto">
    <h1 class="text-2xl font-bold text-green mb-4">Planning</h1>
    <p class="text-text-muted text-sm">Vue calendrier  à venir dans la prochaine étape.</p>
  </div>
</template>

(Même structure pour LunaireView avec titre "Calendrier lunaire" et ReglagesView avec titre "Réglages")

Step 8: Vérifier que tout tourne

cd frontend && npm run build

Attendu : build sans erreur TypeScript.

Step 9: Commit

git add frontend/src/views/
git commit -m "feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations"

Task 11: README + vérification finale

Files:

  • Create: README.md

Step 1: Créer README.md

# 🌿 Jardin — Application de gestion de jardins

Interface web **mobile-first** pour gérer vos jardins, cultures, tâches et calendrier lunaire.
Thème : Gruvbox Dark Seventies.

## Prérequis

- Docker + Docker Compose

## Lancement

```bash
cp .env.example .env
docker compose up --build

Développement local

Backend :

cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
DATABASE_URL=sqlite:///./data/jardin.db UPLOAD_DIR=./data/uploads uvicorn app.main:app --reload

Frontend :

cd frontend
npm install
npm run dev   # http://localhost:5173

Tests

cd backend && pytest tests/ -v

Sauvegarde

La base SQLite est dans data/jardin.db. Copiez ce fichier pour sauvegarder.


**Step 2: Lancer tous les tests backend**

```bash
cd backend && pytest tests/ -v

Attendu : tous PASS, aucune erreur.

Step 3: Vérifier le build frontend

cd frontend && npm run build

Attendu : build sans erreur.

Step 4: Commit final

git add README.md
git commit -m "docs: README installation + guide développement"

Résumé des commits attendus

  1. chore: scaffold projet + docker-compose
  2. feat(backend): setup FastAPI + SQLite + config
  3. feat(backend): modèles SQLModel (10 tables)
  4. feat(backend): CRUD jardins + tests
  5. feat(backend): CRUD variétés, plantations, tâches + tests
  6. feat(backend): settings, upload media, seed données démo
  7. feat(frontend): scaffold Vue 3 + Vite + Tailwind Gruvbox
  8. feat(frontend): API layer + stores Pinia
  9. feat(frontend): layout header + drawer + router
  10. feat(frontend): vues MVP — dashboard, jardins, grille, variétés, tâches, plantations
  11. docs: README installation + guide développement