From 7967f63feae3cd3f43e6d9966bdaa2f4d60eacf2 Mon Sep 17 00:00:00 2001 From: gilles Date: Sun, 1 Mar 2026 07:21:46 +0100 Subject: [PATCH] avant 50 --- .claude/settings.json | 4 +- backend/app/main.py | 12 +- backend/app/migrate.py | 1 + backend/app/models/planting.py | 9 +- backend/app/routers/gardens.py | 13 + backend/app/routers/plantings.py | 13 +- backend/app/services/scheduler.py | 64 ++ backend/app/services/station.py | 90 ++- backend/app/services/yolo_service.py | 62 +- data/jardin.db | Bin 335872 -> 360448 bytes data/jardin.db-shm | Bin 32768 -> 32768 bytes data/jardin.db-wal | Bin 12392 -> 28872 bytes frontend/index.html | 4 + frontend/public/favicon.svg | 4 + frontend/src/App.vue | 12 +- frontend/src/api/client.ts | 36 +- frontend/src/api/gardens.ts | 4 + frontend/src/api/identify.ts | 25 + frontend/src/api/plantings.ts | 1 + frontend/src/components/DiagnosticModal.vue | 72 ++ frontend/src/components/ToastNotification.vue | 56 ++ frontend/src/composables/useToast.ts | 38 ++ frontend/src/main.ts | 3 + frontend/src/style.css | 49 +- frontend/src/utils/uiSizeDefaults.ts | 2 + frontend/src/views/AstucesView.vue | 302 ++++---- frontend/src/views/BibliothequeView.vue | 143 ++-- frontend/src/views/CalendrierView.vue | 520 +++++++------- frontend/src/views/DashboardView.vue | 201 ++++-- frontend/src/views/JardinDetailView.vue | 168 ++++- frontend/src/views/JardinsView.vue | 425 +++++++----- frontend/src/views/OutilsView.vue | 184 +++-- frontend/src/views/PlanningView.vue | 192 +++--- frontend/src/views/PlantationsView.vue | 594 +++++++++++----- frontend/src/views/PlantesView.vue | 615 ++++++++--------- frontend/src/views/ReglagesView.vue | 300 ++++---- frontend/src/views/TachesView.vue | 646 +++++++++++++++--- frontend/tsconfig.json | 2 +- frontend/vite.config.ts | 77 ++- 39 files changed, 3297 insertions(+), 1646 deletions(-) create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/src/api/identify.ts create mode 100644 frontend/src/components/DiagnosticModal.vue create mode 100644 frontend/src/components/ToastNotification.vue create mode 100644 frontend/src/composables/useToast.ts diff --git a/.claude/settings.json b/.claude/settings.json index f424770..79b49e9 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -75,7 +75,9 @@ "Bash(docker compose restart:*)", "Bash(docker compose build:*)", "Bash(__NEW_LINE_5f780afd9b58590d__ echo \"\")", - "Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)" + "Read(//home/gilles/.claude/projects/-home-gilles-Documents-vscode-jardin/**)", + "Bash(npx tsc:*)", + "Bash(npx vite:*)" ], "additionalDirectories": [ "/home/gilles/Documents/vscode/jardin/frontend/src", diff --git a/backend/app/main.py b/backend/app/main.py index 49c280c..b8244f8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import asyncio import os from contextlib import asynccontextmanager @@ -23,15 +24,22 @@ async def lifespan(app: FastAPI): from app.seed import run_seed run_seed() if ENABLE_SCHEDULER: - from app.services.scheduler import setup_scheduler + from app.services.scheduler import setup_scheduler, backfill_station_missing_dates setup_scheduler() + # Backfill des dates manquantes en arrière-plan (ne bloque pas le démarrage) + loop = asyncio.get_running_loop() + loop.run_in_executor(None, backfill_station_missing_dates) yield if ENABLE_BOOTSTRAP and ENABLE_SCHEDULER: from app.services.scheduler import scheduler scheduler.shutdown(wait=False) -app = FastAPI(title="Jardin API", lifespan=lifespan) +app = FastAPI( + title="Jardin API", + lifespan=lifespan, + redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.3/bundles/redoc.standalone.js" +) app.add_middleware( CORSMiddleware, diff --git a/backend/app/migrate.py b/backend/app/migrate.py index 2348509..a983d0a 100644 --- a/backend/app/migrate.py +++ b/backend/app/migrate.py @@ -51,6 +51,7 @@ EXPECTED_COLUMNS: dict[str, list[tuple[str, str, str | None]]] = { ("boutique_url", "TEXT", None), ("tarif_achat", "REAL", None), ("date_achat", "TEXT", None), + ("cell_ids", "TEXT", None), # JSON : liste des IDs de zones (multi-sélect) ], "plantvariety": [ # ancien nom de table → migration vers "plant" si présente diff --git a/backend/app/models/planting.py b/backend/app/models/planting.py index 7619706..c60f765 100644 --- a/backend/app/models/planting.py +++ b/backend/app/models/planting.py @@ -1,5 +1,7 @@ from datetime import date, datetime, timezone -from typing import Optional +from typing import List, Optional +from sqlalchemy import Column +from sqlalchemy import JSON as SA_JSON from sqlmodel import Field, SQLModel @@ -7,6 +9,7 @@ class PlantingCreate(SQLModel): garden_id: int variety_id: int cell_id: Optional[int] = None + cell_ids: Optional[List[int]] = None # multi-sélect zones date_semis: Optional[date] = None date_plantation: Optional[date] = None date_repiquage: Optional[date] = None @@ -28,6 +31,10 @@ class Planting(SQLModel, table=True): garden_id: int = Field(foreign_key="garden.id", index=True) variety_id: int = Field(foreign_key="plant.id", index=True) cell_id: Optional[int] = Field(default=None, foreign_key="gardencell.id") + cell_ids: Optional[List[int]] = Field( + default=None, + sa_column=Column("cell_ids", SA_JSON, nullable=True), + ) date_semis: Optional[date] = None date_plantation: Optional[date] = None date_repiquage: Optional[date] = None diff --git a/backend/app/routers/gardens.py b/backend/app/routers/gardens.py index 56a3c77..958f025 100644 --- a/backend/app/routers/gardens.py +++ b/backend/app/routers/gardens.py @@ -115,6 +115,19 @@ def create_cell(id: int, cell: GardenCell, session: Session = Depends(get_sessio return cell +@router.put("/gardens/{id}/cells/{cell_id}", response_model=GardenCell) +def update_cell(id: int, cell_id: int, data: GardenCell, session: Session = Depends(get_session)): + c = session.get(GardenCell, cell_id) + if not c or c.garden_id != id: + raise HTTPException(status_code=404, detail="Case introuvable") + for k, v in data.model_dump(exclude_unset=True, exclude={"id", "garden_id"}).items(): + setattr(c, k, v) + session.add(c) + session.commit() + session.refresh(c) + return c + + @router.get("/gardens/{id}/measurements", response_model=List[Measurement]) def list_measurements(id: int, session: Session = Depends(get_session)): return session.exec(select(Measurement).where(Measurement.garden_id == id)).all() diff --git a/backend/app/routers/plantings.py b/backend/app/routers/plantings.py index 033824f..e597607 100644 --- a/backend/app/routers/plantings.py +++ b/backend/app/routers/plantings.py @@ -15,7 +15,11 @@ def list_plantings(session: Session = Depends(get_session)): @router.post("/plantings", response_model=Planting, status_code=status.HTTP_201_CREATED) def create_planting(data: PlantingCreate, session: Session = Depends(get_session)): - p = Planting(**data.model_dump()) + d = data.model_dump() + # Rétro-compatibilité : cell_id = première zone sélectionnée + if d.get("cell_ids") and not d.get("cell_id"): + d["cell_id"] = d["cell_ids"][0] + p = Planting(**d) session.add(p) session.commit() session.refresh(p) @@ -35,7 +39,12 @@ def update_planting(id: int, data: PlantingCreate, session: Session = Depends(ge p = session.get(Planting, id) if not p: raise HTTPException(status_code=404, detail="Plantation introuvable") - for k, v in data.model_dump(exclude_unset=True).items(): + d = data.model_dump(exclude_unset=True) + # Rétro-compatibilité : cell_id = première zone sélectionnée + if "cell_ids" in d: + ids = d["cell_ids"] or [] + d["cell_id"] = ids[0] if ids else None + for k, v in d.items(): setattr(p, k, v) p.updated_at = datetime.now(timezone.utc) session.add(p) diff --git a/backend/app/services/scheduler.py b/backend/app/services/scheduler.py index 4186400..7aaf541 100644 --- a/backend/app/services/scheduler.py +++ b/backend/app/services/scheduler.py @@ -90,6 +90,70 @@ def _store_open_meteo() -> None: logger.info(f"Open-Meteo stocké : {len(rows)} jours") +def backfill_station_missing_dates(max_days_back: int = 365) -> None: + """Remplit les dates manquantes de la station météo au démarrage. + + Cherche toutes les dates sans entrée « veille » dans meteostation + depuis max_days_back jours en arrière jusqu'à hier (excl. aujourd'hui), + puis télécharge les fichiers NOAA mois par mois pour remplir les trous. + Un seul appel HTTP par mois manquant. + """ + from datetime import date, timedelta + from itertools import groupby + from app.services.station import fetch_month_summaries + from app.models.meteo import MeteoStation + from app.database import engine + from sqlmodel import Session, select + + today = date.today() + start_date = today - timedelta(days=max_days_back) + + # 1. Dates « veille » déjà présentes en BDD + with Session(engine) as session: + rows = session.exec( + select(MeteoStation.date_heure).where(MeteoStation.type == "veille") + ).all() + existing_dates: set[str] = {dh[:10] for dh in rows} + + # 2. Dates manquantes entre start_date et hier (aujourd'hui exclu) + missing: list[date] = [] + cursor = start_date + while cursor < today: + if cursor.isoformat() not in existing_dates: + missing.append(cursor) + cursor += timedelta(days=1) + + if not missing: + logger.info("Backfill station : aucune date manquante") + return + + logger.info(f"Backfill station : {len(missing)} date(s) manquante(s) à récupérer") + + # 3. Grouper par (année, mois) → 1 requête HTTP par mois + def month_key(d: date) -> tuple[int, int]: + return (d.year, d.month) + + filled = 0 + for (year, month), group_iter in groupby(sorted(missing), key=month_key): + month_data = fetch_month_summaries(year, month) + if not month_data: + logger.debug(f"Backfill station : pas de données NOAA pour {year}-{month:02d}") + continue + + with Session(engine) as session: + for d in group_iter: + data = month_data.get(d.day) + if not data: + continue + date_heure = f"{d.isoformat()}T00:00" + if not session.get(MeteoStation, date_heure): + session.add(MeteoStation(date_heure=date_heure, type="veille", **data)) + filled += 1 + session.commit() + + logger.info(f"Backfill station terminé : {filled} date(s) insérée(s)") + + def setup_scheduler() -> None: """Configure et démarre le scheduler.""" scheduler.add_job( diff --git a/backend/app/services/station.py b/backend/app/services/station.py index f478db8..fa158de 100644 --- a/backend/app/services/station.py +++ b/backend/app/services/station.py @@ -130,45 +130,63 @@ def fetch_current(base_url: str = STATION_URL) -> dict | None: return None +def _parse_noaa_day_line(parts: list[str]) -> dict | None: + """Parse une ligne de données journalières du fichier NOAA WeeWX. + + Format standard : day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir + """ + if not parts or not parts[0].isdigit(): + return None + # Format complet avec timestamps hh:mm en positions 3 et 5 + if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]: + return { + "temp_ext": _safe_float(parts[1]), + "t_max": _safe_float(parts[2]), + "t_min": _safe_float(parts[4]), + "pluie_mm": _safe_float(parts[8]), + "vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"), + } + # Fallback générique (anciens formats sans hh:mm) + return { + "t_max": _safe_float(parts[1]) if len(parts) > 1 else None, + "t_min": _safe_float(parts[2]) if len(parts) > 2 else None, + "temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None, + "pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None, + "vent_kmh": _safe_float(parts[6]) if len(parts) > 6 else None, + } + + +def fetch_month_summaries(year: int, month: int, base_url: str = STATION_URL) -> dict[int, dict]: + """Récupère tous les résumés journaliers d'un mois depuis le fichier NOAA WeeWX. + + Retourne un dict {numéro_jour: data_dict} pour chaque jour disponible du mois. + Un seul appel HTTP par mois — utilisé pour le backfill groupé. + """ + try: + url = f"{base_url.rstrip('/')}/NOAA/NOAA-{year:04d}-{month:02d}.txt" + r = httpx.get(url, timeout=15) + r.raise_for_status() + + result: dict[int, dict] = {} + for line in r.text.splitlines(): + parts = line.split() + if not parts or not parts[0].isdigit(): + continue + data = _parse_noaa_day_line(parts) + if data: + result[int(parts[0])] = data + return result + + except Exception as e: + logger.warning(f"Station fetch_month_summaries({year}-{month:02d}) error: {e}") + return {} + + 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. """ 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 not parts or not parts[0].isdigit() or int(parts[0]) != day: - continue - - # Format WeeWX NOAA (fréquent) : - # day mean max hh:mm min hh:mm HDD CDD rain wind_avg wind_max hh:mm dir - if len(parts) >= 11 and ":" in parts[3] and ":" in parts[5]: - return { - "temp_ext": _safe_float(parts[1]), - "t_max": _safe_float(parts[2]), - "t_min": _safe_float(parts[4]), - "pluie_mm": _safe_float(parts[8]), - "vent_kmh": _to_kmh(_safe_float(parts[10]), "m/s"), - } - - # Fallback générique (anciens formats) - return { - "t_max": _safe_float(parts[1]) if len(parts) > 1 else None, - "t_min": _safe_float(parts[2]) if len(parts) > 2 else None, - "temp_ext": _safe_float(parts[3]) if len(parts) > 3 else None, - "pluie_mm": _safe_float(parts[5]) if len(parts) > 5 else None, - "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 + month_data = fetch_month_summaries(yesterday.year, yesterday.month, base_url) + return month_data.get(yesterday.day) diff --git a/backend/app/services/yolo_service.py b/backend/app/services/yolo_service.py index f0164e0..34ae50a 100644 --- a/backend/app/services/yolo_service.py +++ b/backend/app/services/yolo_service.py @@ -1,27 +1,47 @@ import os -from typing import List +from typing import List, Optional import httpx -AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://localhost:8070") +AI_SERVICE_URL = os.environ.get("AI_SERVICE_URL", "http://ai-service:8070") -# Mapping class_name YOLO → nom commun français (partiel) -_NOMS_FR = { - "Tomato___healthy": "Tomate (saine)", - "Tomato___Early_blight": "Tomate (mildiou précoce)", - "Tomato___Late_blight": "Tomate (mildiou tardif)", - "Pepper__bell___healthy": "Poivron (sain)", - "Apple___healthy": "Pommier (sain)", - "Potato___healthy": "Pomme de terre (saine)", - "Grape___healthy": "Vigne (saine)", - "Corn_(maize)___healthy": "Maïs (sain)", - "Strawberry___healthy": "Fraisier (sain)", - "Peach___healthy": "Pêcher (sain)", +# Mapping complet class_name YOLO → Infos détaillées +_DIAGNOSTICS = { + "Tomato___healthy": { + "label": "Tomate (saine)", + "conseil": "Votre plant est en pleine forme. Pensez au paillage pour garder l'humidité.", + "actions": ["Pailler le pied", "Vérifier les gourmands"] + }, + "Tomato___Early_blight": { + "label": "Tomate (Alternariose)", + "conseil": "Champignon fréquent. Retirez les feuilles basses touchées et évitez de mouiller le feuillage.", + "actions": ["Retirer feuilles infectées", "Traitement bouillie bordelaise"] + }, + "Tomato___Late_blight": { + "label": "Tomate (Mildiou)", + "conseil": "Urgent : Le mildiou se propage vite avec l'humidité. Coupez les parties atteintes immédiatement.", + "actions": ["Couper parties infectées", "Traitement purin de prêle", "Abriter de la pluie"] + }, + "Pepper__bell___healthy": { + "label": "Poivron (sain)", + "conseil": "Le poivron aime la chaleur et un sol riche.", + "actions": ["Apport de compost", "Arrosage régulier"] + }, + "Potato___healthy": { + "label": "Pomme de terre (saine)", + "conseil": "Pensez à butter les pieds pour favoriser la production de tubercules.", + "actions": ["Butter les pieds"] + }, + "Grape___healthy": { + "label": "Vigne (saine)", + "conseil": "Surveillez l'apparition d'oïdium si le temps est chaud et humide.", + "actions": ["Taille en vert", "Vérifier sous les feuilles"] + }, } async def identify(image_bytes: bytes) -> List[dict]: - """Appelle l'ai-service interne et retourne les détections YOLO.""" + """Appelle l'ai-service interne et retourne les détections YOLO avec diagnostics.""" try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( @@ -36,10 +56,18 @@ async def identify(image_bytes: bytes) -> List[dict]: results = [] for det in data[:3]: cls = det.get("class_name", "") + diag = _DIAGNOSTICS.get(cls, { + "label": cls.replace("___", " — ").replace("_", " "), + "conseil": "Pas de diagnostic spécifique disponible pour cette espèce.", + "actions": [] + }) + results.append({ - "species": cls.replace("___", " — ").replace("_", " "), - "common_name": _NOMS_FR.get(cls, cls.split("___")[0].replace("_", " ")), + "species": cls, + "common_name": diag["label"], "confidence": det.get("confidence", 0.0), + "conseil": diag["conseil"], + "actions": diag["actions"], "image_url": "", }) return results diff --git a/data/jardin.db b/data/jardin.db index 5057da404a4571e2695aaf4b66b4bf4965ae3b5a..87c511b9be16a40a2a020a429ac5080a99f12039 100755 GIT binary patch delta 35718 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 delta 4852 zcmb`KdvFuS9mj7^lJ&U9%J_+m@!2FXCbE5ZvYxh)ToRyT@GH)P5Ce*Q7&WqF>*0>c zfQ^XjaS0e|Czqu8g9fI9+mMD4wrm;-L6eMWLej}hn?GPCLrPkj&_bb@q>yR%WSK#Z z6}N+S=0?7~+uv_L-|zl*&wYK_eWx?`=ci9$7}kug*=9YU(}SsFTSg(M4QsTMG}tv< zq}@U7s>T;%IVCwtC5~akb%o~PpA@o?Gh2Di%2~>JBgeAF@Oy>T+H@330Y@-+6`qIh z!=rE{T*QRn@Q97eHe|@;SV?-&wK=dY*zxRUziYc-G7g`&mC_1$2!q$*MK}o$!H?09 z12BB3p>+$bK=A;)Elsc)-VUcXUWFR0NlT&oTzbi{@-&BDzC?wWd~_Mea^c{x@()r^ z6ATZ2wxe;;7)EU+pCP`~9Mx2*O{x!NW071I?2{d6gqr@9naZ4k0`Sv@$lH2A<6=<_ zSQXJ4!9PnW_)83@Z(#HdSWi!*cwf+&8$db+)EHKTfdXkdRBbIT0uL~z^9*OOvPLUs zE@#aKqs1WBd%?H{htn9m1E=9NH0{eUS0Muc$7M3LS_AK3IJ}O*TZs8OdU^>eghDx6*5A zJ-w7xQ~#nqqkd0~Q3t6#R5#_I)=?&^n4-w9$v=}Hk(12QD3*);J&F~j znX)p#v%Im4<;!@kg=MWQi)YBn)OS1i7D*v?oWU8T zMGT&TLHICzkrvUDei~m}EjH}L^CID$cs*!B{10M#6|y&ECGa&^gujVbf)Bv6*j{X! zK1Dx4-lV>uI>;Bu0wPQ_YrfR%*HozgsD56Zt~#i)D$glf)2^iLNn4>ftJoy}hx`S3 z23puvc)MEc?ZdNL^mgi1sl8@j@uAet*~|5@CR^nVb_>41Q}(4xCz`(MFSOmcd1mOD z<*<4E)*Fl z(XNUG7YAR&A7K0cuhH$wnW5KT#H+M?OXA$cn!WgQbRv#8z8CM9+eZ(5w{LI=cTx#s zBd2!Ze^d2#Q)H16HBbta%Jp~gO!nNkA{8^*+s?*4V8b!(YZ~1_m%zBAqwUcnKH*7w z@zz+g;}_SwmAU=m-AHpW;udtnSl-@*#QHLpvrWeXl^`uqvt5Z#gzbyn&q@5WJ!|H< z?VHX+pK5Bl7E0`;rNkhfSFG8CYc|-viNh7o{K{wP=Oo)?&zEv^9=3hGUBY~Rp=4s~ zE_uj@e3wqx!T-jvg^(Cj0OTi_X0YeX6ox%+8Wqb>j!LB2 z&irM@A0WB2=B?y27rA}kU1z`Ahd)t}uzun`qP-T`l9SddD)-|RXd@~~8)$tQN0%(p z`m+3RX@=@4EDgVaQST8yQ`afh;}?{#qt~YJ$b~cV({f^$B|9I>e^lDG#gQrbN^#LI zbik;f~%3bv+{9c2^qk&SUm>o=!4W(6tDa{@h-7U<5o|q{*yMKSSh=W z9|t{HJsR=Q*3fb-jfUGok7BhsbsI6PMd;{sI|2d|h`!<6CNO2p4}yV!qg`0-i;i{$ z1&>o;e1g;4(c$p4MMniI!+qX2tY@}*UH%GZkJBw^8iE0r+s}BSqfWu^ces3l*f0qm zsD9j!!dRIOZ(H<;TVUFwqv#c%!|n2;bkT#1qs`G75PXbZaC zx-8G^)tf8LyguMS6WdrEh|jcG#}vo9q(6S-@U^)-#jP7T9mDGwy^g8WF$NuD)G;O< zW7aVi9mBEcLeDwAHDrbo{$ru0Oj&GN5$#f=0MfYbUWIE^H%t8-bsxH1`}^dX@xrpNrR@{r3+}_@&uxz`97Br z^v$osJjRcl1W(_;@lE4{1J;zF@+_CqnqQMKgPPpz zmS6xCx`gT|K%PqnS(2(hFh2K$e|sG8sla9p1_PVQHQusF!miz&pLC7a`h4CvYsNfZ zbD}#kx1a|iV0{W;^%k?qP?<{Ds`MCa$dwefQCiM3#?A}7X#(WId%xZMRz^H`NuDQ; SOn}Ar58b4<@Eo7YUF^RB7!gqb diff --git a/data/jardin.db-shm b/data/jardin.db-shm index 60b7eac4c1debeb5bd23a6d6339c93d30df08940..aeab45e7c96ab298e546821a9643f3f12b7fa11d 100755 GIT binary patch delta 201 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 bSaHpoQ*`|1iut08$FXE*iM^$gZT7zbx^TlP delta 1711 zcmb7DZERCj7(TaOx9z#-oQ-X|ty;DM0wdjSZ#%kfF82^6TL^B@RFG-Ml$1IxtTwDM zTVjnuO!x?rniG@wiq6Ebxqq#7s6B>b=onE7$;M`u@Jrf+g{ zPTqT;=Xsy^eeabVcdIWG%-G%l-tVgcCvBDKVd@8W6v89tBS!*Agfj^a-2$8PLEzoDDxbMz59 zfexWGdLC^?LF7TLNVq5bAbcrY6iy3Ag&`p(Y!TXo$Aoe?4}XGR!;j$^sKD3A6znIn z zp5u5sU(P-RMXZv@D1%odA}B2-JOps$(5DsS$R@K@)cvQ zsWC~Vnh_SG3_7hrs6}G{4BA!%Rh<>&Tq8vTheZjo|j(|CQ32zhf=ts0i*e^T^voP%Z#rd|g$#LB=>?pUNu($G)eAG5;J7jCH zUbXgG?pXF)Dvq6OGsPWB)7GkppCY|)w81E+QE#mIiGjg%EVb_$>UNKBy?tQ0JfFXO z^tm;2MaI}n*@F4S#^zwq9|)||jK-ZR@%F$1aic+8tDgnpD6O+<%<>;9Z+>DaV%CPZ zUFB9n(UszX|Ame78gMMyR<5mLaoNh9=u3F96J>Yrp3>AOJ0D1e`gWCkl}(0k*)wS* z)blL3wH(hswcsmAtBqDP={=XP8od|wwQApz80?SLB_`gFPn=1`wo{L>cBWj%xyqs3 z0@DK?^wy_|rkhzL?DvX3dCnkOt*@FqYZPMaUWsrItDjsM Mp~Xw5&k%sW0Z4NY761SM diff --git a/frontend/index.html b/frontend/index.html index 19ca06b..6bce616 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,10 @@ 🌿 Jardin + + + + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..14192f8 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + 🌿 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ed0a3f8..b438b5e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,9 @@ Disk {{ debugDiskLabel }} + + + @@ -38,8 +41,12 @@ -
- +
+ + + + +
@@ -49,6 +56,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { RouterLink, RouterView } from 'vue-router' import AppHeader from '@/components/AppHeader.vue' import AppDrawer from '@/components/AppDrawer.vue' +import ToastNotification from '@/components/ToastNotification.vue' import { meteoApi } from '@/api/meteo' import { settingsApi, type DebugSystemStats } from '@/api/settings' import { applyUiSizesToRoot } from '@/utils/uiSizeDefaults' diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5e09632..1478479 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,5 +1,37 @@ -import axios from 'axios' +import axios, { type AxiosError } from 'axios' +import { useToast } from '@/composables/useToast' -export default axios.create({ +const client = axios.create({ baseURL: import.meta.env.VITE_API_URL ?? '', }) + +client.interceptors.response.use( + response => response, + (error: AxiosError<{ detail?: string; message?: string }>) => { + const { error: showError } = useToast() + + if (error.response) { + const status = error.response.status + const detail = error.response.data?.detail ?? error.response.data?.message + + if (status === 422) { + const msg = Array.isArray(detail) + ? (detail as Array<{ msg?: string }>).map(d => d.msg ?? d).join(', ') + : (detail ?? 'Vérifiez les champs du formulaire') + showError(`Données invalides : ${msg}`) + } else if (status === 404) { + showError('Ressource introuvable') + } else if (status >= 500) { + showError(`Erreur serveur (${status}) — réessayez dans un instant`) + } else if (status !== 401 && status !== 403) { + showError(String(detail ?? `Erreur ${status}`)) + } + } else if (error.request) { + showError('Serveur inaccessible — vérifiez votre connexion réseau') + } + + return Promise.reject(error) + }, +) + +export default client diff --git a/frontend/src/api/gardens.ts b/frontend/src/api/gardens.ts index 84656ca..cbe1cac 100644 --- a/frontend/src/api/gardens.ts +++ b/frontend/src/api/gardens.ts @@ -56,6 +56,10 @@ export const gardensApi = { }, delete: (id: number) => client.delete(`/api/gardens/${id}`), cells: (id: number) => client.get(`/api/gardens/${id}/cells`).then(r => r.data), + createCell: (id: number, cell: Partial) => + client.post(`/api/gardens/${id}/cells`, cell).then(r => r.data), + updateCell: (id: number, cellId: number, cell: Partial) => + client.put(`/api/gardens/${id}/cells/${cellId}`, cell).then(r => r.data), measurements: (id: number) => client.get(`/api/gardens/${id}/measurements`).then(r => r.data), addMeasurement: (id: number, m: Partial) => client.post(`/api/gardens/${id}/measurements`, m).then(r => r.data), diff --git a/frontend/src/api/identify.ts b/frontend/src/api/identify.ts new file mode 100644 index 0000000..8ed4300 --- /dev/null +++ b/frontend/src/api/identify.ts @@ -0,0 +1,25 @@ +import client from './client' + +export interface DiagnosticResult { + species: string + common_name: string + confidence: number + conseil: string + actions: string[] + image_url?: string +} + +export interface IdentifyResponse { + source: 'plantnet' | 'yolo' | 'cache' + results: DiagnosticResult[] +} + +export const identifyApi = { + identify: (file: File) => { + const formData = new FormData() + formData.append('file', file) + return client.post('/api/identify', formData, { + headers: { 'Content-Type': 'multipart/form-data' } + }).then(r => r.data) + } +} diff --git a/frontend/src/api/plantings.ts b/frontend/src/api/plantings.ts index 0d2aaa7..5786ba1 100644 --- a/frontend/src/api/plantings.ts +++ b/frontend/src/api/plantings.ts @@ -5,6 +5,7 @@ export interface Planting { garden_id: number variety_id: number cell_id?: number + cell_ids?: number[] // multi-sélect zones date_plantation?: string quantite: number statut: string diff --git a/frontend/src/components/DiagnosticModal.vue b/frontend/src/components/DiagnosticModal.vue new file mode 100644 index 0000000..e4c91e5 --- /dev/null +++ b/frontend/src/components/DiagnosticModal.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/components/ToastNotification.vue b/frontend/src/components/ToastNotification.vue new file mode 100644 index 0000000..34b6152 --- /dev/null +++ b/frontend/src/components/ToastNotification.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/frontend/src/composables/useToast.ts b/frontend/src/composables/useToast.ts new file mode 100644 index 0000000..8635fca --- /dev/null +++ b/frontend/src/composables/useToast.ts @@ -0,0 +1,38 @@ +import { reactive } from 'vue' + +export type ToastType = 'success' | 'error' | 'warning' | 'info' + +export interface Toast { + id: number + type: ToastType + message: string + duration: number +} + +const toasts = reactive([]) +let nextId = 1 + +function add(message: string, type: ToastType = 'info', duration = 4000): number { + const id = nextId++ + toasts.push({ id, type, message, duration }) + if (duration > 0) { + setTimeout(() => remove(id), duration) + } + return id +} + +function remove(id: number) { + const index = toasts.findIndex(t => t.id === id) + if (index !== -1) toasts.splice(index, 1) +} + +export function useToast() { + return { + toasts, + success: (msg: string, duration?: number) => add(msg, 'success', duration ?? 4000), + error: (msg: string, duration?: number) => add(msg, 'error', duration ?? 6000), + warning: (msg: string, duration?: number) => add(msg, 'warning', duration ?? 5000), + info: (msg: string, duration?: number) => add(msg, 'info', duration ?? 4000), + remove, + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index c6adb2a..b4df7a2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,5 +3,8 @@ import { createPinia } from 'pinia' import App from './App.vue' import router from './router' import './style.css' +import { registerSW } from 'virtual:pwa-register' + +registerSW({ immediate: true }) createApp(App).use(createPinia()).use(router).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css index 0433b1d..21a6144 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -2,13 +2,56 @@ @tailwind components; @tailwind utilities; -body { - @apply bg-bg text-text font-mono; - min-height: 100vh; +@layer base { + html { + font-size: var(--ui-font-size, 14px); + } + body { + @apply bg-bg text-text font-mono selection:bg-yellow/30; + min-height: 100vh; + } } +@layer components { + /* Cartes avec effet 70s */ + .card-jardin { + @apply bg-bg-soft border border-bg-hard rounded-xl p-4 shadow-sm + transition-all duration-300 hover:shadow-lg hover:border-text-muted/30; + } + + /* Boutons stylisés */ + .btn-primary { + @apply bg-yellow text-bg px-4 py-2 rounded-lg font-bold text-sm + transition-transform active:scale-95 hover:opacity-90; + } + + .btn-outline { + @apply border border-bg-hard text-text-muted px-4 py-2 rounded-lg text-sm + hover:bg-bg-hard hover:text-text transition-all; + } + + /* Badges colorés */ + .badge { + @apply px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider; + } + .badge-green { @apply bg-green/20 text-green; } + .badge-yellow { @apply bg-yellow/20 text-yellow; } + .badge-blue { @apply bg-blue/20 text-blue; } + .badge-red { @apply bg-red/20 text-red; } + .badge-orange { @apply bg-orange/20 text-orange; } +} + +/* Transitions de pages */ +.fade-enter-active, .fade-leave-active { + transition: opacity 0.2s ease, transform 0.2s ease; +} +.fade-enter-from { opacity: 0; transform: translateY(5px); } +.fade-leave-to { opacity: 0; transform: translateY(-5px); } + * { box-sizing: border-box; } +/* Custom scrollbar */ ::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar-track { background: #1d2021; } ::-webkit-scrollbar-thumb { background: #504945; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #665c54; } diff --git a/frontend/src/utils/uiSizeDefaults.ts b/frontend/src/utils/uiSizeDefaults.ts index 1e42c19..30c85d7 100644 --- a/frontend/src/utils/uiSizeDefaults.ts +++ b/frontend/src/utils/uiSizeDefaults.ts @@ -3,6 +3,8 @@ export const UI_SIZE_DEFAULTS: Record = { ui_menu_font_size: 13, ui_menu_icon_size: 18, ui_thumb_size: 96, + ui_weather_icon_size: 48, + ui_dashboard_icon_size: 24, } export function applyUiSizesToRoot(data: Record): void { diff --git a/frontend/src/views/AstucesView.vue b/frontend/src/views/AstucesView.vue index f23f1a6..b956d80 100644 --- a/frontend/src/views/AstucesView.vue +++ b/frontend/src/views/AstucesView.vue @@ -1,204 +1,200 @@