From c1fe3e2636bbe21e1b13a1bc31e2e8a7e2b44f4d Mon Sep 17 00:00:00 2001 From: gilles Date: Sat, 21 Feb 2026 21:07:19 +0100 Subject: [PATCH] =?UTF-8?q?docs:=20plan=20d'impl=C3=A9mentation=20webapp?= =?UTF-8?q?=20jardin=20(11=20t=C3=A2ches)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan complet : Docker, FastAPI + SQLModel + tests TDD, Vue 3 + Tailwind Gruvbox. Co-Authored-By: Claude Sonnet 4.6 --- ...2026-02-21-jardin-webapp-implementation.md | 2384 +++++++++++++++++ 1 file changed, 2384 insertions(+) create mode 100644 docs/plans/2026-02-21-jardin-webapp-implementation.md diff --git a/docs/plans/2026-02-21-jardin-webapp-implementation.md b/docs/plans/2026-02-21-jardin-webapp-implementation.md new file mode 100644 index 0000000..6f8cf1d --- /dev/null +++ b/docs/plans/2026-02-21-jardin-webapp-implementation.md @@ -0,0 +1,2384 @@ +# 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** + +```bash +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`** + +```env +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`** + +```yaml +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`** + +```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`** + +```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`** + +```nginx +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** + +```bash +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`** + +```python +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`** + +```python +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) + +```python +def run_seed(): + pass +``` + +**Step 5: Créer `backend/app/main.py`** + +```python +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** + +```bash +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`** + +```python +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`** + +```python +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`** + +```python +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`** + +```python +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`** + +```python +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`** + +```python +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** + +```bash +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`** + +```python +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`** + +```python +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** + +```bash +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`** + +```python +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 : + +```python +from fastapi import APIRouter +router = APIRouter() +``` + +**Step 6: Enregistrer les routers dans `backend/app/main.py`** — ajouter après le middleware : + +```python +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 : + +```python +from fastapi.staticfiles import StaticFiles +app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") +``` + +**Step 7: Lancer les tests — vérifier qu'ils passent** + +```bash +cd backend && pytest tests/test_gardens.py -v +``` + +Attendu : tous PASS. + +**Step 8: Commit** + +```bash +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`** + +```python +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`** + +```python +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`** + +```python +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** + +```bash +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`** + +```python +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`** + +```python +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`** + +```python +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** + +```bash +cd backend && pytest tests/ -v +``` + +Attendu : tous PASS. + +**Step 9: Commit** + +```bash +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`** + +```python +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`** + +```python +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`** + +```python +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** + +```bash +cd backend && pytest tests/ -v +``` + +Attendu : tous PASS. + +**Step 5: Commit** + +```bash +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`** + +```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`** + +```typescript +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`** + +```javascript +/** @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`** + +```javascript +export default { + plugins: { tailwindcss: {}, autoprefixer: {} }, +} +``` + +**Step 5: Créer `frontend/tsconfig.json`** + +```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`** + +```html + + + + + + 🌿 Jardin + + + + +
+ + + +``` + +**Step 7: Créer `frontend/src/style.css`** + +```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`** + +```typescript +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) + +```vue + + +``` + +**Step 10: Vérifier l'install** + +```bash +cd frontend && npm install && npm run build +``` + +Attendu : build sans erreur, dossier `dist/` créé. + +**Step 11: Commit** + +```bash +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`** + +```typescript +import axios from 'axios' + +export default axios.create({ + baseURL: import.meta.env.VITE_API_URL ?? '', +}) +``` + +**Step 2: Créer `frontend/src/api/gardens.ts`** + +```typescript +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('/api/gardens').then(r => r.data), + get: (id: number) => client.get(`/api/gardens/${id}`).then(r => r.data), + create: (g: Partial) => client.post('/api/gardens', g).then(r => r.data), + update: (id: number, g: Partial) => client.put(`/api/gardens/${id}`, g).then(r => r.data), + delete: (id: number) => client.delete(`/api/gardens/${id}`), + cells: (id: number) => client.get(`/api/gardens/${id}/cells`).then(r => r.data), + measurements: (id: number) => client.get(`/api/gardens/${id}/measurements`).then(r => r.data), + addMeasurement: (id: number, m: Partial) => + client.post(`/api/gardens/${id}/measurements`, m).then(r => r.data), +} +``` + +**Step 3: Créer `frontend/src/api/varieties.ts`** + +```typescript +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('/api/varieties').then(r => r.data), + get: (id: number) => client.get(`/api/varieties/${id}`).then(r => r.data), + create: (v: Partial) => client.post('/api/varieties', v).then(r => r.data), + update: (id: number, v: Partial) => client.put(`/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`** + +```typescript +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('/api/plantings').then(r => r.data), + get: (id: number) => client.get(`/api/plantings/${id}`).then(r => r.data), + create: (p: Partial) => client.post('/api/plantings', p).then(r => r.data), + update: (id: number, p: Partial) => client.put(`/api/plantings/${id}`, p).then(r => r.data), + delete: (id: number) => client.delete(`/api/plantings/${id}`), + events: (id: number) => client.get(`/api/plantings/${id}/events`).then(r => r.data), + addEvent: (id: number, e: Partial) => + client.post(`/api/plantings/${id}/events`, e).then(r => r.data), +} +``` + +**Step 5: Créer `frontend/src/api/tasks.ts`** + +```typescript +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('/api/tasks', { params }).then(r => r.data), + get: (id: number) => client.get(`/api/tasks/${id}`).then(r => r.data), + create: (t: Partial) => client.post('/api/tasks', t).then(r => r.data), + update: (id: number, t: Partial) => client.put(`/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`** + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { gardensApi, type Garden } from '@/api/gardens' + +export const useGardensStore = defineStore('gardens', () => { + const gardens = ref([]) + const loading = ref(false) + + async function fetchAll() { + loading.value = true + gardens.value = await gardensApi.list() + loading.value = false + } + + async function create(g: Partial) { + 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`** + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { varietiesApi, type PlantVariety } from '@/api/varieties' + +export const useVarietiesStore = defineStore('varieties', () => { + const varieties = ref([]) + const loading = ref(false) + + async function fetchAll() { + loading.value = true + varieties.value = await varietiesApi.list() + loading.value = false + } + + async function create(v: Partial) { + 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`** + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { plantingsApi, type Planting } from '@/api/plantings' + +export const usePlantingsStore = defineStore('plantings', () => { + const plantings = ref([]) + const loading = ref(false) + + async function fetchAll() { + loading.value = true + plantings.value = await plantingsApi.list() + loading.value = false + } + + async function create(p: Partial) { + 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`** + +```typescript +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { tasksApi, type Task } from '@/api/tasks' + +export const useTasksStore = defineStore('tasks', () => { + const tasks = ref([]) + 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) { + 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** + +```bash +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`** + +```typescript +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`** + +```vue + + + +``` + +**Step 3: Créer `frontend/src/components/AppDrawer.vue`** + +```vue + + + + + +``` + +**Step 4: Remplacer `frontend/src/App.vue`** + +```vue + + + +``` + +**Step 5: Commit** + +```bash +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`** + +```vue + + + +``` + +**Step 2: Créer `frontend/src/views/JardinsView.vue`** + +```vue + + + +``` + +**Step 3: Créer `frontend/src/views/JardinDetailView.vue`** + +```vue + + + +``` + +**Step 4: Créer `frontend/src/views/VarietesView.vue`** + +```vue + + + +``` + +**Step 5: Créer `frontend/src/views/TachesView.vue`** + +```vue + + + +``` + +**Step 6: Créer `frontend/src/views/PlantationsView.vue`** + +```vue + + + +``` + +**Step 7: Créer les vues squelettes** (`PlanningView`, `LunaireView`, `ReglagesView`) + +Pour chacune, même template minimal : + +```vue + + +``` + +(Même structure pour `LunaireView` avec titre "Calendrier lunaire" et `ReglagesView` avec titre "Réglages") + +**Step 8: Vérifier que tout tourne** + +```bash +cd frontend && npm run build +``` + +Attendu : build sans erreur TypeScript. + +**Step 9: Commit** + +```bash +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`** + +```markdown +# 🌿 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 +``` + +- Application : http://localhost +- API (docs) : http://localhost:8000/docs + +## Développement local + +**Backend :** +```bash +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 :** +```bash +cd frontend +npm install +npm run dev # http://localhost:5173 +``` + +## Tests + +```bash +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** + +```bash +cd frontend && npm run build +``` + +Attendu : build sans erreur. + +**Step 4: Commit final** + +```bash +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`