Files
jardin/docs/plans/2026-03-08-plant-associations.md
2026-03-08 10:04:14 +01:00

8.1 KiB

Plant Associations Implementation Plan

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

Goal: Ajouter des associations favorables/défavorables (plantes amies/ennemies) à chaque plante, éditables depuis la popup d'édition.

Architecture: Deux colonnes JSON (associations_favorables, associations_defavorables) dans le modèle Plant (List[str] de noms communs). Migration via migrate.py. UI tag-based avec autocomplete et validation croisée dans PlantesView.vue.

Tech Stack: FastAPI + SQLModel + SQLAlchemy JSON column (backend) · Vue 3 + TypeScript (frontend)


Task 1: Backend — modèle Plant

Files:

  • Modify: backend/app/models/plant.py

Step 1: Ajouter les imports nécessaires

from typing import List, Optional
from sqlalchemy import Column
from sqlalchemy import JSON as SA_JSON

Step 2: Ajouter les 2 champs dans la classe Plant

Après le champ notes, avant created_at :

associations_favorables: Optional[List[str]] = Field(
    default=None,
    sa_column=Column("associations_favorables", SA_JSON, nullable=True),
)
associations_defavorables: Optional[List[str]] = Field(
    default=None,
    sa_column=Column("associations_defavorables", SA_JSON, nullable=True),
)

Task 2: Backend — migration

Files:

  • Modify: backend/app/migrate.py

Step 1: Ajouter les 2 colonnes dans EXPECTED_COLUMNS["plant"]

"plant": [
    ("categorie", "TEXT", None),
    ("hauteur_cm", "INTEGER", None),
    ("maladies_courantes", "TEXT", None),
    ("astuces_culture", "TEXT", None),
    ("url_reference", "TEXT", None),
    ("associations_favorables", "TEXT", None),   # JSON list[str]
    ("associations_defavorables", "TEXT", None),  # JSON list[str]
],

Task 3: Frontend — interface TypeScript

Files:

  • Modify: frontend/src/api/plants.ts

Step 1: Ajouter les 2 champs à l'interface Plant

associations_favorables?: string[]
associations_defavorables?: string[]

Task 4: Frontend — PlantesView.vue (formulaire + détail)

Files:

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

Step 1: Étendre form reactive

Dans const form = reactive({...}) ajouter :

associations_favorables: [] as string[],
associations_defavorables: [] as string[],

Step 2: Ajouter ref pour l'autocomplete

const assocInput = reactive({ fav: '', def: '' })

Step 3: Computed — noms de plantes disponibles pour l'autocomplete

const allPlantNames = computed(() =>
  plantsStore.plants
    .map(p => p.nom_commun)
    .filter(n => n && n !== form.nom_commun)
    .sort()
)

function filteredAssocSuggestions(type: 'fav' | 'def') {
  const query = assocInput[type].toLowerCase()
  const excluded = type === 'fav'
    ? new Set([...form.associations_favorables, ...form.associations_defavorables])
    : new Set([...form.associations_defavorables, ...form.associations_favorables])
  return allPlantNames.value
    .filter(n => !excluded.has(n) && n.toLowerCase().includes(query))
    .slice(0, 8)
}

function addAssoc(type: 'fav' | 'def', name: string) {
  const list = type === 'fav' ? form.associations_favorables : form.associations_defavorables
  const other = type === 'fav' ? form.associations_defavorables : form.associations_favorables
  if (!name.trim() || list.includes(name) || other.includes(name)) return
  list.push(name)
  assocInput[type] = ''
}

function removeAssoc(type: 'fav' | 'def', name: string) {
  const list = type === 'fav' ? form.associations_favorables : form.associations_defavorables
  const idx = list.indexOf(name)
  if (idx !== -1) list.splice(idx, 1)
}

Step 4: startEdit — peupler les nouvelles listes

associations_favorables: [...(p.associations_favorables ?? [])],
associations_defavorables: [...(p.associations_defavorables ?? [])],

Step 5: submitPlant — inclure les listes dans le payload

Le spread { ...form } les inclut automatiquement — rien à changer.

Step 6: Ajouter le bloc UI dans le formulaire (pleine largeur, après les 2 colonnes existantes)

<!-- Associations — pleine largeur -->
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-6">
  <!-- Favorables -->
  <div>
    <label class="text-[10px] font-black text-green uppercase tracking-widest block mb-2">
      🤝 Associations favorables
    </label>
    <div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
      <span v-for="n in form.associations_favorables" :key="n"
        class="flex items-center gap-1 bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full">
        {{ n }}
        <button type="button" @click="removeAssoc('fav', n)" class="hover:text-red leading-none"></button>
      </span>
    </div>
    <div class="relative">
      <input v-model="assocInput.fav"
        placeholder="Nom commun d'une plante..."
        class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-green outline-none"
        @keydown.enter.prevent="addAssoc('fav', assocInput.fav)" />
      <ul v-if="assocInput.fav && filteredAssocSuggestions('fav').length"
        class="absolute z-10 mt-1 w-full bg-bg-hard border border-bg-soft rounded-xl shadow-lg overflow-hidden max-h-40 overflow-y-auto">
        <li v-for="s in filteredAssocSuggestions('fav')" :key="s"
          @click="addAssoc('fav', s)"
          class="px-3 py-2 text-sm text-text hover:bg-green/10 hover:text-green cursor-pointer">
          {{ s }}
        </li>
      </ul>
    </div>
  </div>

  <!-- Défavorables -->
  <div>
    <label class="text-[10px] font-black text-red uppercase tracking-widest block mb-2">
      ⚡ Associations défavorables
    </label>
    <div class="flex flex-wrap gap-1.5 mb-2 min-h-[28px]">
      <span v-for="n in form.associations_defavorables" :key="n"
        class="flex items-center gap-1 bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full">
        {{ n }}
        <button type="button" @click="removeAssoc('def', n)" class="hover:text-red leading-none"></button>
      </span>
    </div>
    <div class="relative">
      <input v-model="assocInput.def"
        placeholder="Nom commun d'une plante..."
        class="w-full bg-bg border border-bg-soft rounded-xl px-3 py-2 text-text text-sm focus:border-red outline-none"
        @keydown.enter.prevent="addAssoc('def', assocInput.def)" />
      <ul v-if="assocInput.def && filteredAssocSuggestions('def').length"
        class="absolute z-10 mt-1 w-full bg-bg-hard border border-bg-soft rounded-xl shadow-lg overflow-hidden max-h-40 overflow-y-auto">
        <li v-for="s in filteredAssocSuggestions('def')" :key="s"
          @click="addAssoc('def', s)"
          class="px-3 py-2 text-sm text-text hover:bg-red/10 hover:text-red cursor-pointer">
          {{ s }}
        </li>
      </ul>
    </div>
  </div>
</div>

Step 7: Ajouter le bloc lecture dans la modale détail (après la section Notes)

<!-- Associations -->
<div v-if="detailPlant.associations_favorables?.length || detailPlant.associations_defavorables?.length" class="space-y-3">
  <h3 class="text-[10px] font-black text-text-muted uppercase tracking-widest">Associations</h3>
  <div class="grid grid-cols-2 gap-4">
    <div v-if="detailPlant.associations_favorables?.length">
      <div class="text-[10px] font-black text-green uppercase mb-1.5">🤝 Favorables</div>
      <div class="flex flex-wrap gap-1.5">
        <span v-for="n in detailPlant.associations_favorables" :key="n"
          class="bg-green/10 border border-green/40 text-green text-[11px] px-2 py-0.5 rounded-full">
          {{ n }}
        </span>
      </div>
    </div>
    <div v-if="detailPlant.associations_defavorables?.length">
      <div class="text-[10px] font-black text-red uppercase mb-1.5">⚡ À éviter</div>
      <div class="flex flex-wrap gap-1.5">
        <span v-for="n in detailPlant.associations_defavorables" :key="n"
          class="bg-red/10 border border-red/40 text-red text-[11px] px-2 py-0.5 rounded-full">
          {{ n }}
        </span>
      </div>
    </div>
  </div>
</div>