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 envSTATION_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 dansmodels/__init__.py - Migration colonnes :
backend/app/migrate.py— patternEXPECTED_COLUMNS Astucemodèle existant : aentity_type,entity_id,titre,contenu,source— on ajoutecategorie,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"