Files
jardin/docs/plans/2026-02-22-meteo-astuces.md

53 KiB

Météo + Astuces — Plan d'implémentation

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

Goal: Intégrer la station météo WeeWX locale + Open-Meteo dans FastAPI via APScheduler, afficher un tableau synthétique dans le Calendrier, et ajouter une vue Astuces avec catégories/tags.

Architecture: APScheduler tourne dans le process FastAPI (lifespan). Deux tables SQLModel stockent les données : meteostation (WeeWX hourly) et meteoopenmeteo (prévisions journalières). Le tableau synthétique fusionne 7j passé (station) + J0 + 7j futur (open-meteo). Les astuces sont une bibliothèque tag-based indépendante.

Tech Stack: FastAPI, SQLModel, APScheduler 3.x, httpx, xml.etree.ElementTree (stdlib), Vue 3 + TypeScript + Pinia


Contexte du projet

  • Backend FastAPI : backend/ — port 8060
  • Frontend Vue 3 : frontend/ — port 8061
  • SQLite : /data/jardin.db (volume Docker)
  • Thème Gruvbox Dark — palette dans tailwind.config.js
  • Tests : cd backend && pytest tests/test_X.py -v
  • Station WeeWX : http://10.0.0.8:8081/ (configurable via env STATION_URL)
  • Lat/lon par défaut : 45.14 / 4.12 (configurable via METEO_LAT / METEO_LON)
  • Modèles existants : tous dans backend/app/models/ — importés dans models/__init__.py
  • Migration colonnes : backend/app/migrate.py — pattern EXPECTED_COLUMNS
  • Astuce modèle existant : a entity_type, entity_id, titre, contenu, source — on ajoute categorie, tags, mois

Task 1 : Dépendances + configuration

Files:

  • Modify: backend/requirements.txt
  • Modify: backend/app/config.py
  • Modify: .env.example

Step 1: Ajouter apscheduler à requirements.txt

# Ajouter après redis==5.2.1 :
apscheduler==3.10.4

Fichier final :

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
Pillow==11.1.0
skyfield==1.49
pytz==2025.1
numpy==2.2.3
redis==5.2.1
apscheduler==3.10.4

Step 2: Ajouter STATION_URL, METEO_LAT, METEO_LON dans 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(",")
STATION_URL = os.getenv("STATION_URL", "http://10.0.0.8:8081/")
METEO_LAT = float(os.getenv("METEO_LAT", "45.14"))
METEO_LON = float(os.getenv("METEO_LON", "4.12"))

Step 3: Ajouter dans .env.example

STATION_URL=http://10.0.0.8:8081/
METEO_LAT=45.14
METEO_LON=4.12

Step 4: Vérifier que l'import fonctionne

cd backend && python -c "from app.config import STATION_URL, METEO_LAT, METEO_LON; print(STATION_URL, METEO_LAT, METEO_LON)"

Expected: http://10.0.0.8:8081/ 45.14 4.12

Step 5: Commit

git add backend/requirements.txt backend/app/config.py .env.example
git commit -m "feat(config): ajout STATION_URL, METEO_LAT/LON + apscheduler dep"

Task 2 : Modèles SQLModel pour les tables météo

Files:

  • Create: backend/app/models/meteo.py
  • Modify: backend/app/models/__init__.py

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

from typing import Optional
from sqlmodel import Field, SQLModel


class MeteoStation(SQLModel, table=True):
    """Données collectées depuis la station WeeWX locale."""
    __tablename__ = "meteostation"

    date_heure: str = Field(primary_key=True)  # "2026-02-22T14:00"
    type: str = "current"  # "current" | "veille"
    temp_ext: Optional[float] = None   # °C extérieur
    temp_int: Optional[float] = None   # °C intérieur (serre)
    humidite: Optional[float] = None   # %
    pression: Optional[float] = None   # hPa
    pluie_mm: Optional[float] = None   # précipitations
    vent_kmh: Optional[float] = None
    vent_dir: Optional[str] = None     # N/NE/E/SE/S/SO/O/NO
    uv: Optional[float] = None
    solaire: Optional[float] = None    # W/m²


class MeteoOpenMeteo(SQLModel, table=True):
    """Prévisions journalières Open-Meteo."""
    __tablename__ = "meteoopenmeteo"

    date: str = Field(primary_key=True)  # "2026-02-22"
    t_min: Optional[float] = None
    t_max: Optional[float] = None
    pluie_mm: Optional[float] = None
    vent_kmh: Optional[float] = None
    wmo: Optional[int] = None
    label: Optional[str] = None
    humidite_moy: Optional[float] = None
    sol_0cm: Optional[float] = None    # temp sol surface
    etp_mm: Optional[float] = None     # évapotranspiration
    fetched_at: Optional[str] = None

Step 2: Ajouter les imports dans backend/app/models/init.py

Ajouter à la fin du fichier :

from app.models.meteo import MeteoStation, MeteoOpenMeteo  # noqa

Step 3: Vérifier que les tables sont créées

cd backend && python -c "
import app.models
from app.database import create_db_and_tables, engine
from sqlmodel import SQLModel
SQLModel.metadata.create_all(engine)
from sqlalchemy import text
with engine.connect() as c:
    print([r[0] for r in c.execute(text(\"SELECT name FROM sqlite_master WHERE type='table'\")).fetchall()])
"

Expected: liste incluant meteostation et meteoopenmeteo

Step 4: Commit

git add backend/app/models/meteo.py backend/app/models/__init__.py
git commit -m "feat(models): tables MeteoStation + MeteoOpenMeteo (SQLModel)"

Task 3 : Refonte du modèle Astuce (ajout categorie/tags/mois)

Files:

  • Modify: backend/app/models/astuce.py
  • Modify: backend/app/migrate.py

Step 1: Mettre à jour le modèle Astuce

Remplacer le contenu de backend/app/models/astuce.py :

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


class Astuce(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    # Anciens champs conservés (colonnes existantes en DB)
    entity_type: Optional[str] = None
    entity_id: Optional[int] = None
    source: Optional[str] = None
    # Champs principaux
    titre: str
    contenu: str
    # Nouveaux champs bibliothèque
    categorie: Optional[str] = None   # "plante"|"jardin"|"tache"|"general"|"ravageur"|"maladie"
    tags: Optional[str] = None        # JSON array string: '["tomate","semis"]'
    mois: Optional[str] = None        # JSON array string: '[3,4,5]' ou null = toute l'année
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

Step 2: Ajouter la migration des nouvelles colonnes dans migrate.py

Ajouter dans le dict EXPECTED_COLUMNS (après "media") :

    "astuce": [
        ("categorie", "TEXT", None),
        ("tags", "TEXT", None),
        ("mois", "TEXT", None),
    ],

Step 3: Vérifier la migration

cd backend && python -c "
from app.migrate import run_migrations
run_migrations()
print('Migration OK')
"

Expected: Migration OK (sans erreur)

Step 4: Commit

git add backend/app/models/astuce.py backend/app/migrate.py
git commit -m "feat(astuce): ajout colonnes categorie/tags/mois + migration"

Task 4 : Service station météo (scraper WeeWX)

Files:

  • Create: backend/app/services/station.py

Step 1: Créer backend/app/services/station.py

"""Service de collecte des données de la station météo locale WeeWX."""
import logging
import xml.etree.ElementTree as ET
from datetime import datetime, timezone

import httpx

from app.config import STATION_URL

logger = logging.getLogger(__name__)


def _safe_float(text: str | None) -> float | None:
    if text is None:
        return None
    try:
        cleaned = text.strip().replace(",", ".")
        # Retirer unités courantes
        for unit in [" °C", " %", " hPa", " km/h", " W/m²", "°C", "%", "hPa"]:
            cleaned = cleaned.replace(unit, "")
        return float(cleaned.strip())
    except (ValueError, AttributeError):
        return None


def _direction_to_abbr(deg: float | None) -> str | None:
    if deg is None:
        return None
    dirs = ["N", "NE", "E", "SE", "S", "SO", "O", "NO"]
    return dirs[round(deg / 45) % 8]


def fetch_current(base_url: str = STATION_URL) -> dict | None:
    """Scrape les données actuelles depuis le RSS de la station WeeWX.

    Retourne un dict avec les clés : temp_ext, humidite, pression,
    pluie_mm, vent_kmh, vent_dir, uv, solaire — ou None si indisponible.
    """
    try:
        url = base_url.rstrip("/") + "/rss.xml"
        r = httpx.get(url, timeout=10)
        r.raise_for_status()
        root = ET.fromstring(r.text)
        ns = {"w": "http://www.w3.org/2005/Atom"}

        # WeeWX RSS : le premier <item> contient les conditions actuelles
        channel = root.find("channel")
        if channel is None:
            return None

        item = channel.find("item")
        if item is None:
            return None

        desc = item.findtext("description") or ""

        # Parser la description HTML simplifiée (format WeeWX standard)
        result: dict = {}

        # Extraire les valeurs depuis le texte description
        # Format typique : "Outside Temp: 6.2°C | Humidity: 71%..."
        import re

        patterns = {
            "temp_ext": r"(?:Outside|Ext(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
            "temp_int": r"(?:Inside|Int(?:erieur)?)\s*Temp(?:erature)?\s*[:\s]+(-?\d+(?:[.,]\d+)?)",
            "humidite": r"(?:Outside\s*)?Hum(?:idity)?\s*[:\s]+(\d+(?:[.,]\d+)?)",
            "pression": r"(?:Bar(?:ometer)?|Pression)\s*[:\s]+(\d+(?:[.,]\d+)?)",
            "pluie_mm": r"(?:Rain(?:fall)?|Pluie)\s*[:\s]+(\d+(?:[.,]\d+)?)",
            "vent_kmh": r"(?:Wind\s*Speed|Vent)\s*[:\s]+(\d+(?:[.,]\d+)?)",
            "uv": r"UV\s*[:\s]+(\d+(?:[.,]\d+)?)",
            "solaire": r"(?:Solar\s*Radiation|Solaire)\s*[:\s]+(\d+(?:[.,]\d+)?)",
        }

        for key, pattern in patterns.items():
            m = re.search(pattern, desc, re.IGNORECASE)
            result[key] = _safe_float(m.group(1)) if m else None

        # Direction vent (texte ou degrés)
        vent_dir_m = re.search(r"(?:Wind\s*Dir(?:ection)?)\s*[:\s]+([NSEO]{1,2}|Nord|Sud|Est|Ouest|\d+)", desc, re.IGNORECASE)
        if vent_dir_m:
            val = vent_dir_m.group(1).strip()
            if val.isdigit():
                result["vent_dir"] = _direction_to_abbr(float(val))
            else:
                result["vent_dir"] = val[:2].upper()
        else:
            result["vent_dir"] = None

        return result if any(v is not None for v in result.values()) else None

    except Exception as e:
        logger.warning(f"Station fetch_current error: {e}")
        return None


def fetch_yesterday_summary(base_url: str = STATION_URL) -> dict | None:
    """Récupère le résumé de la veille via le fichier NOAA mensuel de la station WeeWX.

    Retourne un dict avec : temp_ext (moy), t_min, t_max, pluie_mm — ou None.
    """
    from datetime import timedelta
    import re

    yesterday = (datetime.now() - timedelta(days=1)).date()
    year = yesterday.strftime("%Y")
    month = yesterday.strftime("%m")
    day = yesterday.day

    try:
        url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year}-{month}.txt"
        r = httpx.get(url, timeout=15)
        r.raise_for_status()

        for line in r.text.splitlines():
            parts = line.split()
            if len(parts) >= 7 and parts[0].isdigit() and int(parts[0]) == day:
                # Format NOAA : jour  tmax tmin tmoy  precip  ...
                return {
                    "t_max": _safe_float(parts[1]),
                    "t_min": _safe_float(parts[2]),
                    "temp_ext": _safe_float(parts[3]),
                    "pluie_mm": _safe_float(parts[5]),
                    "vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None,
                }
    except Exception as e:
        logger.warning(f"Station fetch_yesterday_summary error: {e}")
    return None

Step 2: Test rapide (sans pytest)

cd backend && python -c "
from app.services.station import fetch_current
result = fetch_current()
print('Station current:', result)
"

Expected: dict avec données (ou None si station non accessible depuis cet environnement)

Step 3: Commit

git add backend/app/services/station.py
git commit -m "feat(service): scraper station WeeWX (RSS current + NOAA yesterday)"

Task 5 : Service Open-Meteo enrichi (remplace l'ancien)

Files:

  • Modify: backend/app/services/meteo.py

Step 1: Écrire le test d'abord

Créer backend/tests/test_meteo.py :

"""Tests du service météo et des endpoints."""
from unittest.mock import patch, MagicMock


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


def test_meteo_tableau_vide(client):
    """Le tableau fonctionne même si les tables sont vides."""
    r = client.get("/api/meteo/tableau")
    assert r.status_code == 200
    data = r.json()
    assert "rows" in data
    assert isinstance(data["rows"], list)
    # 15 lignes attendues (7 passé + J0 + 7 futur)
    assert len(data["rows"]) == 15


def test_meteo_station_current_vide(client):
    """Retourne null si aucune donnée station."""
    r = client.get("/api/meteo/station/current")
    assert r.status_code == 200
    # Peut être null ou un objet
    assert r.json() is None or isinstance(r.json(), dict)


def test_meteo_previsions(client):
    """Retourne une liste de jours de prévisions."""
    r = client.get("/api/meteo/previsions")
    assert r.status_code == 200
    data = r.json()
    assert "days" in data

Step 2: Lancer le test pour vérifier qu'il échoue

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

Expected: FAIL — test_meteo_tableau_vide échoue car l'endpoint n'existe pas encore.

Step 3: Remplacer backend/app/services/meteo.py

"""Service Open-Meteo — enrichi avec sol, ETP, humidité, données passées."""
import logging
from datetime import datetime, date, timedelta, timezone
from typing import Any

import httpx

from app.config import METEO_LAT, METEO_LON

logger = logging.getLogger(__name__)

WMO_LABELS = {
    0: "Ensoleillé", 1: "Principalement ensoleillé", 2: "Partiellement nuageux",
    3: "Couvert", 45: "Brouillard", 48: "Brouillard givrant",
    51: "Bruine légère", 53: "Bruine modérée", 55: "Bruine dense",
    61: "Pluie légère", 63: "Pluie modérée", 65: "Pluie forte",
    71: "Neige légère", 73: "Neige modérée", 75: "Neige forte",
    80: "Averses légères", 81: "Averses modérées", 82: "Averses violentes",
    85: "Averses de neige", 95: "Orage", 96: "Orage avec grêle", 99: "Orage violent",
}


def fetch_and_store_forecast(lat: float = METEO_LAT, lon: float = METEO_LON) -> list[dict]:
    """Appelle Open-Meteo et stocke les résultats en base.

    Retourne la liste des jours insérés/mis à jour.
    """
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": lat,
        "longitude": lon,
        "daily": ",".join([
            "temperature_2m_max", "temperature_2m_min",
            "precipitation_sum", "windspeed_10m_max", "weathercode",
            "relative_humidity_2m_max",
            "soil_temperature_0cm",
            "et0_fao_evapotranspiration",
        ]),
        "past_days": 7,
        "forecast_days": 8,
        "timezone": "Europe/Paris",
    }
    try:
        r = httpx.get(url, params=params, timeout=15)
        r.raise_for_status()
        raw = r.json()
    except Exception as e:
        logger.error(f"Open-Meteo fetch error: {e}")
        return []

    daily = raw.get("daily", {})
    dates = daily.get("time", [])
    now_iso = datetime.now(timezone.utc).isoformat()
    rows = []

    for i, d in enumerate(dates):
        code = int(daily.get("weathercode", [0] * len(dates))[i] or 0)
        row = {
            "date": d,
            "t_min": daily.get("temperature_2m_min", [None] * len(dates))[i],
            "t_max": daily.get("temperature_2m_max", [None] * len(dates))[i],
            "pluie_mm": daily.get("precipitation_sum", [0] * len(dates))[i] or 0.0,
            "vent_kmh": daily.get("windspeed_10m_max", [0] * len(dates))[i] or 0.0,
            "wmo": code,
            "label": WMO_LABELS.get(code, f"Code {code}"),
            "humidite_moy": daily.get("relative_humidity_2m_max", [None] * len(dates))[i],
            "sol_0cm": daily.get("soil_temperature_0cm", [None] * len(dates))[i],
            "etp_mm": daily.get("et0_fao_evapotranspiration", [None] * len(dates))[i],
            "fetched_at": now_iso,
        }
        rows.append(row)

    return rows


def fetch_forecast(lat: float = METEO_LAT, lon: float = METEO_LON, days: int = 14) -> dict[str, Any]:
    """Compatibilité ascendante avec l'ancien endpoint GET /api/meteo."""
    rows = fetch_and_store_forecast(lat, lon)
    # Filtrer seulement les jours futurs
    today = date.today().isoformat()
    future = [r for r in rows if r["date"] >= today][:days]
    return {"days": future}

Step 4: Commit (le service seulement)

git add backend/app/services/meteo.py
git commit -m "feat(service): open-meteo enrichi (sol, ETP, past_days, humidité)"

Task 6 : Scheduler APScheduler

Files:

  • Create: backend/app/services/scheduler.py
  • Modify: backend/app/main.py

Step 1: Créer backend/app/services/scheduler.py

"""Scheduler APScheduler — 3 jobs de collecte météo."""
import logging
from datetime import datetime

from apscheduler.schedulers.asyncio import AsyncIOScheduler

logger = logging.getLogger(__name__)

scheduler = AsyncIOScheduler(timezone="Europe/Paris")


def _store_station_current() -> None:
    """Collecte et stocke les données actuelles de la station."""
    from app.services.station import fetch_current
    from app.models.meteo import MeteoStation
    from app.database import engine
    from sqlmodel import Session

    data = fetch_current()
    if not data:
        logger.warning("Station current: aucune donnée collectée")
        return

    now_str = datetime.now().strftime("%Y-%m-%dT%H:00")
    entry = MeteoStation(date_heure=now_str, type="current", **data)

    with Session(engine) as session:
        existing = session.get(MeteoStation, now_str)
        if existing:
            for k, v in data.items():
                setattr(existing, k, v)
            session.add(existing)
        else:
            session.add(entry)
        session.commit()
    logger.info(f"Station current stockée : {now_str}")


def _store_station_veille() -> None:
    """Collecte et stocke le résumé de la veille (NOAA)."""
    from datetime import timedelta
    from app.services.station import fetch_yesterday_summary
    from app.models.meteo import MeteoStation
    from app.database import engine
    from sqlmodel import Session

    data = fetch_yesterday_summary()
    if not data:
        logger.warning("Station veille: aucune donnée collectée")
        return

    yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%dT00:00")
    entry = MeteoStation(date_heure=yesterday, type="veille", **data)

    with Session(engine) as session:
        existing = session.get(MeteoStation, yesterday)
        if existing:
            for k, v in data.items():
                setattr(existing, k, v)
            session.add(existing)
        else:
            session.add(entry)
        session.commit()
    logger.info(f"Station veille stockée : {yesterday}")


def _store_open_meteo() -> None:
    """Collecte et stocke les prévisions Open-Meteo."""
    from app.services.meteo import fetch_and_store_forecast
    from app.models.meteo import MeteoOpenMeteo
    from app.database import engine
    from sqlmodel import Session

    rows = fetch_and_store_forecast()
    if not rows:
        logger.warning("Open-Meteo: aucune donnée collectée")
        return

    with Session(engine) as session:
        for row in rows:
            existing = session.get(MeteoOpenMeteo, row["date"])
            if existing:
                for k, v in row.items():
                    if k != "date":
                        setattr(existing, k, v)
                session.add(existing)
            else:
                session.add(MeteoOpenMeteo(**row))
        session.commit()
    logger.info(f"Open-Meteo stocké : {len(rows)} jours")


def setup_scheduler() -> None:
    """Configure et démarre le scheduler."""
    scheduler.add_job(
        _store_station_current, "interval", hours=1,
        next_run_time=datetime.now(), id="station_current", replace_existing=True,
    )
    scheduler.add_job(
        _store_station_veille, "cron", hour=6, minute=0,
        next_run_time=datetime.now(), id="station_veille", replace_existing=True,
    )
    scheduler.add_job(
        _store_open_meteo, "interval", hours=1,
        next_run_time=datetime.now(), id="open_meteo", replace_existing=True,
    )
    scheduler.start()
    logger.info("Scheduler météo démarré (3 jobs)")

Step 2: Intégrer dans le lifespan de main.py

Modifier backend/app/main.py — remplacer le bloc lifespan :

import os
from contextlib import asynccontextmanager

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

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)
    try:
        os.makedirs("/data/skyfield", exist_ok=True)
    except OSError:
        pass
    import app.models  # noqa — enregistre tous les modèles avant create_all
    from app.migrate import run_migrations
    run_migrations()
    create_db_and_tables()
    from app.seed import run_seed
    run_seed()
    # Démarrer le scheduler météo
    from app.services.scheduler import setup_scheduler
    setup_scheduler()
    yield
    # Arrêter le scheduler
    from app.services.scheduler import scheduler
    scheduler.shutdown(wait=False)


app = FastAPI(title="Jardin API", lifespan=lifespan)
# ... reste inchangé

Step 3: Vérifier que le serveur démarre sans erreur

cd backend && python -c "
import asyncio
from app.main import app
print('Import OK:', app.title)
"

Expected: Import OK: Jardin API

Step 4: Commit

git add backend/app/services/scheduler.py backend/app/main.py
git commit -m "feat(scheduler): APScheduler 3 jobs météo dans FastAPI lifespan"

Task 7 : Endpoints météo (tableau synthétique)

Files:

  • Modify: backend/app/routers/meteo.py

Step 1: Remplacer backend/app/routers/meteo.py

"""Router météo — station WeeWX + Open-Meteo + tableau synthétique."""
from datetime import date, timedelta
from typing import Any, Optional

from fastapi import APIRouter, Query
from sqlalchemy import text

from app.database import engine

router = APIRouter(tags=["météo"])


def _station_daily_summary(iso_date: str) -> Optional[dict]:
    """Agrège les mesures horaires d'une journée en résumé."""
    with engine.connect() as conn:
        rows = conn.execute(
            text("SELECT temp_ext, pluie_mm, vent_kmh, humidite FROM meteostation WHERE date(date_heure) = :d"),
            {"d": iso_date},
        ).fetchall()

    if not rows:
        return None

    temps = [r[0] for r in rows if r[0] is not None]
    pluies = [r[1] for r in rows if r[1] is not None]
    vents = [r[2] for r in rows if r[2] is not None]
    hums = [r[3] for r in rows if r[3] is not None]

    return {
        "t_min": round(min(temps), 1) if temps else None,
        "t_max": round(max(temps), 1) if temps else None,
        "pluie_mm": round(sum(pluies), 1) if pluies else 0.0,
        "vent_kmh": round(max(vents), 1) if vents else None,
        "humidite": round(sum(hums) / len(hums), 0) if hums else None,
    }


def _station_current_row() -> Optional[dict]:
    """Dernière mesure station (max 2h d'ancienneté)."""
    with engine.connect() as conn:
        row = conn.execute(
            text("SELECT temp_ext, humidite, pression, pluie_mm, vent_kmh, vent_dir, uv, solaire, date_heure "
                 "FROM meteostation WHERE type='current' ORDER BY date_heure DESC LIMIT 1")
        ).fetchone()

    if not row:
        return None

    return {
        "temp_ext": row[0], "humidite": row[1], "pression": row[2],
        "pluie_mm": row[3], "vent_kmh": row[4], "vent_dir": row[5],
        "uv": row[6], "solaire": row[7], "date_heure": row[8],
    }


def _open_meteo_day(iso_date: str) -> Optional[dict]:
    with engine.connect() as conn:
        row = conn.execute(
            text("SELECT t_min, t_max, pluie_mm, vent_kmh, wmo, label, humidite_moy, sol_0cm, etp_mm "
                 "FROM meteoopenmeteo WHERE date = :d"),
            {"d": iso_date},
        ).fetchone()

    if not row:
        return None

    return {
        "t_min": row[0], "t_max": row[1], "pluie_mm": row[2],
        "vent_kmh": row[3], "wmo": row[4], "label": row[5],
        "humidite_moy": row[6], "sol_0cm": row[7], "etp_mm": row[8],
    }


@router.get("/meteo/tableau")
def get_tableau() -> dict[str, Any]:
    """Tableau synthétique : 7j passé + J0 + 7j futur."""
    today = date.today()
    rows = []

    for delta in range(-7, 8):
        d = today + timedelta(days=delta)
        iso = d.isoformat()

        if delta < 0:
            row_type = "passe"
            station = _station_daily_summary(iso)
            om = None  # Pas de prévision pour le passé
        elif delta == 0:
            row_type = "aujourd_hui"
            station = _station_current_row()
            om = _open_meteo_day(iso)
        else:
            row_type = "futur"
            station = None
            om = _open_meteo_day(iso)

        rows.append({"date": iso, "type": row_type, "station": station, "open_meteo": om})

    return {"rows": rows}


@router.get("/meteo/station/current")
def get_station_current() -> Optional[dict]:
    return _station_current_row()


@router.get("/meteo/station/history")
def get_station_history(days: int = Query(7, ge=1, le=30)) -> dict[str, Any]:
    today = date.today()
    result = []
    for delta in range(-days, 0):
        d = today + timedelta(days=delta)
        iso = d.isoformat()
        summary = _station_daily_summary(iso)
        result.append({"date": iso, "station": summary})
    return {"days": result}


@router.get("/meteo/previsions")
def get_previsions(days: int = Query(7, ge=1, le=14)) -> dict[str, Any]:
    today = date.today()
    result = []
    for delta in range(0, days + 1):
        d = today + timedelta(days=delta)
        iso = d.isoformat()
        om = _open_meteo_day(iso)
        if om:
            result.append({"date": iso, **om})
    return {"days": result}


@router.get("/meteo")
def get_meteo_legacy(
    days: int = Query(14, ge=1, le=16),
    lat: float = Query(45.14),
    lon: float = Query(4.12),
):
    """Compatibilité ascendante avec l'ancien endpoint."""
    from app.services.meteo import fetch_forecast
    return fetch_forecast(lat=lat, lon=lon, days=days)


@router.post("/meteo/refresh")
def refresh_meteo() -> dict[str, str]:
    """Force le rafraîchissement immédiat des 3 jobs."""
    from app.services.scheduler import scheduler
    for job_id in ["station_current", "station_veille", "open_meteo"]:
        job = scheduler.get_job(job_id)
        if job:
            job.modify(next_run_time=__import__("datetime").datetime.now())
    return {"status": "refresh planifié"}

Step 2: Lancer les tests

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

Expected: tous les tests passent (4/4)

Step 3: Commit

git add backend/app/routers/meteo.py backend/tests/test_meteo.py
git commit -m "feat(router): endpoints météo tableau/station/previsions + tests"

Task 8 : Router astuces — filtres categorie/tags/mois

Files:

  • Modify: backend/app/routers/astuces.py

Step 1: Écrire le test d'abord

Créer backend/tests/test_astuces.py :

"""Tests CRUD astuces avec filtres categorie/tags/mois."""
import json


def test_create_astuce(client):
    r = client.post("/api/astuces", json={
        "titre": "Arrosage tomate",
        "contenu": "Arroser au pied, jamais sur les feuilles.",
        "categorie": "plante",
        "tags": json.dumps(["tomate", "arrosage"]),
        "mois": json.dumps([5, 6, 7, 8]),
    })
    assert r.status_code == 201
    data = r.json()
    assert data["titre"] == "Arrosage tomate"
    assert data["categorie"] == "plante"


def test_list_astuces(client):
    client.post("/api/astuces", json={"titre": "T1", "contenu": "C1", "categorie": "jardin"})
    client.post("/api/astuces", json={"titre": "T2", "contenu": "C2", "categorie": "plante"})
    r = client.get("/api/astuces")
    assert r.status_code == 200
    assert len(r.json()) >= 2


def test_filter_categorie(client):
    client.post("/api/astuces", json={"titre": "A", "contenu": "A", "categorie": "ravageur"})
    client.post("/api/astuces", json={"titre": "B", "contenu": "B", "categorie": "plante"})
    r = client.get("/api/astuces?categorie=ravageur")
    assert r.status_code == 200
    assert all(a["categorie"] == "ravageur" for a in r.json())


def test_filter_mois(client):
    client.post("/api/astuces", json={
        "titre": "Printemps", "contenu": "X", "mois": json.dumps([3, 4, 5])
    })
    r = client.get("/api/astuces?mois=3")
    assert r.status_code == 200
    # Au moins une astuce contient le mois 3
    assert any("3" in (a.get("mois") or "") for a in r.json())


def test_delete_astuce(client):
    r = client.post("/api/astuces", json={"titre": "À suppr", "contenu": "X"})
    id_ = r.json()["id"]
    assert client.delete(f"/api/astuces/{id_}").status_code == 204
    assert client.get(f"/api/astuces/{id_}").status_code == 404

Step 2: Lancer pour vérifier l'échec

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

Expected: certains tests échouent (le filtre mois n'est pas encore implémenté)

Step 3: Mettre à jour backend/app/routers/astuces.py

"""Router astuces — CRUD + filtres categorie/tags/mois."""
import json
from typing import List, Optional

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

from app.database import get_session
from app.models.astuce import Astuce

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


@router.get("/astuces", response_model=List[Astuce])
def list_astuces(
    categorie: Optional[str] = Query(None),
    mois: Optional[int] = Query(None, ge=1, le=12),
    tag: Optional[str] = Query(None),
    session: Session = Depends(get_session),
):
    q = select(Astuce)
    if categorie:
        q = q.where(Astuce.categorie == categorie)
    astuces = session.exec(q).all()

    # Filtres post-requête (JSON arrays stockés en TEXT)
    if mois is not None:
        astuces = [
            a for a in astuces
            if a.mois is None or str(mois) in (a.mois or "")
        ]
    if tag:
        astuces = [
            a for a in astuces
            if tag.lower() in (a.tags or "").lower()
        ]
    return astuces


@router.post("/astuces", response_model=Astuce, status_code=status.HTTP_201_CREATED)
def create_astuce(a: Astuce, session: Session = Depends(get_session)):
    session.add(a)
    session.commit()
    session.refresh(a)
    return a


@router.get("/astuces/{id}", response_model=Astuce)
def get_astuce(id: int, session: Session = Depends(get_session)):
    a = session.get(Astuce, id)
    if not a:
        raise HTTPException(404, "Astuce introuvable")
    return a


@router.put("/astuces/{id}", response_model=Astuce)
def update_astuce(id: int, data: Astuce, session: Session = Depends(get_session)):
    a = session.get(Astuce, id)
    if not a:
        raise HTTPException(404, "Astuce introuvable")
    for k, v in data.model_dump(exclude_unset=True, exclude={"id", "created_at"}).items():
        setattr(a, k, v)
    session.add(a)
    session.commit()
    session.refresh(a)
    return a


@router.delete("/astuces/{id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_astuce(id: int, session: Session = Depends(get_session)):
    a = session.get(Astuce, id)
    if not a:
        raise HTTPException(404, "Astuce introuvable")
    session.delete(a)
    session.commit()

Step 4: Lancer les tests

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

Expected: 5/5 PASS

Step 5: Lancer tous les tests backend

cd backend && pytest -v

Expected: tous passent (aucune régression)

Step 6: Commit

git add backend/app/routers/astuces.py backend/tests/test_astuces.py
git commit -m "feat(astuces): filtres categorie/mois/tag + tests CRUD complet"

Task 9 : Frontend API météo + store astuces

Files:

  • Modify: frontend/src/api/meteo.ts
  • Create: frontend/src/api/astuces.ts
  • Create: frontend/src/stores/astuces.ts

Step 1: Mettre à jour frontend/src/api/meteo.ts

import client from './client'

export interface MeteoDay {
  date: string
  t_max?: number
  t_min?: number
  pluie_mm: number
  vent_kmh: number
  code: number
  label: string
  icone: string
}

export interface StationCurrent {
  temp_ext?: number
  humidite?: number
  pression?: number
  pluie_mm?: number
  vent_kmh?: number
  vent_dir?: string
  uv?: number
  solaire?: number
  date_heure?: string
}

export interface StationDay {
  t_min?: number
  t_max?: number
  pluie_mm?: number
  vent_kmh?: number
  humidite?: number
}

export interface OpenMeteoDay {
  t_min?: number
  t_max?: number
  pluie_mm?: number
  vent_kmh?: number
  wmo?: number
  label?: string
  humidite_moy?: number
  sol_0cm?: number
  etp_mm?: number
}

export interface TableauRow {
  date: string
  type: 'passe' | 'aujourd_hui' | 'futur'
  station: StationDay | StationCurrent | null
  open_meteo: OpenMeteoDay | null
}

export const meteoApi = {
  getForecast: (days = 14) =>
    client.get<{ days: MeteoDay[] }>('/api/meteo', { params: { days } }).then(r => r.data),

  getTableau: () =>
    client.get<{ rows: TableauRow[] }>('/api/meteo/tableau').then(r => r.data),

  getStationCurrent: () =>
    client.get<StationCurrent | null>('/api/meteo/station/current').then(r => r.data),

  getPrevisions: (days = 7) =>
    client.get<{ days: OpenMeteoDay[] }>('/api/meteo/previsions', { params: { days } }).then(r => r.data),

  refresh: () =>
    client.post('/api/meteo/refresh').then(r => r.data),
}

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

import client from './client'

export interface Astuce {
  id?: number
  titre: string
  contenu: string
  categorie?: string
  tags?: string   // JSON string: '["tomate","semis"]'
  mois?: string   // JSON string: '[3,4,5]'
  source?: string
  created_at?: string
}

export const astucesApi = {
  list: (params?: { categorie?: string; mois?: number; tag?: string }) =>
    client.get<Astuce[]>('/api/astuces', { params }).then(r => r.data),

  get: (id: number) =>
    client.get<Astuce>(`/api/astuces/${id}`).then(r => r.data),

  create: (a: Omit<Astuce, 'id' | 'created_at'>) =>
    client.post<Astuce>('/api/astuces', a).then(r => r.data),

  update: (id: number, a: Partial<Astuce>) =>
    client.put<Astuce>(`/api/astuces/${id}`, a).then(r => r.data),

  remove: (id: number) =>
    client.delete(`/api/astuces/${id}`),
}

Step 3: Créer frontend/src/stores/astuces.ts

import { ref } from 'vue'
import { defineStore } from 'pinia'
import { astucesApi, type Astuce } from '@/api/astuces'

export const useAstucesStore = defineStore('astuces', () => {
  const astuces = ref<Astuce[]>([])
  const loading = ref(false)

  async function fetchAll(params?: { categorie?: string; mois?: number; tag?: string }) {
    loading.value = true
    try {
      astuces.value = await astucesApi.list(params)
    } finally {
      loading.value = false
    }
  }

  async function create(a: Omit<Astuce, 'id' | 'created_at'>) {
    const created = await astucesApi.create(a)
    astuces.value.unshift(created)
    return created
  }

  async function update(id: number, data: Partial<Astuce>) {
    const updated = await astucesApi.update(id, data)
    const idx = astuces.value.findIndex(x => x.id === id)
    if (idx !== -1) astuces.value[idx] = updated
    return updated
  }

  async function remove(id: number) {
    await astucesApi.remove(id)
    astuces.value = astuces.value.filter(a => a.id !== id)
  }

  return { astuces, loading, fetchAll, create, update, remove }
})

Step 4: Commit

git add frontend/src/api/meteo.ts frontend/src/api/astuces.ts frontend/src/stores/astuces.ts
git commit -m "feat(frontend): API météo enrichie + api/stores astuces"

Task 10 : Frontend — CalendrierView.vue (refonte onglet météo)

Files:

  • Modify: frontend/src/views/CalendrierView.vue

La section <!-- === MÉTÉO === --> (lignes 87-103) est à remplacer par un tableau synthétique.

Step 1: Remplacer le bloc <div v-if="activeTab === 'meteo'"> (lignes 87-103)

<!-- === MÉTÉO === -->
<div v-if="activeTab === 'meteo'">
  <!-- Widget "maintenant" -->
  <div v-if="stationCurrent" class="bg-bg-soft rounded-xl p-4 border border-bg-hard mb-4 flex flex-wrap gap-4 items-center">
    <div>
      <div class="text-text-muted text-xs mb-1">Température extérieure</div>
      <div class="text-text text-2xl font-bold">{{ stationCurrent.temp_ext?.toFixed(1) }}°C</div>
    </div>
    <div class="flex gap-4 text-sm">
      <span v-if="stationCurrent.humidite" class="text-blue">💧{{ stationCurrent.humidite }}%</span>
      <span v-if="stationCurrent.vent_kmh" class="text-text">💨{{ stationCurrent.vent_kmh }} km/h {{ stationCurrent.vent_dir || '' }}</span>
      <span v-if="stationCurrent.pression" class="text-text-muted">⬛{{ stationCurrent.pression }} hPa</span>
    </div>
    <div v-if="stationCurrent.date_heure" class="text-text-muted text-xs ml-auto">
      Relevé {{ stationCurrent.date_heure?.slice(11, 16) }}
    </div>
  </div>

  <!-- Tableau synthétique -->
  <div v-if="loadingTableau" class="text-text-muted text-sm py-4">Chargement météo...</div>
  <div v-else-if="!tableauRows.length" class="text-text-muted text-sm py-4">Pas de données météo.</div>
  <div v-else class="overflow-x-auto">
    <table class="w-full text-sm border-collapse">
      <thead>
        <tr class="text-text-muted text-xs">
          <th class="text-left py-2 px-2">Date</th>
          <th class="text-center py-2 px-2 text-blue" colspan="3">📡 Station locale</th>
          <th class="text-center py-2 px-2 text-green" colspan="4">🌐 Open-Meteo</th>
        </tr>
        <tr class="text-text-muted text-xs border-b border-bg-hard">
          <th class="text-left py-1 px-2"></th>
          <th class="text-right py-1 px-1">T°min</th>
          <th class="text-right py-1 px-1">T°max</th>
          <th class="text-right py-1 px-1">💧mm</th>
          <th class="text-right py-1 px-1">T°min</th>
          <th class="text-right py-1 px-1">T°max</th>
          <th class="text-right py-1 px-1">💧mm</th>
          <th class="text-left py-1 px-2">État</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="row in tableauRows" :key="row.date"
          :class="['border-b border-bg-hard transition-colors',
            row.type === 'aujourd_hui' ? 'border-green bg-green/5 font-semibold' : '',
            row.type === 'passe' ? 'opacity-60' : '']">
          <td class="py-2 px-2 text-text-muted text-xs whitespace-nowrap">
            <span :class="row.type === 'aujourd_hui' ? 'text-green font-bold' : ''">
              {{ formatDate(row.date) }}
            </span>
          </td>
          <!-- Colonnes station -->
          <td class="text-right px-1 text-blue text-xs">
            {{ row.station && 't_min' in row.station && row.station.t_min != null ? row.station.t_min.toFixed(1) + '°' : '—' }}
          </td>
          <td class="text-right px-1 text-orange text-xs">
            {{ row.station && 't_max' in row.station && row.station.t_max != null ? row.station.t_max.toFixed(1) + '°' : (row.type === 'aujourd_hui' && row.station && 'temp_ext' in row.station && row.station.temp_ext != null ? row.station.temp_ext.toFixed(1) + '° act.' : '—') }}
          </td>
          <td class="text-right px-1 text-blue text-xs">
            {{ row.station && row.station.pluie_mm != null ? row.station.pluie_mm : '—' }}
          </td>
          <!-- Colonnes open-meteo -->
          <td class="text-right px-1 text-blue text-xs">
            {{ row.open_meteo?.t_min != null ? row.open_meteo.t_min.toFixed(1) + '°' : '—' }}
          </td>
          <td class="text-right px-1 text-orange text-xs">
            {{ row.open_meteo?.t_max != null ? row.open_meteo.t_max.toFixed(1) + '°' : '—' }}
          </td>
          <td class="text-right px-1 text-blue text-xs">
            {{ row.open_meteo?.pluie_mm != null ? row.open_meteo.pluie_mm : '—' }}
          </td>
          <td class="px-2">
            <div class="flex items-center gap-1">
              <img v-if="row.open_meteo?.wmo != null" :src="weatherIcon(row.open_meteo.wmo)" class="w-5 h-5" :alt="row.open_meteo.label" />
              <span class="text-text-muted text-xs">{{ row.open_meteo?.label || '' }}</span>
            </div>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>

Step 2: Ajouter dans la section <script setup lang="ts"> les nouvelles refs et fonctions

Ajouter après const loadingMeteo = ref(false) (ligne 180 environ) :

// Tableau synthétique
const tableauRows = ref<import('@/api/meteo').TableauRow[]>([])
const loadingTableau = ref(false)
const stationCurrent = ref<import('@/api/meteo').StationCurrent | null>(null)

async function loadTableau() {
  loadingTableau.value = true
  try {
    const res = await meteoApi.getTableau()
    tableauRows.value = res.rows || []
  } catch { tableauRows.value = [] }
  finally { loadingTableau.value = false }
}

async function loadStationCurrent() {
  try {
    stationCurrent.value = await meteoApi.getStationCurrent()
  } catch { stationCurrent.value = null }
}

Step 3: Mettre à jour les imports et le watch

En haut du <script setup>, remplacer la ligne d'import meteo :

import { meteoApi, type MeteoDay, type TableauRow, type StationCurrent } from '@/api/meteo'

Mettre à jour le watch et onMounted :

watch(activeTab, (tab) => {
  if (tab === 'lunaire' && !lunarDays.value.length) loadLunar()
  if (tab === 'meteo' && !tableauRows.value.length) { loadTableau(); loadStationCurrent() }
  if (tab === 'dictons' && !dictons.value.length) loadDictons()
})

onMounted(() => { loadLunar(); loadTableau(); loadStationCurrent() })

Supprimer les anciennes refs meteoData et loadingMeteo et la fonction loadMeteo (plus utilisées).

Step 4: Vérifier que le build TypeScript compile

cd frontend && npm run build 2>&1 | head -30

Expected: Build réussi sans erreur TypeScript

Step 5: Commit

git add frontend/src/views/CalendrierView.vue
git commit -m "feat(frontend): CalendrierView météo — tableau synthétique station + open-meteo"

Task 11 : Frontend — AstucessView.vue (nouvelle vue)

Files:

  • Create: frontend/src/views/AstucessView.vue
  • Modify: frontend/src/router/index.ts
  • Modify: frontend/src/components/AppDrawer.vue

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

<template>
  <div class="p-4 max-w-3xl mx-auto">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold text-yellow">💡 Astuces</h1>
      <button @click="openCreate" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
        + Ajouter
      </button>
    </div>

    <!-- Tabs catégories -->
    <div class="flex gap-1 mb-4 bg-bg-soft rounded-lg p-1 w-fit flex-wrap">
      <button v-for="cat in categories" :key="cat.val" @click="activeCat = cat.val"
        :class="['px-3 py-1.5 rounded-md text-xs font-medium transition-colors',
          activeCat === cat.val ? 'bg-yellow text-bg' : 'text-text-muted hover:text-text']">
        {{ cat.label }}
      </button>
    </div>

    <!-- Filtre mois courant -->
    <div class="flex items-center gap-2 mb-4">
      <button @click="filterMoisActuel = !filterMoisActuel"
        :class="['px-3 py-1 rounded-full text-xs font-medium transition-colors border',
          filterMoisActuel ? 'bg-green/20 text-green border-green/40' : 'border-bg-hard text-text-muted']">
        📅 Ce mois ({{ moisActuelLabel }})
      </button>
      <input v-model="filterTag" placeholder="Filtrer par tag..." class="bg-bg border border-bg-hard rounded px-3 py-1 text-text text-xs outline-none focus:border-yellow w-40" />
    </div>

    <div v-if="store.loading" class="text-text-muted text-sm">Chargement...</div>
    <div v-else-if="!store.astuces.length" class="text-text-muted text-sm py-8 text-center">
      Aucune astuce pour ce filtre.
    </div>

    <!-- Liste astuces -->
    <div v-for="a in store.astuces" :key="a.id"
      class="bg-bg-soft rounded-xl p-4 mb-3 border border-bg-hard">
      <div class="flex items-start justify-between gap-2 mb-2">
        <div>
          <span class="text-text font-semibold text-sm">{{ a.titre }}</span>
          <span v-if="a.categorie" :class="['ml-2 text-xs px-2 py-0.5 rounded-full', catClass(a.categorie)]">
            {{ a.categorie }}
          </span>
        </div>
        <div class="flex gap-2 shrink-0">
          <button @click="startEdit(a)" class="text-yellow text-xs hover:underline">Édit.</button>
          <button @click="remove(a.id!)" class="text-text-muted hover:text-red text-xs"></button>
        </div>
      </div>
      <p class="text-text-muted text-sm mb-2">{{ a.contenu }}</p>
      <div class="flex gap-1 flex-wrap">
        <span v-for="tag in parseTags(a.tags)" :key="tag"
          @click="filterTag = tag"
          class="text-xs bg-bg border border-bg-hard rounded-full px-2 py-0.5 text-text-muted cursor-pointer hover:text-yellow hover:border-yellow transition-colors">
          #{{ tag }}
        </span>
        <span v-if="parseMois(a.mois).length"
          class="text-xs bg-green/10 text-green rounded-full px-2 py-0.5">
          Mois : {{ parseMois(a.mois).join(', ') }}
        </span>
      </div>
    </div>

    <!-- Modal création / édition -->
    <div v-if="showForm" class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" @click.self="closeForm">
      <div class="bg-bg-hard rounded-xl p-6 w-full max-w-md border border-bg-soft max-h-[90vh] overflow-y-auto">
        <h2 class="text-text font-bold text-lg mb-4">{{ editId ? 'Modifier l\'astuce' : 'Nouvelle astuce' }}</h2>
        <form @submit.prevent="submit" class="flex flex-col 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 outline-none focus:border-yellow" />
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Contenu *</label>
            <textarea v-model="form.contenu" required rows="4" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm outline-none focus:border-yellow resize-none" />
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Catégorie</label>
            <select v-model="form.categorie" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm">
              <option value="">Toutes catégories</option>
              <option v-for="c in categories.slice(1)" :key="c.val" :value="c.val">{{ c.label }}</option>
            </select>
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-1">Tags (séparés par virgule)</label>
            <input v-model="tagsInput" placeholder="tomate, semis, printemps" class="w-full bg-bg border border-bg-hard rounded px-3 py-2 text-text text-sm outline-none focus:border-yellow" />
          </div>
          <div>
            <label class="text-text-muted text-xs block mb-2">Mois concernés (0 = toute l'année)</label>
            <div class="grid grid-cols-6 gap-1">
              <label v-for="m in 12" :key="m" class="flex items-center gap-1 text-xs text-text-muted cursor-pointer">
                <input type="checkbox" :value="m" v-model="moisSelected" class="accent-yellow" />
                {{ ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][m-1] }}
              </label>
            </div>
          </div>
          <div class="flex gap-2 justify-end mt-2">
            <button type="button" @click="closeForm" class="px-4 py-2 text-text-muted hover:text-text text-sm">Annuler</button>
            <button type="submit" class="bg-yellow text-bg px-4 py-2 rounded-lg text-sm font-semibold hover:opacity-90">
              {{ editId ? 'Enregistrer' : 'Créer' }}
            </button>
          </div>
        </form>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useAstucesStore } from '@/stores/astuces'
import type { Astuce } from '@/api/astuces'

const store = useAstucesStore()
const showForm = ref(false)
const editId = ref<number | null>(null)
const activeCat = ref('')
const filterMoisActuel = ref(false)
const filterTag = ref('')
const tagsInput = ref('')
const moisSelected = ref<number[]>([])

const form = reactive({ titre: '', contenu: '', categorie: '', tags: '', mois: '' })

const categories = [
  { val: '', label: '🌿 Toutes' },
  { val: 'plante', label: '🌱 Plante' },
  { val: 'jardin', label: '🏡 Jardin' },
  { val: 'tache', label: '✅ Tâche' },
  { val: 'general', label: '📖 Général' },
  { val: 'ravageur', label: '🐛 Ravageur' },
  { val: 'maladie', label: '🍄 Maladie' },
]

const moisActuel = new Date().getMonth() + 1
const moisActuelLabel = ['Jan','Fév','Mar','Avr','Mai','Jun','Jul','Aoû','Sep','Oct','Nov','Déc'][moisActuel - 1]

function parseTags(tags?: string): string[] {
  try { return tags ? JSON.parse(tags) : [] } catch { return [] }
}
function parseMois(mois?: string): number[] {
  try { return mois ? JSON.parse(mois) : [] } catch { return [] }
}

const catClass = (cat: string) => ({
  plante: 'bg-green/20 text-green',
  jardin: 'bg-yellow/20 text-yellow',
  tache: 'bg-blue/20 text-blue',
  general: 'bg-text-muted/20 text-text-muted',
  ravageur: 'bg-red/20 text-red',
  maladie: 'bg-orange/20 text-orange',
}[cat] || 'bg-bg text-text-muted')

async function load() {
  const params: Record<string, string | number> = {}
  if (activeCat.value) params.categorie = activeCat.value
  if (filterMoisActuel.value) params.mois = moisActuel
  if (filterTag.value) params.tag = filterTag.value
  await store.fetchAll(params)
}

watch([activeCat, filterMoisActuel, filterTag], load)

function openCreate() {
  editId.value = null
  Object.assign(form, { titre: '', contenu: '', categorie: '', tags: '', mois: '' })
  tagsInput.value = ''
  moisSelected.value = []
  showForm.value = true
}

function startEdit(a: Astuce) {
  editId.value = a.id!
  Object.assign(form, { titre: a.titre, contenu: a.contenu, categorie: a.categorie || '', tags: a.tags || '', mois: a.mois || '' })
  tagsInput.value = parseTags(a.tags).join(', ')
  moisSelected.value = parseMois(a.mois)
  showForm.value = true
}

function closeForm() { showForm.value = false; editId.value = null }

async function submit() {
  const tagsArr = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
  const payload = {
    ...form,
    tags: tagsArr.length ? JSON.stringify(tagsArr) : undefined,
    mois: moisSelected.value.length ? JSON.stringify(moisSelected.value) : undefined,
  }
  if (editId.value) {
    await store.update(editId.value, payload)
  } else {
    await store.create(payload)
  }
  closeForm()
  load()
}

async function remove(id: number) {
  if (confirm('Supprimer cette astuce ?')) await store.remove(id)
}

onMounted(load)
</script>

Step 2: Ajouter la route dans frontend/src/router/index.ts

Ajouter après la ligne /calendrier :

{ path: '/astuces', component: () => import('@/views/AstucessView.vue') },

Step 3: Ajouter l'entrée dans frontend/src/components/AppDrawer.vue

Dans le tableau links, ajouter après { to: '/calendrier', label: 'Calendrier' } :

{ to: '/astuces', label: '💡 Astuces' },

Step 4: Vérifier le build

cd frontend && npm run build 2>&1 | head -40

Expected: Build sans erreur TypeScript

Step 5: Commit

git add frontend/src/views/AstucessView.vue frontend/src/router/index.ts frontend/src/components/AppDrawer.vue
git commit -m "feat(frontend): AstucessView + route /astuces + drawer"

Task 12 : Vérification finale

Step 1: Tests backend complets

cd backend && pytest -v

Expected: tous les tests passent

Step 2: Build frontend

cd frontend && npm run build

Expected: Build réussi

Step 3: Démarrage local (optionnel)

cd backend && uvicorn app.main:app --reload --port 8060

Vérifier dans les logs :

  • Scheduler météo démarré (3 jobs)
  • Station current stockée (ou warning si station inaccessible)
  • Open-Meteo stocké : N jours

Step 4: Commit de clôture

git add -A
git commit -m "feat: météo + astuces — APScheduler + SQLite + tableau synthétique + AstucessView"